Skip to content

CAT delivery onto the sync rail — build plan (administered-window, Slice E)

Status: build plan awaiting sign-off. Concretizes secure-delivery-single-item-shape.md (the approved pattern note) for the one remaining REST-consuming surface: POST /workflow/cat-sessions + POST /workflow/cat-sessions/items/:itemSessionId/responses. Facts below verified against the code 2026-07-06 (cat-delivery/{routes,session-service,assembly-service}.ts, cat-pool.ts, assessment-item.ts, AdaptivePlayer.tsx).

Three findings reshape (and mostly shrink) the pattern note’s job for CAT:

  1. The item body is already on the rail. The REST responses carry only an itemVersionId reference; AdaptivePlayer renders the body from the learner’s already-synced, key-stripped assessment_item_version rows. So CAT’s secrecy today is exactly three things, all already server-held: the keys (withholdAssessmentItemVersionKeys), the difficulties (item_parameter_set, admin-rail ephemeral/lazy/pessimistic, no learner entry), and the cursor (which item comes next). The conversion only has to move the cursor’s verbs; the window shape is a widening fix, not new secrecy machinery (see finding 3).
  2. The REST respond path is non-transactional. startCatSession/submitCatResponse pass a plain handle, not a transaction — the item_session score-update, the next-item insert, and the cat_session completion flip autocommit independently. A crash mid-respond can strand a scored item with no successor and an in-progress session. The pessimistic /unit conversion puts the whole step in one transaction — a correctness win, not just a rail migration.
  3. A real functional gap closes. A pool item outside the learner’s offering-synced item set renders “item unavailable” today. The administered-window disjunct streams the administered version into reach for exactly the duration it is administered — the gap disappears.

Also confirmed: CAT terminates into cat_session (θ/SE), never into assessment_attempt — there is no finalize/evaluate interaction to design. And CAT is independent of launch-sessions (stays so).

  • assessment_item_version — window disjunct, existing entry. Add to the learner read filter: id in (select item_version_id from item_session where person_id = <claim> and session_status = 'initial') — single-column, self-contained, Electric-expressible (the live initial row IS the server-owned cursor; no denormalized pointer needed). Rides the EXISTING persistent entry because CAT is formative practice whose bodies are already persistently synced — the ephemeral single-item posture from the pattern note is reserved for the secure linear exam case (below). Difficulties stay off the learner rail entirely. Keys need an active gate, not just the rowTransform (E1 review finding): keys_withheld was only ever set by the assessment-definition publish path, so cross-offering pool items — reachable ONLY via the window disjunct — would have streamed their keys. As built: the cat_pool publish transition seals every member version (keys_withheld = true, monotonic, the definition-publish precedent), adding a member to a published pool seals it on both write rails, and delivery fails closed (start/next reject) on any unsealed version. Posture: most-restrictive-wins — no client code consumes keys (verified), so a version in a published CAT pool is permanently key-withheld everywhere it syncs.
  • cat_session — NEW readonly owner entry (reached via practice_session.person_id, one-hop subquery), lazy. serverProjection.rowTransform withholds estimated_ability / ability_standard_error while status = 'in_progress' (the withholdAssessmentItemVersionKeys precedent), revealing them at complete. This tightens REST: today every respond response leaks the running θ/SE to the wire (an item-selection oracle — a testee who reads it can infer what’s coming); the UI only ever showed the completion values, so nothing user-visible changes. selection_policy_version, cat_pool_id, status, items_administered, finished_at sync plainly. No RLS beyond SELECT parity (writes are privileged-only).
  • practice_session + item_session are already readwrite learner entries — the wire for both verbs exists. No registry posture change; item_session keeps its optimistic lane for the QTI offline-complete path (both-rails split, the assessment_attempt precedent).

Verb 1 — start (pessimistic /unit on practice_session, survey-style expansion)

Section titled “Verb 1 — start (pessimistic /unit on practice_session, survey-style expansion)”

Client stages a practice_session create {id, offeringId} with a catPoolId wire-only signal (the D9 toolId precedent: rides payloadJson, dropped by the overlay upsert). The expander:

  • Guards (ported exactly): active membership in the offering (notMember), cat_pool_assignment exists (poolNotAssigned), pool assembly gate (poolNotFound/poolEmpty/missingParameters — which today escape the REST error mapper as 500s; on the rail they become clean failed/ conflicted per the campaign taxonomy — a second fixed bug).
  • Runs assembly + engine.openingStage (both pure @emergent/domain; the expander’s privileged reads fetch item_parameter_set rows and pool members — never exposed to the wire).
  • Derives, in the ONE apply transaction (the ADR-0046 survey expansion tier): the cat_session row and the first item_session (server-authored: itemVersionId, constructId, paramsSnapshotRef, selectionPolicyVersion, sessionStatus:'initial'; the client can author NONE of these — whitelist rejects extras). selectionSeed = practiceSessionId keeps selection reproducible, unchanged.
  • Ack → the first item is in the window (the initial item_session admits it); Electric streams the cat_session + item_session rows to the owner.

Verb 2 — respond (pessimistic /unit on item_session, verb-gated update)

Section titled “Verb 2 — respond (pessimistic /unit on item_session, verb-gated update)”

Client stages an item_session update carrying ONLY {responsePayload} + the verb marker. The expander (all in one transaction — fixing finding 2):

  • Guards ported exactly: row exists (itemSessionNotFound), owner (itemSessionForbidden), not already final (itemSessionAlreadyAnswered — the FOR UPDATE lock makes the REST’s race window a real conflict), session not complete (sessionComplete), and the row is practice-session-scoped with a cat_session (so the verb can never touch QTI test rows).
  • Scores server-side (rescoreItemSession against the server-held keyed payload; itemNotScoreable → failed/422-equivalent), stamps score fields + sessionStatus:'final' + submittedAt — all server-authored; the whitelist rejects any client-supplied score/status.
  • Re-estimates from full history (unchanged engine call), then EITHER derives the next item_session (window flips: answered item may leave reach, next enters — the ADR-0023/24 move-out/move-in does the churn) OR completes the cat_session (privileged update: status, θ/SE, itemsAdministered, finishedAt — the rowTransform then reveals θ/SE to the owner).

What the client assembles (Slice E2 prep, pure fns)

Section titled “What the client assembles (Slice E2 prep, pure fns)”

CatSessionState dies as a wire type; the player derives it from synced rows: status + completion θ/SE from cat_session; itemsAnswered = count of final item_sessions for the practice session; nextItem = the live initial item_session (its itemVersionId + stagePosition = itemsAnswered + 1). Pure exported assembly fns + unit tests up front.

Full adaptive walk (start → N responds → completion) asserting: one-transaction atomicity (kill a respond mid-guard → nothing landed), window reach at each step (exactly one administered version admitted; answered version evicted unless offering-reach re-admits; a cross-offering pool item becomes reachable ONLY while administered), θ/SE withheld in-progress + revealed at complete, guards (non-member, unassigned pool, foreign item_session, double-submit → conflicted, submit after complete → conflicted), score forgery rejected by the whitelist, missing-parameter pool → clean failure, selection reproducibility (same seed → same walk), QTI test rows untouchable by the verb. Plus filter unit renders via buildRowFilterShape for the new disjunct + cat_session entry.

AdaptivePlayer onto the provider: start/respond via the learner sync provider’s unit helpers; state from the pure assemblers; the “item unavailable” alert becomes unreachable in-pool (keep it as a defensive state). Retire both routes, cat-delivery-client.ts, path aliases, the route tests (guard coverage re-homed in the lanes), and the now-dead CatSessionState wire types. cat-delivery/{session,assembly}-service.ts shrink to whatever the expanders import — the engine calls and assembly move mostly intact behind the expander seam; delete only zero-caller residue.

Explicitly second (Slice E3, designed here, built after E1/E2 proves the pattern)

Section titled “Explicitly second (Slice E3, designed here, built after E1/E2 proves the pattern)”

The secure linear (server-anchored QTI) window: same cursor mechanism over item_session.test_session_id rows, but with the pattern note’s full posture — a SEPARATE ephemeral+lazy window entry for exam item bodies (never IndexedDB), opened on attempt start, dropped on finish, and the submit/next pessimistic unit formalized. Distinct because (a) the exposure contract is stricter (summative), (b) it interacts with finalize/evaluate, (c) CAT proves the churn + verb mechanics first with lower stakes.

Slice E3 — concretized (survey 2026-07-06)

Section titled “Slice E3 — concretized (survey 2026-07-06)”

The survey of the current server-anchored path reshaped E3 substantially. What the sketch above got right: the cursor mechanism, ephemeral+lazy, the pessimistic submit/next, finalize stays. What it did not surface — and what makes E3 much larger than E1/E2:

  1. The exam bodies are already on the persistent rail. assessment_item_version syncs every offering-reach version to every enrolled learner (key-stripped for keys_withheld rows) via the persistent readonly entry’s offering-member disjunct. A server-anchored exam’s whole item pool is therefore already on-device (keys stripped) before the attempt starts. E3’s job is to remove those bodies from the persistent rail and deliver them one-at-a-time — a tightening, not a REST retirement (there is no REST route serving exam bodies; the player renders from synced rows).

  2. Today’s server-anchored player is whole-test-local with free back-navigation. TestPlayer.tsx assembles the entire test from synced versions and lets the learner moveTo any item and revise before finalize; deliveryProfile === "server-anchored" today changes only the end screen (server grade vs local badge) + a secureNotice alert + the upload seam — it does NOT restrict which items are reachable or enforce one-at-a-time. E3’s cursor turns server-anchored delivery into genuinely one-at-a-time server-advanced delivery — a player reshape, not a wiring swap.

  3. item_session responses/scores stay on the persistent readwrite rail; only the bodies go ephemeral. Finalize aggregates committed item_session rows by test_session_id (assessmentAttemptFinalizeExpander), reading server-held keys. Dropping an ephemeral body window at finish does not touch finalize’s inputs, provided responses ride the normal item_session rail. Human-eval routing is server-anchored-exclusive — finalize may leave the attempt partially_graded/awaiting-grader, so the window drop must sequence after the finalize flush and must not assume a terminal finalized state.

  4. The E1 administered-window disjunct is CAT-shaped and person-scoped, not test-scoped. It admits any initial item_session’s version for the person. For the linear case it must be scoped on test_session_id (the attempt cursor), and — for the ephemeral posture — must live on a SEPARATE ephemeral+lazy projection so exam bodies never persist while formative/CAT bodies stay persistent.

The three load-bearing decisions (recommendations inline; awaiting Anton)

Section titled “The three load-bearing decisions (recommendations inline; awaiting Anton)”
# Decision Options Recommendation
E3-1 Never-disk strictness. The pattern note wants retention: ephemeral (item body never touches IndexedDB). The lighter alternative windows the body on the existing persistent entry — only one item reachable at a time, but it briefly persists to disk before the cursor flip evicts it. (A) Ephemeral projection — first learner-side ephemeral entry, its own consistency group, ensureSynced on start + desync on finish (first learner-side drop). Matches “byte-for-byte REST posture.” (B) Window on the persistent entry — reuse E1 machinery, no ephemeral/desync, but the current key-stripped item lands on disk transiently. (A) Ephemeral. It is the whole reason E3 is a separate slice from E1; a summative exam is exactly where never-disk earns its cost. Otherwise E3 is just “E1 with a test_session_id scope” and the pattern note’s strict posture is never actually built.
E3-2 The discriminator. How the row filter tells a server-anchored-exam version (strip from persistent reach, admit only via window) from a formative/CAT/offline-complete version (stays persistent). keys_withheld can’t distinguish — CAT items are key-withheld too. (A) A new denormalized control column secure_delivery_only on assessment_item_version, publish-set, most-restrictive-wins — the exact keys_withheld precedent. Set when a version publishes into a server-anchored definition; NOT set for CAT (stays persistent per E1 decision #1) or offline-complete. Needs a migration (Anton applies). (B) Row-filter join version→item→definition to read delivery_profile — but item↔definition is many-to-many via the test-structure JSON, so there is no clean self-contained subquery; would need a denormalized join table anyway. (A) secure_delivery_only column. It mirrors machinery that already exists and works (keys_withheld), keeps the row filter a single-column subquery, and is monotonic/most-restrictive across item reuse. Costs one migration in your domain.
E3-3 Navigation semantics of the reshaped secure player. One-at-a-time forward-only (submit locks the item, no revision) vs. review-before-finalize (all already-administered items reachable for review/revision until finalize). (A) Forward-only, submit-locks — the strict cursor model; the window holds exactly the current item; matches high-stakes proctored posture. (B) Review-allowed — the window holds all administered-so-far items; the learner may revise any answered item until finalize; softer, but weakens “can’t-see-ahead” to “can-see-behind” and complicates the cursor (multiple initial/reviewable rows). (A) Forward-only for the first cut. It is the clean cursor model, the strictest exposure, and what “server-anchored secure exam” most naturally means. Review-before-finalize can be a later widening if a real exam needs it — flag, don’t build speculatively. Note this is a product/UX change to how server-anchored exams behave today (currently free back-nav), so it is genuinely yours to confirm.

⛔ BLOCKED (2026-07-06): the ephemeral posture needs an upstream pgxsinkit capability

Section titled “⛔ BLOCKED (2026-07-06): the ephemeral posture needs an upstream pgxsinkit capability”

Decisions locked to the strict options (E3-1 ephemeral never-disk, E3-2 secure_delivery_only column, E3-3 forward-only). The E3a foundation build then hit a hard framework gate and stopped:

A payload-bearing ephemeral read projection cannot strip answer keys. The exam-window entry has to be a defineReadProjection over assessment_item_version (a second client shape — ephemeral, lazy, test_session_id-scoped — over rows the persistent entry already owns; a second owning defineSyncTable on the same physical table is rejected by validateRegistryTableUniqueness, and duplicating the body into another table is not on the table). But defineReadProjection’s options are exactly { as, columns?, rowFilter?, consistencyGroup?, subscription?, retention? }no serverProjection / rowTransform (verified: @pgxsinkit/contracts registry.d.ts:610). The server resolves the key-stripping transform strictly by shapeKey (getRowTransformForTable → resolveEntryByShapeKey(...)?.serverProjection?.rowTransform), so the projection’s shapeKey (assessment_item_version_exam_window) resolves to an entry with no serverProjection and payload would stream with correctResponse/responseProcessing intact — a summative answer-key leak. No app-layer workaround is acceptable (it would be exactly the second-owning-entry the framework forbids). Flagged upstream to the pgxsinkit session: tmp/agents/upstream-spec-pgxsinkit-read-projection-rowtransform.md.

The deeper subtlety the fix must resolve: the transform reads keys_withheld, which must be fetched from Electric but omitted from the client row. A full defineSyncTable entry expresses this as two layers (serverProjection.rowTransform reads it on egress; clientProjection.omitColumns drops it from PGlite). A read projection has only columns, which is simultaneously the fetch list AND the client shape — so it can’t fetch-but-omit. The upstream capability must give a projection both a rowTransform and a fetch/omit split.

Everything else in E3a (the secure_delivery_only column, the publish-seal, the persistent-reach narrowing) is reverted, not landed — because the seal + narrowing are only safe paired with the window: once a server-anchored definition is (re)published, its versions leave the learner’s persistent rail, and with no window the current whole-test-local server-anchored player has no delivery path. E3 must ship as one coherent slice when unblocked. Do not land the seal/narrow ahead of the window.

The build, assuming (A/A/A) — deferred until the upstream capability lands

Section titled “The build, assuming (A/A/A) — deferred until the upstream capability lands”
  • Schema: add secure_delivery_only boolean not null default false to assessment_item_version (omitted from client projection — server-only control column, the keys_withheld sibling). Set it in the publish path alongside keys_withheld when the publishing definition is server-anchored (privileged in-tx write; most-restrictive-wins). Regenerate migrations (Anton applies).
  • Persistent entry: narrow the offering-reach disjunct of buildPublishedVersionTeacherReadRowFilterWhere’s learner branch to exclude secure_delivery_only versions (... and secure_delivery_only = false). CAT’s E1 window disjunct is unaffected (CAT items aren’t secure_delivery_only).
  • New ephemeral+lazy projection assessment_item_version_exam_window (a defineReadProjection / withRetention(asReadonly(...), "ephemeral") over the version entry): subscription: lazy, retention: ephemeral, its own consistency group; row filter = the test_session_id-scoped administered-window subquery (id in (select item_version_id from item_session where test_session_id = <attempt> and person_id = <claim> and session_status = 'initial')), keys withheld via the same serverProjection.rowTransform. Bodies stream into a TEMP cluster only.
  • Cursor discipline: server-anchored attempt start must author the first item_session (session_status: 'initial', server-authored version/construct) — today the linear path does not create a server cursor (whole-test-local). A secureAttemptStartExpander (pessimistic /unit on assessment_attempt, verb-gated to server-anchored definitions) mints the attempt + first item_session in one tx, mirroring catSessionStartExpander.
  • Submit/next verb: a secureItemResponseExpander (pessimistic /unit on item_session, verb-gated to test-session rows — the inverse of catResponseExpander’s CAT-only guard, which today rejects test rows). One tx: FOR UPDATE, score server-side, mark current final, author the next initial (or none if last) — the cursor flip streams the window delete+insert.
  • Client (learner-web): ensureSecureExamWindow (client.ensureSynced + examWindowActiveRef, the ensureCatDelivery precedent) on attempt start; startSecureAttempt / submitSecureResponse pessimistic-unit helpers (the startCatSession/submitCatResponse precedent), online-gated via the existing useOnlineStatus server-anchored gate; the reshaped TestPlayer server-anchored branch renders only the windowed item, submit → server advance → next; on finalize, flush() → finalize unit → client.desync(["examWindow"]) (first learner-side drop; refuses on unsettled writes, so it follows the flush).
  • Lanes (real Postgres + Electric): full linear walk (start → N submit/next → finalize) asserting one-item-at-a-time reach (exactly the current version admitted; prior evicted; pool never on the rail), secure_delivery_only versions absent from the persistent entry, keys withheld throughout, finalize aggregation over the persistent item_session rows, human-eval routing intact, the verb rejecting CAT rows and vice-versa, ephemeral drop-on-finish, offline-blocked verbs. Plus filter unit renders for the narrowed persistent branch + the new exam-window subquery.

The pattern note’s write-only pessimistic unit enrichment (client builds+flushes a unit for a table it doesn’t subscribe as a read shape) is still NOT needed here — both verbs target tables with existing writable entries, and the exam-window is a read projection. Re-confirmed at E3. Leave unfiled upstream.

# Decision Recommendation
1 Window rides the existing persistent item_version entry (CAT) vs a new ephemeral entry Existing entry. Matches today’s body-exposure posture for formative practice; ephemeral is the E3 exam posture.
2 Mid-test θ/SE on the wire Withhold until complete (rowTransform). Strictly tighter than REST; zero UX change.
3 Offline posture for the verbs Block with explicit “connection required” UX — identical to REST semantics; discovery stays offline-capable. No response queuing (a queued adaptive response is meaningless — the next item depends on it).
4 Scope CAT-first (E1+E2), linear exam window second (E3).
5 Write-only pessimistic-unit enrichment (pattern note’s candidate upstream gap) Not needed for CAT — both verbs target tables with existing writable entries. Re-evaluate at E3; do not file upstream yet.
6 Teacher/grader visibility of CAT item_sessions (grader branch keys on test_session_id only — CAT rows invisible to teachers today) Out of scope, unchanged. Note as a future teacher-analytics widening if wanted.

Pending (Anton).