vignette/docs/decisions/backend-node-transition.md
2026-06-28 21:50:21 +09:00

7.1 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/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()

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. A future Node.js gateway must preserve these shapes.

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, 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, 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.
  • 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.

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/routes/personas.py keeps route decorators, auth and teacher/admin gates, repository calls, RAG source registration, LLM draft generation, 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.

Evidence

  • python -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
  • node scripts/check-engine-gateway-contract.mjs --json
  • python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py -q — 19 passed
  • python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q — 50 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
  • python -B -m py_compile app/persona_read_model.py app/routes/personas.py
  • python -B -m pytest -p no:cacheprovider app/test_persona_review.py -q — 33 passed
  • python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_session_turn_persistence.py -q — 55 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. Leave admin.py mapper cleanup for later because RBAC, audit, and RLS blast radius is larger.