Server-anchored delivery — phased implementation plan
Companion to ADR-0019. Design only; nothing here is built yet. Vocabulary is neutral (Offering, Learning Unit, Assessment, Item Version, Item Session, Test Session, Module Package). Effort is rough (S ≈ ≤1d, M ≈ 2–4d, L ≈ 1–2wk) and indicative only.
Phase ordering and why
Section titled “Phase ordering and why”The prompt floated a → b → c → d. We invert the last two: a → b → d → c.
- (d) server-side general scoring is the prerequisite for (c) key-withholding. There is no point withholding answer keys from the client until the server can score every item type without the client’s committed verdict. Withholding keys before (d) would silently break scoring for every non-PCI item type.
- (d) also delivers the v1 value on its own — authoritative, tamper-resistant grading — while keys still sync. That is exactly the v1 scope Anton chose (defer key-secrecy).
- (c) is the largest and riskiest piece and the only one touching pgxsinkit. Pushing it last means v1 ships with zero pgxsinkit change and (c) becomes a clean, isolated hardening phase on top of a server that already scores authoritatively.
Each phase is independently shippable behind the existing delivery_profile default of
offline-complete, so nothing changes for current content until an author opts in.
A authoring ──► B learner block-UX + grade-flip ──► D general server scoring ──► C key-withholding(emergent) (emergent) (emergent + conform-ed) (pgxsinkit + emergent) S M M–L L low risk med risk med risk high riskPhase A — Authoring: surface delivery_profile, frozen on publish
Section titled “Phase A — Authoring: surface delivery_profile, frozen on publish”Goal: a teacher can declare an Assessment server-anchored; the choice freezes at
publish (ADR-0019 decision 1).
Touches: emergent only (apps/teacher-portal authoring UI + the publish path in
apps/api). No schema migration — the enum/column already exist.
Work:
- Teacher authoring control (Mantine
SegmentedControl/Radio.Group) on the Assessment editor, editable only whilestate = draft, read-only oncepublished. Copy explains the trade-off in neutral terms (instant offline grade vs server-graded secure test). - Publish path asserts immutability: reject a
delivery_profilechange on a published Assessment (publish a new version instead, per ADR-0003/0019). Backstop with a state-transition check; do not edit generated migrations. - Keep
scoring_policyconsistent with the profile (ADR-0019 consequence): derive/validate rather than letting the two contradict.
Gate: unit tests on the publish immutability rule; authoring component test. Risk: low. Effort: S. Ship criterion: authors can set+freeze the profile; no runtime behaviour changes yet (player still treats everything as offline-complete).
Phase B — Learner UX: grade-flip + connectivity gate (secure test)
Section titled “Phase B — Learner UX: grade-flip + connectivity gate (secure test)”Goal: for a server-anchored Assessment the learner gets no local grade, finalize
is required, and the server-authoritative steps gate on connectivity (ADR-0019
decision 2 — a test completable fully offline is not secure).
Touches: emergent only — apps/learner-web player/sync
(features/assessment/TestPlayer.tsx, routes/offerings.$offeringId.tsx,
features/workflow/lib/sync.tsx).
Work:
- Branch on the synced
delivery_profile(already onassessment_definition, already in PGlite). Forserver-anchored:- Require connectivity to begin an Attempt (recommended) and to submit; surface a connection required state when offline rather than starting/finishing on stale state. Reuse the existing online/offline signal the sync layer already exposes.
- On finish: do not call
submitQtiAttemptwith a client aggregate as the grade of record. Instead transition the Test Session to submitted-for-grading and call finalize as a required step (await success; explicit retry on transient failure), then show the server grade once it returns / syncs back. - Player result region shows submitted — awaiting grade (never a fabricated/zero/local grade) until the server grade is present.
offline-completepath is untouched: instant local grade, best-effort finalize.- i18n strings for the new states (submitted-for-grading, connection-required, awaiting-grade, retry).
Note: in v1 keys still sync, so the client could compute a local score — Phase B’s job is to make sure it is never treated as the grade of record for server-anchored, and that the secure steps gate on connectivity. The actual withholding is Phase C.
Gate: player tests for both profiles; a test that server-anchored refuses to finish/grade offline; offline-complete regression stays green. Risk: medium (touches the live player + sync state machine). Effort: M. Ship criterion: server-anchored Assessments are connectivity-gated and show only the server grade; offline-complete unchanged.
Phase D — Run conform-ed Response Processing server-side (the authoritative core)
Section titled “Phase D — Run conform-ed Response Processing server-side (the authoritative core)”Not a new scoring engine. “General server scoring” = running conform-ed’s existing Response Processing on the server for all item types, instead of the current PCI-only special case. The engine is conform-ed’s and stays there; emergent writes only thin orchestration. This is the anti-tamper substance and the prerequisite for Phase C.
What already exists in conform-ed (verified, all React-free): item RP
(executeResponseProcessing), the standard scoring templates (scoreResponse /
matchCorrect / mapResponse / mapResponsePoint, incl. maxScore derivation),
test-level outcome processing (runOutcomeProcessing in test/controller.ts), and the
customOperators extension point. There is essentially no generic scoring left to
write — only an export widening.
Touches: conform-ed (one additive export change) + emergent (thin finalize orchestration; delete duplicated mapping).
Upstream (conform-ed) — specify, do not build this session:
- Widen the
@conform-ed/qti-react/headlessexport to expose the already-React-free engine:executeResponseProcessing, the scoring templates (scoreResponse/matchCorrect/mapResponse/mapResponsePoint), and the test-levelcreateTestController/ outcome-processing surface (assessmentItemViewFromNormalizedis already headless). These are currently exported only from the package root, which pulls React for a server importer. No new logic — only a server-safe entry. Verified React-free: therp/import closure (interpreter,evaluate,values,templates,lookup-table,response-processing,graphic,types,capability) andtest/controller.tscontain noreact/react-domimport. - Publish + bump the conform-ed dependency in emergent (per repo rule: fix/extend upstream, then upgrade here).
emergent finalize work (apps/api/src/features/assessment-finalize) — orchestration
only:
- Replace the PCI-only
rescoreItemSessionwith the general path: for eachitem_session, normalize the full Item Versionpayload→AssessmentItemView, runexecuteResponseProcessing(view, { responses: response_payload, responseDeclarations, outcomeDeclarations, … }), read the item outcomes, and aggregate (test-level OP via the controller when the test declares it; otherwise the documented sum-of-item-SCORE fallback). The math-entry PCI becomes one binding of conform-ed’scustomOperatorspoint (@conform-ed/pci-math-entry/checkeris the server checker), not a special case. - Delete emergent’s duplicated mapping:
effectiveItemScore(@emergent/ui) and finalize’sitemScoreMaxcollapse onto conform-ed’sscoreResponse+ the SCORE-outcome convention. Do not relocate them within emergent — retire them in favour of the engine both client and server already share. - Keep finalize idempotent and the
assessment_score/gradebook_entryprojection exactly as today; only the per-item raw derivation changes (now the engine, not trust). - For
offline-complete, the server result must match the client’s (both run the same conform-ed engine over the same keys) — that equality is a strong regression assertion.
Gate: the round-trip discipline this repo favours — for a corpus of item types, assert
server RP score == client score under offline-complete; assert server overrides a tampered
client verdict; extend the existing finalize integration tests. validate:full +
test:integration.
Risk: medium — RP operator coverage is milestone-bound in conform-ed; items using
unsupported RP abort to outcome defaults (conform-ed already reports unsupported-rp).
Define the policy for an item the server cannot authoritatively score (see open
question O1).
Effort: M–L.
Ship criterion: finalize is authoritative for all supported item types; PCI path is a
special case of the general path; keys still sync.
Phase C — Conditional key-withholding (the hard part, deferred)
Section titled “Phase C — Conditional key-withholding (the hard part, deferred)”Goal: answer keys never reach a server-anchored learner’s PGlite, conditionally —
offline-complete delivery keeps shipping keys (peer profiles, ADR-0019). Mechanism: a
pgxsinkit conditional sub-document transform projection (Anton’s chosen mechanism).
Touches: pgxsinkit (the upstream enrichment) + emergent (publish-time flag + schema).
Why static omitColumns cannot do this: it is whole-column, per-table, static — it
deletes the column from the local schema for all rows. Keys live inside the payload
jsonb (mixed with renderable body), and withholding depends on (Item Version × delivery
context × Person), not on the table. So we need a sub-document, per-row, conditional
transform.
pgxsinkit enrichment (upstream — specify):
- Add a registry-declared per-row row-transform hook to
ClientProjectionSpec, applied in the proxy response path wherestripOmittedColumnsFromShapeLogEntriesalready runs (packages/server/src/electric-proxy.ts) — able to rewrite a column value, not only delete whole columns. Signature roughly(row, { claims }) => row', pure and synchronous. - The local PGlite schema keeps the transformed column (it is not in
omitColumns); transformed rows simply carry a thinner jsonb. (packages/client/src/schema.tsalready builds the local schema from non-omitted columns — no change needed beyond not omittingpayload.) - Because the transform runs per-response in the proxy (like the existing per-claims ownership row-filter), it does not pollute Electric’s shared shape cache. Document the determinism + cache contract explicitly.
emergent work:
- A denormalized
keys_withheld booleanon the delivered Item Version row, maintained by the API at publish time (set when an Item Version is referenced by aserver-anchoredAssessment). Schema addition → migration (regenerate, never hand-edit; DB reset/apply is Anton’s step per the memory rules). - Register the transform for
assessment_item_version: whenkeys_withheldis true, stripresponseProcessingand each responseDeclaration’scorrectResponse/mapping/areaMappingfrompayload. - Item-reuse resolution = most-restrictive-wins (ADR-0019 open decision 5): if an Item Version is delivered server-anchored to a Person anywhere, its keys are withheld from that Person everywhere; offline-complete Assessments reusing it then also rely on Phase-D server scoring for that item. Maintain the flag accordingly (it is a property of the Item Version’s delivery, recomputed on publish/retire).
- The learner player must already tolerate a key-stripped payload (it cannot client-score withheld items) — Phase D guarantees the server still scores them, and Phase B already treats server-anchored grades as server-owned, so the player change here is small.
Gate: a pgxsinkit unit/integration test that the transform strips keys for
keys_withheld rows and leaves offline-complete rows intact; an emergent test that a
server-anchored learner’s PGlite never contains correctResponse/responseProcessing;
the Phase-D scoring tests now must pass with keys absent on the client.
Risk: high — upstream sync-substrate change with Electric cache-consistency
implications; the most-restrictive-wins recomputation has edge cases (retire/republish,
mixed-profile reuse).
Effort: L.
Ship criterion: keys verifiably absent from server-anchored learners’ local DBs;
offline-complete keys still present; grades unchanged.
Cross-cutting open questions (carry into build)
Section titled “Cross-cutting open questions (carry into build)”- O1 — Unsupported RP at finalize. RESOLVED 2026-06-15 (route to human evaluation).
rescoreItemSessionnow reports a per-itemauthoritativeflag — false when conform-ed’s interpreter returns anunsupported-rpissue, the math-entry verdict is unjudgeable, or the item has no server-scoreable basis. A server-anchored finalize with any non-authoritative item routes to human evaluation: the grade of record is the auto-scored subtotal, theassessment_scoreis writtenis_final = falsewithevaluation_source = hybrid(some auto + some pending) orhuman(none auto), and thegradebook_entryispartially_graded— never a fabricated number for the withheld-key items. offline-complete is unchanged: the client scored with full keys, so its reproducible committed scores stand (auto/graded). The finalize result carriespendingHumanEvaluation. Follow-on (not O1): a teacher UI to complete the pending items, and a learner “awaiting full grade” result state. - O2 — Selection locus (ADR-0005). Out of scope for this plan (scoring only), but server-anchored selection that needs cross-learner aggregates is the same “block on connectivity” shape; flag it when selection work begins.
- O3 — Module Package as a first-class sync unit (ADR-0006). v1 rides the existing item shape. A distinct package shape only becomes necessary if/when offline secure rendering (key-stripped, synced) is wanted at scale; folds naturally onto the Phase-C transform.
What touches what (summary)
Section titled “What touches what (summary)”| Phase | emergent | conform-ed | pgxsinkit | DB migration |
|---|---|---|---|---|
| A authoring | ✓ | – | – | – |
| B learner UX | ✓ | – | – | – |
| D server scoring | ✓ | ✓ (widen /headless export) |
– | – |
| C key-withholding | ✓ | – | ✓ (conditional transform projection) | ✓ (keys_withheld) |
v1 = A + B + D (no pgxsinkit change, no migration). C is the deferred hardening phase and the only one that enriches the sync substrate.