Server-anchored delivery: authoritative grading on an offline-first substrate
Status: accepted
Builds on ADR-0006 (Delivery Profile / offline regimes), ADR-0003 (scoring locus +
item-version immutability), and ADR-0005 (selection locus). ADR-0006 declared two
coherent Delivery Profiles but only offline-complete is wired; the
delivery_profile enum and column exist on assessment_definition and are inert —
nothing authors server-anchored, the learner player ignores it, and finalize does
not branch on it. This ADR makes server-anchored real.
The tension (named, not hand-waved)
Section titled “The tension (named, not hand-waved)”pgxsinkit is offline-first; QTI 3 / 1EdTech standards were not designed for offline-first delivery or answer-key secrecy. Offline-complete wants everything — content, interactions, answer keys, selection params — synced into PGlite so the headless core can render, score, and complete with no network. Server-anchored wants the opposite: withhold keys from the client, score authoritatively on the server, and block on connectivity for required steps (ADR-0006). Reconciling secure server-anchored delivery with an offline-first sync substrate, in a standards-driven model, is the design problem.
A second, sharper obstruction sits underneath: Delivery Profile is a property of the
Assessment (assessment_definition), but answer keys live on the reusable, immutable
assessment_item_version (ADR-0003). The same Item Version can be delivered
offline-complete by one Assessment and server-anchored by another. Key-withholding is
therefore a property of (Item Version × delivery context × Person), not of the Item
Version alone — which is precisely what a static, whole-column, per-table sync
projection cannot express.
Non-negotiable: the two profiles are peers
Section titled “Non-negotiable: the two profiles are peers”The existing client-sent-keys path (offline-complete) remains first-class and is the default. Server-anchored is additive, never a replacement. Anything that can be fully offline still should be (ADR-0006). Every decision below preserves the offline-complete path untouched, and any future key-withholding must be conditional — stripping keys only in server-anchored delivery contexts, never globally.
What is true in the code today (the starting line)
Section titled “What is true in the code today (the starting line)”- Grade of record is the client’s. The learner plays and scores in the headless
core, submits a client-computed aggregate, then calls finalize best-effort
(
apps/learner-web/.../offerings.$offeringId.tsx). Finalize trusts the committed per-item scores for every item type except single-interaction math-entry PCI, which it re-scores server-side (assessment-finalize/finalize-service.ts,pci-rescore.ts). - Keys ship to the client.
assessment_item_version.payload(responseProcessing + correctResponse) syncs into PGlite via the offering-membership shape; the player reads it locally. omitColumnsis static, per-table, whole-column. pgxsinkit enforces it twice: the generated local PGlite schema never creates the omitted column, and the proxy strips it from every shape-log row. It cannot strip a sub-document of a jsonb, and cannot vary by row, profile, or Person.- A pure server scorer already exists upstream. conform-ed’s
executeResponseProcessingis a pure, deterministic, React-free interpreter of the QTI responseProcessing tree (qti-react/src/rp/interpreter.ts); its whole import closure is React-free. It already powers client scoring. It is not yet exported from conform-ed’s/headlessentry point (only the React-coupled package root).
Decision
Section titled “Decision”1. Profile is frozen on publish
Section titled “1. Profile is frozen on publish”delivery_profile becomes immutable when an Assessment transitions draft → published
(mirrors the test-structure and Item-Version freezing of ADR-0003). Flipping the
profile changes scoring locus and the grade of record; freezing prevents corrupting
in-flight Attempts and keeps published behaviour coherent. Changing the profile means
publishing a new Assessment version.
2. Grade of record flips with the profile
Section titled “2. Grade of record flips with the profile”-
offline-complete (unchanged): client scores instantly in the headless core; the local grade is authoritative-for-display; server finalize runs best-effort and projects the same grade into
assessment_score+gradebook_entry. Never blocks. -
server-anchored: the server-authoritative steps require connectivity — a server-anchored Assessment is a secure test, and a test that can be completed and held entirely offline is not secure. The client does not present a local grade; finalize is required, not best-effort; submit/score gate on connectivity (the learner waits / “moves closer to the wifi” per ADR-0006, rather than proceeding on stale offline state). The grade of record exists only once the server produces it and it syncs back (
gradebook_entry/ the finalize response). Timing stays wall-clock and does not pause (ADR-0006).This sharpens ADR-0006: “still local-first for capture” must not be read as “offline-completable”. For a secure test, local buffering of in-progress input within a connected session is a resilience detail; it never licenses taking the whole Assessment offline and submitting later.
3. Run conform-ed Response Processing on the server for all item types
Section titled “3. Run conform-ed Response Processing on the server for all item types”This is not a new scoring engine — it is Response Processing, run server-side. Today finalize re-derives only math-entry PCI and trusts the client for everything else. “General” means all item types via the real RP engine, not one special-cased PCI checker. The engine is, and stays, conform-ed’s; it already exists, pure and React-free:
- item RP (
executeResponseProcessing), the standard scoring templates (scoreResponse/matchCorrect/mapResponse/mapResponsePoint, includingmaxScorederivation), test-level outcome processing (runOutcomeProcessing, in the React-free test controller), and thecustomOperatorsextension point all already live in conform-ed.
The only upstream conform-ed change is widening the @conform-ed/qti-react/headless
export to expose this engine (it is currently root-only, which would pull React into a
server importer): the RP interpreter, the scoring templates, the already-headless
assessmentItemViewFromNormalized normalize adapter, and the test-level
createTestController / outcome-processing surface. No new scoring logic — only
export surface, plus confirming the test controller stays headless-safe.
The genuinely emergent part is thin orchestration, coupled to emergent’s schema and
so unable to live in conform-ed: for each item_session, hand the stored
response_payload plus the full (keyed) Item Version payload to conform-ed’s engine,
take the outcomes back, write assessment_score + gradebook_entry, keep finalize
idempotent, enforce auth. The math-entry PCI becomes one binding of conform-ed’s
custom-operator point (@conform-ed/pci-math-entry/checker already is the server
checker); it stops being a special case.
emergent’s effectiveItemScore (in @emergent/ui) and finalize’s duplicated
itemScoreMax are re-derivations of conform-ed’s scoreResponse + the SCORE-outcome
convention; they collapse onto conform-ed’s existing capability (not a new emergent
module), so client and server agree by construction.
This is authoritative grading even while keys still sync (the v1 scope). It is the anti-tamper substance and — critically — the prerequisite for key-withholding: there is no point hiding keys until the server can score without the client’s help. Hence the phase order is a → b → d → c, not a → b → c → d (see the plan).
4. Key-withholding is deferred; when it lands, the mechanism is a pgxsinkit
Section titled “4. Key-withholding is deferred; when it lands, the mechanism is a pgxsinkit”conditional transform projection
v1 ships server-anchored as grade-ownership + block-UX + authoritative server scoring, with keys still syncing. Key-secrecy is a later, isolated hardening phase.
When that phase lands, key material is withheld via a conditional, sub-document transform projection in pgxsinkit (not a static omit, not an app-layer workaround, per the repo upstream-first rule):
- pgxsinkit gains a registry-declared per-row row-transform hook applied in the
proxy’s response path (where it already strips columns and applies per-claims row
filters), able to rewrite a column value — here, strip
responseProcessingand each responseDeclaration’scorrectResponse/mapping/areaMappingfrom thepayloadjsonb — rather than only delete whole columns. The local PGlite schema keeps thepayloadcolumn (it is not omitted); server-anchored rows simply carry a thinner jsonb. Because the transform runs in the proxy’s per-response path (like the existing ownership row-filter), it does not pollute Electric’s shared shape cache. - The transform decision is row-local and cheap: the API maintains a denormalized
keys_withheld booleanon the delivered Item Version (set at publish for server-anchored references), and the transform strips iff that flag is set. No per-Person secrecy set, no per-row DB lookup. - Item reuse is resolved most-restrictively: if an Item Version is delivered server-anchored to a Person in any active Assessment, its keys are withheld from that Person’s synced copy everywhere; any offline-complete Assessment that reuses it then also loses client scoring for that item and falls back to server scoring (safe, because piece (d) makes server scoring universal). Secrecy is monotonic — never partially leaked.
- The transform keeps all content delivery on the one uniform sync substrate (no special online item-fetch endpoint, mixed offline-complete + server-anchored items in one synced shape) while guaranteeing keys never land in the learner’s PGlite. It is a key-secrecy mechanism, not a licence to complete a secure test offline: the server-authoritative steps still gate on connectivity (decision 2). Syncing a key-stripped body lets a secure item render while connected without leaking keys; it does not make the Assessment offline-completable.
5. What becomes a “Module Package”
Section titled “5. What becomes a “Module Package””ADR-0006 makes the Module Package a first-class unit. This ADR pins its meaning per profile:
- offline-complete: the Module Package is the synced bundle as it exists today — Item Versions with keys, selection params, assets — scoped to a Learning Unit subtree. No change.
- server-anchored, v1: there is no distinct package shape yet; secure Assessments ride the same synced Item shape (keys present) and the only behavioural change is grade-ownership + block-UX + server scoring.
- server-anchored, key-withholding phase: the Module Package becomes the key-stripped projection of the same Item Versions (produced by the transform of decision 4), plus the server-held key material that never syncs. Selection-param and cross-learner-aggregate handling (ADR-0005/0006) layer on here later.
6. What stays offline-first
Section titled “6. What stays offline-first”offline-complete stays fully autonomous end-to-end: render, capture, score, and complete with no network; responses written locally-first and synced. This is the unchanged, first-class default.
server-anchored is not offline-first in the completion sense — it is a secure test. In-progress responses may be buffered locally within a connected session for resilience, but the server-authoritative steps (submit, score, finalize, and any selection depending on cross-learner aggregates per ADR-0005) require connectivity and block when it is absent. The offline-first substrate still carries the content and the sync of captured responses; it does not carry the integrity-bearing operations.
Consequences
Section titled “Consequences”- ADR-0003’s
scoring_policyand ADR-0005’s selection locus become projections of the Delivery Profile (ADR-0006), not independently-set knobs. v1 derives scoring locus fromdelivery_profile;scoring_policystays as the stored detail but must not contradict the profile. - Finalize stops being best-effort for server-anchored and becomes a required, connectivity-gated step with explicit learner UX for the submitted-for-grading and awaiting-connectivity states.
- conform-ed’s
/headlessexport widens to include the RP interpreter (upstream, additive). emergent gains a server-side general QTI scorer and a custom-interaction checker registry. - The pure outcome→score mapping is de-duplicated into one shared module.
- The key-withholding phase requires a genuine pgxsinkit enrichment (conditional
row-transform projection) and a publish-time
keys_withheldmaintenance path; it is explicitly out of v1. - No schema migration is required for v1 (the enum/column already exist); the
keys_withheldflag and any Module Package tables arrive only with the key-withholding phase.
Decisions for Anton
Section titled “Decisions for Anton”Resolved in this session (recorded here for provenance):
- Key-secrecy is deferred from v1. v1 = grade-ownership + block-UX + server scoring, keys still sync. (chosen)
- Connectivity-required server-anchored delivery is acceptable for v1. Offline capture of secure items is not a v1 requirement. (chosen)
- The chosen key-withholding mechanism is the pgxsinkit conditional transform projection (deferred to its own phase). (chosen)
delivery_profileis frozen on publish. (chosen)
Still genuinely open — recommended defaults:
- Item-reuse secrecy policy: most-restrictive-wins (recommended) vs forbidding an Item Version from being delivered under mixed profiles to the same cohort. Default: most-restrictive-wins (no hard authoring constraint, never leaks).
- Where the connectivity gate sits. Resolved by the “secure test” requirement: server-anchored gates the server-authoritative steps (submit/score/finalize) on connectivity — a test completable and held entirely offline is not secure. Local buffering of in-progress input within a connected session is a resilience detail only. The remaining sub-call is how hard the start gate is: require connectivity to begin a server-anchored Attempt (recommended — fail fast, no wasted offline effort), or allow starting/answering while connected but only hard-block at submit. Default: require connectivity to begin and to submit.
- What the learner sees when connectivity drops at a gated step: a persistent submitted — awaiting connection to grade (or connection required to continue) state with an explicit retry, never a fabricated, local, or zero grade. Recommended as stated.