9.4 KiB
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/generatePOST /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
EngineGatewaySseLineDecoderbehindEngineClient.stream_packets() - default-routing sentinel
gateway-defaultnormalized bynormalize_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: tokenwithdata: {"text": "..."}event: donewithdata: {"provider": "...", "model": "...", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "turns": 0}event: errorwithdata: {"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
/personasnormal state remainssource:"database", degraded:false;seed_fallbackis not normal.- Browser credentials and BFF session behavior stay unchanged.
- Browser-facing
/sessions/{id}/streamSSE 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/errorwrite 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.pynode scripts/check-engine-gateway-contract.mjs --jsonpy -3.11 -X utf8 -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py -q— 27 passedpy -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 passedapps/api/engine_gateway/golden/engine_gateway_contract.v1.jsonandapps/api/engine_gateway/golden/engine_gateway_schema.v1.jsonvalidated byengine_gateway/test_gateway_model.pypython -B -m py_compile app/session_read_model.py app/routes/sessions.pypython -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 passedpy -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.pypy -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_persona_generation_contract.py app/test_persona_review.py -q— 39 passednpm run check:api-typesnpm run typecheck
Next Refactor Candidates
- Add live Node.js gateway endpoint conformance once a Node gateway implementation exists; the artifact runner is now in place.
- Extract a
TurnEvaluationPayloadadapter after pinning the current dict shape in tests. - Leave
admin.pymapper cleanup for later because RBAC, audit, and RLS blast radius is larger.