Secure / adaptive item delivery on the sync rail — the single-item (administered-window) shape
Status: design note (not built). Records the pattern + a per-case study so the last REST holdouts can
be converted deliberately. Companion to wave-2-sync-migration-disposition.md (door 2).
The problem it removes
Section titled “The problem it removes”The migration rule (that doc) keeps a REST endpoint only for three reasons: an external-standards door, raw bytes, or an auth/secret substrate. The single lingering candidate for a fourth reason (“door 2 — a provable correctness/security break”) was secure / adaptive exam item delivery: the worry that putting items on a sync shape places un-administered items — and their answer keys — on the learner’s device, which the one-item-at-a-time REST endpoint never does.
Anton’s observation (2026-07-01) dissolves that worry: a sync shape can be scoped so it contains exactly the item currently administered — no more than REST ever exposes — and advanced server-side. So there is no “door 2” here; it is an ordinary (if careful) conversion.
The pattern: an administered-window shape + a pessimistic advance
Section titled “The pattern: an administered-window shape + a pessimistic advance”-
One-item shape. The item-delivery Electric shape’s
whereis keyed on a server-owned attempt cursor, so the shape’s membership is exactly the currently-administered item(s) — typically one. The filter is a bare-column subquery over the cursor (e.g. the learner’s liveitem_sessionfor this attempt whosesession_statusis the in-progress state, or anattempt.current_item_idpointer):item_version_id in (select item_version_id from item_session where test_session_id = $1 and person_id = $2 and session_status = 'initial'). The learner never receives the pool or any not-yet-administered item. -
Keys are never in the window. Answer keys / scoring parameters stay off the shape — already built:
assessment_item_version.keysWithheld+ thewithholdAssessmentItemVersionKeysrow-transform stripresponseProcessing/correctResponsefrom the synced payload (ADR-0019 phase C), and the Rasch difficulties (item_parameter_set) are admin-rail-only (ephemeral/lazy/pessimistic). The delivered window carries the prompt, never the key. -
Advance = a pessimistic write. The learner’s “submit / next” is a pessimistic unit (
transaction({ mode: "pessimistic" })→POST /api/mutations/unit). The applier, in one transaction: records the response, scores server-side, and advances the cursor (marks the current item final, selects + reveals the next). It blocks until the server ack — identical to awaiting a REST 200. -
The cursor flip streams a delete + an insert. Flipping the server cursor changes the shape’s membership: the just-answered item leaves the window (Electric streams a delete) and the next item enters (streams an insert). This is exactly the desired transition, and pgxsinkit already does it in both directions — the tagged-subquery move-out/move-in reconciliation (ADR-0023/0024): a row that leaves a subquery filter is evicted, a row that enters is materialised, live and across an offline gap. No new toolkit capability is needed for the churn itself.
-
Ephemeral + lazy. The attempt’s shapes use
retention: ephemeral(a temp PGlite cluster, never IndexedDB) andsubscriptionTiming: lazy(open on attempt start, drop on finish). The item never touches disk.
Why it is strictly equivalent to REST
Section titled “Why it is strictly equivalent to REST”At every instant the browser holds exactly the current item, no key, and no future item; the next item appears only after a server-confirmed advance. That is byte-for-byte the REST posture — same in-memory exposure, same “can’t see ahead”, same server-authoritative gating — so the sync rail adds nothing to the device’s exposure. What it gains is one uniform rail (no bespoke delivery REST), offline-resilient resume of an in-progress item, and the same optimistic-overlay/convergence machinery as everything else.
Per-case study (the holdouts, under this lens)
Section titled “Per-case study (the holdouts, under this lens)”| Case | Today | Under the pattern |
|---|---|---|
CAT adaptive delivery — POST /workflow/cat-sessions + /cat-sessions/items/:id/responses (cat-delivery/session-service.ts; stateless engine per ADR-0037 §11; CatItemPrompt returns only the current itemVersionId; cat_session server-only) |
REST | The clearest fit. practice_session/item_session become the cursor; the shape delivers the current assessment_item_version (keys withheld); the response POST becomes a pessimistic unit that scores + persists the next item_session, flipping the window. cat_session (θ/SE) stays server-authoritative, synced read-only to the learner or kept server-only. |
QTI server-anchored delivery (ADR-0019; assessment_definition.deliveryProfile = 'server-anchored') |
partly sync already — assessment_attempt (readwrite, learner-owned) + item_session sync; keysWithheld row-transform |
Formalise the one-at-a-time gating as an administered-window shape over item_session; the submit/next is already a candidate pessimistic unit; finalize (/assessments/:id/finalize) stays a server aggregation. |
| QTI offline-complete delivery | fully synced (whole test, keys withheld) | Not this pattern — deliberately hands the learner the whole test offline. Distinct, already-correct posture; listed only to contrast. |
The broader affordance Anton raised — write-only pessimistic units
Section titled “The broader affordance Anton raised — write-only pessimistic units”Even a case that genuinely wants no local read setup can still go over the rail as “a package of
sync-compatible mutation data objects sent straight to a mutation endpoint” — a pessimistic unit to
/api/mutations/unit, applied by the in-DB apply function under RLS, with no local shape subscribed for
reads. The write path is independent of the read path (the two are asymmetric by design), so the server
side already supports this. The open question / candidate pgxsinkit enrichment is client-side: whether
the client can build + flush a unit for a table it does not subscribe to as a local read shape (a
“write-only registry entry”), or whether that needs a small first-class affordance. If it does, these real
cases justify adding it upstream (per Anton: “if we have genuine examples of it being genuinely useful, we
could definitely add it”).
Enrichment candidates (note — not assumed)
Section titled “Enrichment candidates (note — not assumed)”- Write-only / no-local-read pessimistic unit for a registered table (above) — the one likely gap.
- A “reveal-on-ack” convenience — probably unnecessary: the applier cursor-flip + the subquery shape’s move-in already yield reveal-after-ack with existing primitives. Confirm at prototype time, don’t build speculatively.
Disposition
Section titled “Disposition”There is no door-2 exception. Secure/adaptive delivery converts via the administered-window shape; it needs design + a measured prototype (CAT delivery first), and possibly the write-only-unit enrichment, but it is not a reason to keep REST. Each such case is worth studying and is now recorded here so it can be analysed rather than silently parked.