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).
What the survey changed about the design
Section titled “What the survey changed about the design”Three findings reshape (and mostly shrink) the pattern note’s job for CAT:
- The item body is already on the rail. The REST responses carry only an
itemVersionIdreference;AdaptivePlayerrenders the body from the learner’s already-synced, key-strippedassessment_item_versionrows. 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). - The REST respond path is non-transactional.
startCatSession/submitCatResponsepass 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/unitconversion puts the whole step in one transaction — a correctness win, not just a rail migration. - 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).
The rail (Slice E1)
Section titled “The rail (Slice E1)”Shapes
Section titled “Shapes”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 liveinitialrow 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_withheldwas 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 viapractice_session.person_id, one-hop subquery), lazy.serverProjection.rowTransformwithholdsestimated_ability/ability_standard_errorwhilestatus = 'in_progress'(thewithholdAssessmentItemVersionKeysprecedent), revealing them atcomplete. 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_atsync plainly. No RLS beyond SELECT parity (writes are privileged-only).practice_session+item_sessionare already readwrite learner entries — the wire for both verbs exists. No registry posture change;item_sessionkeeps its optimistic lane for the QTI offline-complete path (both-rails split, theassessment_attemptprecedent).
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_assignmentexists (poolNotAssigned), pool assembly gate (poolNotFound/poolEmpty/missingParameters— which today escape the REST error mapper as 500s; on the rail they become cleanfailed/conflictedper the campaign taxonomy — a second fixed bug). - Runs assembly +
engine.openingStage(both pure@emergent/domain; the expander’s privileged reads fetchitem_parameter_setrows and pool members — never exposed to the wire). - Derives, in the ONE apply transaction (the ADR-0046 survey expansion tier): the
cat_sessionrow and the firstitem_session(server-authored:itemVersionId,constructId,paramsSnapshotRef,selectionPolicyVersion,sessionStatus:'initial'; the client can author NONE of these — whitelist rejects extras).selectionSeed = practiceSessionIdkeeps selection reproducible, unchanged. - Ack → the first item is in the window (the
initialitem_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 alreadyfinal(itemSessionAlreadyAnswered— the FOR UPDATE lock makes the REST’s race window a real conflict), session notcomplete(sessionComplete), and the row is practice-session-scoped with acat_session(so the verb can never touch QTI test rows). - Scores server-side (
rescoreItemSessionagainst 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 thecat_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.
Lanes (real Postgres + Electric)
Section titled “Lanes (real Postgres + Electric)”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.
Cutover (Slice E2)
Section titled “Cutover (Slice E2)”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:
What the survey found
Section titled “What the survey found”-
The exam bodies are already on the persistent rail.
assessment_item_versionsyncs every offering-reach version to every enrolled learner (key-stripped forkeys_withheldrows) 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). -
Today’s server-anchored player is whole-test-local with free back-navigation.
TestPlayer.tsxassembles the entire test from synced versions and lets the learnermoveToany item and revise before finalize;deliveryProfile === "server-anchored"today changes only the end screen (server grade vs local badge) + asecureNoticealert + 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. -
item_sessionresponses/scores stay on the persistent readwrite rail; only the bodies go ephemeral. Finalize aggregates committeditem_sessionrows bytest_session_id(assessmentAttemptFinalizeExpander), reading server-held keys. Dropping an ephemeral body window at finish does not touch finalize’s inputs, provided responses ride the normalitem_sessionrail. Human-eval routing is server-anchored-exclusive — finalize may leave the attemptpartially_graded/awaiting-grader, so the window drop must sequence after the finalize flush and must not assume a terminalfinalizedstate. -
The E1 administered-window disjunct is CAT-shaped and person-scoped, not test-scoped. It admits any
initialitem_session’s version for the person. For the linear case it must be scoped ontest_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 falsetoassessment_item_version(omitted from client projection — server-only control column, thekeys_withheldsibling). Set it in the publish path alongsidekeys_withheldwhen 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 excludesecure_delivery_onlyversions (... and secure_delivery_only = false). CAT’s E1 window disjunct is unaffected (CAT items aren’tsecure_delivery_only). - New ephemeral+lazy projection
assessment_item_version_exam_window(adefineReadProjection/withRetention(asReadonly(...), "ephemeral")over the version entry):subscription: lazy,retention: ephemeral, its own consistency group; row filter = thetest_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 sameserverProjection.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). AsecureAttemptStartExpander(pessimistic/unitonassessment_attempt, verb-gated to server-anchored definitions) mints the attempt + firstitem_sessionin one tx, mirroringcatSessionStartExpander. - Submit/next verb: a
secureItemResponseExpander(pessimistic/unitonitem_session, verb-gated to test-session rows — the inverse ofcatResponseExpander’s CAT-only guard, which today rejects test rows). One tx: FOR UPDATE, score server-side, mark currentfinal, author the nextinitial(or none if last) — the cursor flip streams the window delete+insert. - Client (learner-web):
ensureSecureExamWindow(client.ensureSynced+examWindowActiveRef, theensureCatDeliveryprecedent) on attempt start;startSecureAttempt/submitSecureResponsepessimistic-unit helpers (thestartCatSession/submitCatResponseprecedent), online-gated via the existinguseOnlineStatusserver-anchored gate; the reshapedTestPlayerserver-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_onlyversions absent from the persistent entry, keys withheld throughout, finalize aggregation over the persistentitem_sessionrows, 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.
Open follow-up (not E3 scope)
Section titled “Open follow-up (not E3 scope)”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.
Open decisions (recommendations inline)
Section titled “Open decisions (recommendations inline)”| # | 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. |
Decision
Section titled “Decision”Pending (Anton).