vignette/docs/decisions/backend-node-transition.md
2026-06-29 08:12:14 +09:00

99 lines
9.4 KiB
Markdown

# Backend Node.js Transition Decision
Date: 2026-06-28
## Decision
Vignette keeps the current Python/FastAPI backend for the near-term delivery path, but the replacement target for new replaceable backend infrastructure is Node.js.
This is not a full rewrite approval. The migration path is contract-first and strangler-style:
- Keep the FastAPI core while delivery-critical behavior is still being closed.
- Move cross-runtime boundaries into explicit contracts before extracting services.
- Prefer Node.js for new replaceable services or gateway implementations once the contract is stable.
- Do not weaken existing fail-closed security, persona catalog, DB/RLS, audit, or PII masking behavior to make migration easier.
## Rationale
The project proposal and team capability point toward Node.js, but a direct Python-to-Node rewrite during the current schedule would add delivery and staffing risk. The safer path is to make Python replaceable by pinning the wire contracts first.
## First Boundary
The first implementation boundary is the engine gateway contract:
- `POST /v1/generate`
- `POST /v1/stream`
- SSE events: `token`, `done`, `error`
- request/response models owned by `apps/api/app/contracts/engine_gateway.py`
- gateway SSE line parsing owned by `EngineGatewaySseLineDecoder` behind `EngineClient.stream_packets()`
- default-routing sentinel `gateway-default` normalized by `normalize_engine_gateway_model()`
- structured response fallback owned by `structured_payload_from_response()`
- current-turn prompt split owned by the Python gateway `GatewayPromptParts(system_prompt, user_payload)` boundary
Both the FastAPI engine client and the current Python engine gateway import this same contract. The API orchestrator consumes decoded `EngineGatewaySsePacket` values from `EngineClient.stream_packets()` instead of hand-parsing `event:`/`data:` lines. The current Python gateway also returns `/v1/generate` through `GenerateResponse.model_dump()` rather than a hand-mirrored dict literal, and its `provider/model` response metadata is owned by one helper path. `GatewayPromptParts` names the current-turn prompt boundary: non-empty system messages are joined for `--system-prompt`, the final user message is sent as the turn payload, and missing-user generate/stream requests fail with 400 before session resolution. Evaluator, live-coach, and persona generation call `structured_payload_from_response()` directly, so a future Node.js gateway must preserve the structured field and legacy text-JSON fallback behavior.
The cross-runtime golden fixture is `apps/api/engine_gateway/golden/engine_gateway_contract.v1.json`, backed by `apps/api/engine_gateway/golden/engine_gateway_schema.v1.json`.
The fixture pins a representative `/v1/generate` request/response, stream `token/done/error` frames, decoded packet payloads, the `gateway-default` default-routing sentinel, and the provider `[DONE]` compatibility line. The schema artifact pins the same request/response/event shapes without requiring Python imports. The Python contract test validates that the schema artifact matches the current Pydantic contract and that the fixture passes both JSON Schema and the runtime SSE decoder. `scripts/check-engine-gateway-contract.mjs` reads the same artifacts from Node.js, decodes the SSE frames, ignores provider `[DONE]` compatibility lines, verifies that `gateway-default` appears only as the request routing sentinel and not as a resolved response model, and proves packet-level equivalence without importing Python code. A future Node.js gateway should keep this runner green and then add live endpoint conformance on top of it.
For `/v1/stream`, gateway-to-API SSE frames are:
- `event: token` with `data: {"text": "..."}`
- `event: done` with `data: {"provider": "...", "model": "...", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "turns": 0}`
- `event: error` with `data: {"detail": "..."}`
Provider pass-through sentinels such as `data: [DONE]` are ignored compatibility lines, not completion packets. A Node.js gateway must translate provider completion into the explicit `event: done` JSON frame above.
## Invariants
- `/personas` normal state remains `source:"database", degraded:false`; `seed_fallback` is not normal.
- Browser credentials and BFF session behavior stay unchanged.
- Browser-facing `/sessions/{id}/stream` SSE stays a separate app contract; the engine gateway stream uses JSON token payloads.
- Browser-facing session list/dashboard/detail/review/share response DTOs stay stable across the Python route and any future Node read API.
- Browser-facing persona catalog/review/draft/source/evidence response DTOs stay stable across the Python route and any future Node read API.
- Persona draft generation keeps the same structured schema, prompt bundle id/version/hash, output extraction, and default coercion semantics.
- Session evaluation persistence keeps the same `status/source/scope/stage/payload/error` write packet semantics.
- RBAC, RLS, audit logging, and PII masking stay fail-closed.
- OpenAPI generated frontend contracts stay current.
- Existing ports and local/prod startup paths stay compatible until a service is deliberately replaced.
## Second Boundary
The second internal boundary is the browser-facing session read model.
`apps/api/app/session_read_model.py` now owns the session list, learner dashboard, session detail, session review, worksheet, teacher-review status, and share payload DTOs plus deterministic builders. `apps/api/app/routes/sessions.py` keeps route decorators, auth/role checks, consent/onboarding gates, RLS-backed persistence reads, mutations, streaming, and session lifecycle behavior.
This preserves the current FastAPI delivery path while making a future Node.js read API mirror one module's browser contract instead of re-deriving response shapes from mixed route code. Hidden/evaluator-only turn filtering, saved worksheet precedence, teacher read-only review status, and public share sanitization remain invariants.
## Third Boundary
The third internal boundary is the browser-facing persona read model plus persona draft generation contract.
`apps/api/app/persona_read_model.py` now owns persona catalog, review queue, draft payload/detail, source document, generation evidence, and draft generation response DTOs plus deterministic mappers. `apps/api/app/persona_generation_contract.py` owns the draft structured schema, prompt bundle id/version/hash, `GenerateResponse` payload extraction, and generated draft default/coercion rules. `apps/api/app/routes/personas.py` keeps route decorators, auth and teacher/admin gates, repository calls, RAG source registration, engine invocation, provenance assembly, and HTTP error mapping.
This keeps `/personas` fail-closed semantics stable: normal catalog entries remain `source:"database", degraded:false`, seed fallback remains degraded, and OpenAPI schema names such as `PersonaSummary`, `PersonaReviewSummary`, `PersonaDraftPayload`, and `PersonaDraftDetail` stay unchanged.
## Fourth Boundary
The fourth internal boundary is the session evaluation write packet.
`apps/api/app/session_persistence.py` now exposes `SessionEvaluationWrite` for `app.session_evaluation` writes. `routes/sessions.py` and `routes/eval.py` construct the named packet instead of passing an anonymous keyword bag, while the SQL columns, fallback cache record, status/source/scope/stage semantics, and saved payload shape stay unchanged.
## Evidence
- `py -3.11 -X utf8 -B -m py_compile app/contracts/engine_gateway.py app/engine_client.py app/services/orchestrator.py engine_gateway/gateway.py engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py app/persona_generation_contract.py app/persona_read_model.py app/routes/personas.py app/session_persistence.py app/routes/sessions.py app/routes/eval.py app/services/evaluator.py app/services/live_coach.py`
- `node scripts/check-engine-gateway-contract.mjs --json`
- `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py -q` — 27 passed
- `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_evaluation_persistence.py app/test_evaluator_model_routing.py app/test_session_turn_persistence.py app/test_live_coach_sources.py app/test_persona_generation_contract.py app/test_persona_review.py -q` — 120 passed
- `apps/api/engine_gateway/golden/engine_gateway_contract.v1.json` and `apps/api/engine_gateway/golden/engine_gateway_schema.v1.json` validated by `engine_gateway/test_gateway_model.py`
- `python -B -m py_compile app/session_read_model.py app/routes/sessions.py`
- `python -B -m pytest -p no:cacheprovider app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_session_share.py app/test_learner_dashboard.py app/test_rbac_idor.py app/test_teacher_dashboard.py -q` — 48 passed
- `py -3.11 -X utf8 -B -m py_compile app/persona_generation_contract.py app/persona_read_model.py app/routes/personas.py app/test_persona_generation_contract.py app/test_persona_review.py`
- `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_persona_generation_contract.py app/test_persona_review.py -q` — 39 passed
- `npm run check:api-types`
- `npm run typecheck`
## Next Refactor Candidates
1. Add live Node.js gateway endpoint conformance once a Node gateway implementation exists; the artifact runner is now in place.
2. Extract a `TurnEvaluationPayload` adapter after pinning the current dict shape in tests.
3. Leave `admin.py` mapper cleanup for later because RBAC, audit, and RLS blast radius is larger.