Skip to content

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.

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.
  • omitColumns is 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 executeResponseProcessing is 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 /headless entry point (only the React-coupled package root).

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.

  • 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, including maxScore derivation), test-level outcome processing (runOutcomeProcessing, in the React-free test controller), and the customOperators extension 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 responseProcessing and each responseDeclaration’s correctResponse / mapping / areaMapping from the payload jsonb — rather than only delete whole columns. The local PGlite schema keeps the payload column (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 boolean on 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.

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.

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.

  • ADR-0003’s scoring_policy and ADR-0005’s selection locus become projections of the Delivery Profile (ADR-0006), not independently-set knobs. v1 derives scoring locus from delivery_profile; scoring_policy stays 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 /headless export 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_withheld maintenance path; it is explicitly out of v1.
  • No schema migration is required for v1 (the enum/column already exist); the keys_withheld flag and any Module Package tables arrive only with the key-withholding phase.

Resolved in this session (recorded here for provenance):

  1. Key-secrecy is deferred from v1. v1 = grade-ownership + block-UX + server scoring, keys still sync. (chosen)
  2. Connectivity-required server-anchored delivery is acceptable for v1. Offline capture of secure items is not a v1 requirement. (chosen)
  3. The chosen key-withholding mechanism is the pgxsinkit conditional transform projection (deferred to its own phase). (chosen)
  4. delivery_profile is frozen on publish. (chosen)

Still genuinely open — recommended defaults:

  1. 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).
  2. 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.
  3. 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.