REST → sync migration — disposition
Status: living disposition. Originally drafted 2026-06-29 (Wave-1: discussion + groups promoted, discussion REST retired). Rewritten 2026-07-01 to apply the correct bar — the earlier revisions justified keeping REST on grounds that are not valid (server-authoritative writes, state machines, PII, anonymity, “transient queue”, signing state). Those are not reasons to keep REST; they are reasons to use the pessimistic sync rail. This doc re-triages every row against the actual rule.
The rule (the only one)
Section titled “The rule (the only one)”Every normal database row goes over sync. REST is kept for a row only when:
- it is a standards-mandated external API — the counterparty is an external system, not our client (LTI 1.3, OneRoster, Caliper, Badge Connect / OB-CLR host, OAuth token for external clients, the Supabase auth webhook); or
- going via sync would provably and measurably break correctness or security — and “provably” means an in-code comparison with actual data/measurements, not an assertion. No such comparison has been run, so no row is kept on door-2 grounds today. The burden of proof is on keeping REST; absent a demonstrated break, the row converts.
Nothing else. In particular these are NOT reasons to keep REST — they only decide which sync
rail: server-authoritative writes, server-assigned version/sequence numbers, state machines,
server-side derivation or parent→child fan-out, multi-row invariants (uniqueness / capacity /
atomicity), PII, server-enforced anonymity, “it’s a transient queue”, grade-of-record aggregation,
issuance/signing state. All of these run server-side in the ADR-0022 pessimistic write-unit
(POST /api/mutations/unit) — identity_binding already ships a hard multi-row active-window
invariant that way, which is the proof.
Two things are orthogonal to this rule, not exceptions to it:
- Raw bytes are not rows. Blobs (images, media, packages, uploads, format exports) ride the object-storage byte path (TUS upload + signed-URL delivery + serve proxies). Their metadata rows still sync.
- Secret material never enters a client store. A private key / token stays server-side; the column simply isn’t synced. The surrounding data rows still sync, and the signed artifact is either synced or fetched as bytes.
Sync rails
Section titled “Sync rails”- optimistic — client-authored, RLS-scoped (owner / offering-membership / org), no server-authoritative write logic. Client writes an overlay + background flush.
- pessimistic — the write is server-authoritative (any of the “NOT a REST reason” list above).
Client writes through
POST /api/mutations/unit; the server applies it atomically with typed rejects; the client reads the result back over sync. Still sync — just the authoritative rail.
Already on sync (done)
Section titled “Already on sync (done)”offering, assessment_definition (read projections), assessment_item(+version),
assessment_stimulus(+version), learning_period(+offering junction), offering_membership,
discussion + post + report + discussion_read_marker + posting_restriction, the Group
primitive (group_set/group/group_membership), offering_resource, assessment_attempt,
practice_session/item_session/interaction_event, assessment_person_pnp_profile,
document_progress_event, media_playback_event; admin rail — lti_tool_context_mapping,
lti_tool_ags_line_item_mapping, catalog_resource, cat_pool(+cat_pool_item),
item_parameter_set (ephemeral+lazy+pessimistic), identity_binding (pessimistic), and the
lti_tool_platform/lti_tool_deployment/assessment_definition_admin_summary reference reads;
teacher rail — grade_category (org-scoped teacher read filter + org-teacher write RLS; the
ManageWeightingModal reads + authors it on the sync rail, the REST category CRUD is retired),
media_resource + media_assignment (ADR-0045; the MediaTab authors both on the sync rail — the org
media catalog under the org-scoped teacher read filter + a FOUR-branch write RLS that also admits an
offering-teacher whose offering is in the row’s org, and the offering placement under the offering
fan-out + teacher-manage write RLS, learner-read; the media-authoring REST CRUD is retired — only a
bytes-only upload endpoint remains as the byte door, delivery unchanged), and cat_pool_assignment
(ADR-0037 §7; the CatPoolAssignmentTab assigns/unassigns published pools on the sync rail under the
teacher-manage RLS + the applier’s same-org/published guard; the picker reads sync too — the widened
claims-branching cat_pool/cat_pool_item read asReadonly, the bounded competency-label
projections, and the offering org lookup; the assignment REST is retired). issuer_profile also
joined the admin rail (ADR-0036; the IssuerProfilePanel authors profile_meta over sync; the
profile-fields REST is retired).
Two of these have a UI cutover still pending (backend on sync, app still calls REST):
offering_resource (teacher-portal ResourcesTab) and cat_pool_item membership (admin
CatPoolItemsModal add/remove).
Rows still on REST that MUST move to sync
Section titled “Rows still on REST that MUST move to sync”Every row below is a normal DB row with no standards-door and no provable break — so it converts. The “rail” column is the only open question (optimistic vs pessimistic), never sync-vs-REST.
| Row | Rail | Notes / what it needs (a how, never a keep-REST reason) |
|---|---|---|
grade_category |
optimistic | ✅ On the teacher sync rail. The org-scoped teacher read filter (buildTeacherManagedOrganizationRowFilterWhere, prereq #2) + org-teacher write RLS (buildClaimAwareOrganizationTeacherManageNativePolicies, decision A(i)) + applier branch shipped; the ManageWeightingModal reads + authors it over sync and the REST category CRUD (routes + service + client) is retired. The filter is reusable for media_resource and the teacher cat_pool read (below). |
| line-item → grade-category assignment | optimistic | NEXT. Writes lti_platform_line_item.grade_category_id (a column on the LTI platform line-item row, not a standalone row) — the ManageWeightingModal still assigns it over REST (assignTeacherLineItemGradeCategory) until the line-item row converts (offering-scoped; offeringMembershipFanOut). |
| assessment → grade-category assignment | pessimistic | Writes assessment_definition.grade_category_id — the modal assigns it over REST (updateTeacherAssessment) until assessment_definition authoring converts onto /api/mutations/unit (already listed below). |
media_resource |
optimistic | ✅ On the teacher sync rail (ADR-0045). Org media catalog under the REUSED org-scoped teacher read filter (buildTeacherManagedOrganizationRowFilterWhere, +null-org globals) + a four-branch write RLS — the new offeringReach variant of buildClaimAwareOrganizationTeacherManageNativePolicies also admits an offering-teacher whose offering is in the row’s org (adding media is an offering-teacher gesture, unlike grade_category’s A(i)). Teacher-only sync; the learner plays via the REST delivery byte door. Bytes stay on the byte path (a bytes-only upload endpoint returns the storage ref the row carries). |
media_assignment |
optimistic | ✅ On the teacher sync rail (ADR-0045). Offering placement under the offering fan-out read + teacher-manage write RLS; authoritative-readwrite (teacher), learner-asReadonly (offering fan-out). Applier cross-org guard keeps a placement’s resource in the offering’s org. Retire = visibilityState -> retired (no hard delete). |
external_package_assignment |
learner read | ✅ Learner-readonly on the sync rail as part of the offering-contents discovery list (below). Authored server-side (import pipeline), so there is no optimistic teacher-write path to retire — the only client interaction was the GET /contents read. Filter: offering fan-out AND visibility_state = 'published' (a member syncs only published placements in their offerings). Bytes stay on the byte path. external_package (+external_package_version) ride the same slice, learner-readonly, scoped by a 2-/3-level nested subquery to the packages/versions reachable through such a published assignment (proven at real Electric by the L5 grammar probe). |
learning_unit_external_link |
learner read | ✅ Learner-readonly (offering fan-out), part of the offering-contents discovery list. Server-created (CC import / provisioning / duplication), so no optimistic write path to retire. |
cat_pool_assignment |
optimistic | ✅ On the teacher sync rail (ADR-0037 §7). Offering fan-out read (learner-asReadonly), teacher-manage write RLS (offering teaching set / org admin), add/remove only; the applier enforces the same-org + published assign guard the REST enforced. The picker reads ride along with NO enrichment REST: cat_pool/cat_pool_item widened to a claims-branching read (teaching set → published pools only, admins unchanged) consumed asReadonly by the teacher, and the competency label halves sync as bounded defineReadProjections over competency_node(+_revision) scoped to pool-referenced nodes. offering joined the teacher registry (readonly) for the org lookup. The CatPoolAssignmentTab authors over sync; the GET/POST/DELETE cat-pool-assignments REST is retired. The teacher cat_pool read row below is thereby DONE too. |
entity_image (selection metadata) |
optimistic | ✅ On the STAFF sync rail (ADR-0035). One authoritative readwrite entry consumed readwrite by BOTH the teacher portal (offering-kind rows; SettingsTab image manager) and the admin console (achievement/organization badge/issuer art). Kind-branched read filter (buildEntityImageReadPredicate) + a two-set write RLS (admin over the kind-CASE owning-org; teacher-manage AND-ed to entity_kind = 'offering' via the new rowCondition option) — the read twin of the REST assertCanManageEntityImage. Applier validates the polymorphic referent EXISTS (409, no FK), FULL-REPLACES the source fields on update, pins an uploaded row to its own <kind>/<entity_id>.<ext> key; delete is REAL (clear = revert to the generated card). Bytes split to a bytes-only upload door (uploadEntityImageBytes → /workflow/entity-images/:kind/:id/upload) that returns the storage facts the sync row write relays; the external-URL PUT + clear DELETE REST is retired, and the panels’ live preview surfaces a non-image URL (the best-effort HEAD probe retired with the write). |
issuer_profile fields |
optimistic | ✅ On the admin sync rail (ADR-0036). Org-scoped (direct org; null-org platform default platform-admin-only) admin read/write; the IssuerProfilePanel validates against the shared IssuerProfileMeta contract and authors over sync; the GET/PUT profile-fields REST is retired. Provisioning (row + Vault keys) stays the platform-admin REST door. |
qualification_definition (read) |
read-only (no write path) | ✅ On the READ-ONLY credential-reference rail (Wave-3; ADR-0025 claims-branching). The admin achievement-image picker reads the org’s definitions from the admin sync rail (org-scoped: platform admin all / org admin their orgs) — the GET /workflow/organizations/:id/qualification-definitions REST route + service + admin client are retired. A learner also syncs the definitions referenced by their own awards (bounded single-level subquery) for the wallet’s issued-credential list (the LearnerSyncProvider.walletCredentials assembly). Authoring CRUD is still seed-only (a future OB/CLR authoring surface). |
learning_container |
optimistic | Offering-scoped. |
offering create/update/status |
pessimistic | State machine + provisioning fan-out → the applier. |
offering_membership status / roster invite |
pessimistic | Status transitions; the invite email is a server side-effect the applier triggers. |
learning_unit (structure) |
WRITE DONE / learner read DONE | ✅ As-built: the pessimistic WRITE rides the /unit expander (e4a9039 backend; cutover this commit) — server-assigned next-sibling sequence_no, the two-phase reorder renumber (each member parks to a negative then finalises, batched as one statement + a no-negative-seq-survives backstop), reparent tree/cycle integrity via the ported recursive CTE, the draft→active→archived state machine, kind guards, and a subtree cascade delete guarded by a reference-block (assessments / package placements → 409 archive-instead). A reorder unit may carry ONLY sequence_no updates (mixing with reparent/title/status is a build-phase 400). The teacher StructureTab authors over the sync provider; the four REST write routes (create / reorder / update / delete learning-unit) + their service fns + path aliases retired. The learner-readonly READ is untouched (offering fan-out), and the teacher offering-detail READ still carries the units until the offering slice converts. |
assessment_definition (authoring) |
pessimistic | QTI serialize/validate, server version_no, draft→published→retired. |
| attempt finalize / evaluate | pessimistic | Server aggregation → grade of record. Rides the /unit applier. |
survey |
pessimistic | ✅ On the sync rail (ADR-0046; first Wave-3 slice). The whole survey pair rides the pessimistic /unit endpoint, which gained the FOUNDATIONAL async unit-expander tier: a member may pre-read the DB for advisory guards and return SERVER-DERIVED children riding the ONE pgxsinkit_apply_mutations transaction (children carry ${parentMutationId}:<tag>:<n> ids/seqs, ordered after the parent). Survey create derives organization_id from the offering + FORCES status draft; the questionnaire create EXPANDS to the questionnaire plus one questionnaire_item per deriveQuestionnaireItems(definition) (the same parser the REST used, imported not re-implemented). Teacher authors survey/questionnaire over sync (SurveyTab cutover), reads the derived item index asReadonly + an identity-free response SUMMARY projection; learner reads the delivery view asReadonly (offline-capable). The delivery + authoring REST is retired. |
questionnaire_response |
pessimistic | ✅ On the sync rail (ADR-0046). Learner-writable pessimistic + LAZY + EPHEMERAL (own-rows read; anonymous rows carry NULL person_id and sync to nobody). The learner submits via a pessimistic /unit; the response expander SERVER-ENFORCES anonymity (person_id NULL + anonymous from survey.anonymous, never client-trusted) and derives one questionnaire_item_response per derived item. The raw SurveyJS result document is the document of record persisted on the new answers_payload column (the response-side twin of questionnaire.definition → questionnaire_item: answers_payload (raw) → questionnaire_item_response (derived index); scrubbed on erasure). The response/consent REST is retired. |
qualification_award |
✅ On the READ-ONLY credential-reference rail (Wave-3; ADR-0025 claims-branching), NOT the pessimistic write path this triage assumed. Scoping overturned the triage: qualification_award has NO write path anywhere — no service, no route, no seed; the proposed→granted→revoked “state machine” is only an enum plus read-side state = 'granted' guards, and the signed artifact is minted deterministically on export (ADR-0016/0017), never stored. So there is no pessimistic write to migrate. Read filters: platform admin all / org admin via the definition→org nested subquery (the org’s award registry; erasure-severed person_id-NULL rows stay in it) / OWN rows in ANY state (the wallet’s issued list) / DENY_ALL. evidence_payload + the LWW pair are withheld from the client projection. The award WRITE rail is a recorded product-discovery candidate — a granting/revoking authoring surface (an OB/CLR issuer granting workflow), ADR-0022 pessimistic when built — not invented here. The learner wallet’s issued-credential list now reads this rail (own granted awards ⨝ definition) — half of LearnerSyncProvider.walletCredentials. |
|
held_credential (imported wallet) |
read-only (server-written) | ✅ On the credential rail (Wave-3; Capability B / ADR-0038). The learner’s IMPORTED credentials — the other half of the wallet list. It was BUILT for the rail during Capability B (person-owner row filter, governance grant-gen, person-owner SELECT RLS, raw_payload/verification_meta withheld from the client projection) but was never registered until now; wiring it into authoritativeSyncRegistry + learnerSyncRegistry completes the wallet cutover. Client-readonly (mode: "readonly") — all writes are the server-side wallet import / badge-connect REST door (verify-on-ingest needs the network), so it does NOT join the applier’s writableSyncTableNames. With both halves synced, the REST GET /workflow/wallet/credentials list + its wallet-service.ts (list + issued-award projection) are retired; the wallet ACTION endpoints (import / present / EDC render+export / badge-connect) stay REST byte/standards doors. |
lti_tool_nrps_context_snapshot / _member_snapshot |
pessimistic (read-mostly) | The NRPS fetch is the standards door; the resulting rows sync to the admin console (server-derived resolutionStatus/personId are just server-written columns). |
lti_tool_ags_grade_publication |
pessimistic (read-mostly) | The AGS send is the standards door; the publication/queue rows sync to the admin console. |
lti_tool_proctoring_session / _control_event |
pessimistic (read-mostly) | The proctoring protocol is the standards door; the session/event rows sync to the console. |
| identity correlation / merge review queues | pessimistic | Server-generated recommendations; the human resolution is an authoritative write. Rows sync. |
| learner read | ✅ GET /workflow/offerings/:offeringId/contents retired (service + routes + path alias + app wiring + learner client + env helper deleted). The learner client assembles the list client-side (assembleContentItemsByOfferingId, pure + unit-tested) from the five synced tables via a tier-① Drizzle read over client.query/client.drizzle (no raw SQL); offline-capable. Proven end-to-end at real Postgres + Electric (offering-contents-sync-cycle). |
|
cat_session + server-anchored / secure delivery |
pessimistic + administered-window shape | Scoring/next-item is an authoritative write (pessimistic unit); session + results sync. The “does the item shape leak un-administered items?” worry is resolved by design via a single-item administered-window shape — see secure-delivery-single-item-shape.md. Converts; needs a measured CAT-first prototype. |
Genuinely REST — the whole list
Section titled “Genuinely REST — the whole list”Standards-mandated external APIs (door 1) — the counterparty is not our client; keep the endpoint, the underlying rows still sync internally:
- LTI 1.3 tool surface:
/lti/login,/lti/launch,/lti/jwks,/lti/deep-linking/*,/lti/proctoring/*,/lti/launch-auth,/lti/launch-bridge,/cmi5/fetch. - emergent-as-LTI-platform (ADR-0040):
/lti/platform/*(auth, token, jwks, NRPS serve, AGS receive, proctoring, deep-linking return,.well-known/openid-configuration, registration). - OneRoster provider:
/ims/oneroster/*+ its token. - Badge Connect / OB / CLR host:
/ims/ob/*,/ims/clr/*,/.well-known/badgeconnect.json, register, token. - OAuth authorize/token where an external client speaks it; the Supabase auth send-email webhook.
- Caliper is outbound telemetry (not a client API) — imposes nothing here.
Provable correctness/security break (door 2):
- None — and the last candidate is resolved by design. Secure/adaptive (CAT + QTI server-anchored)
item delivery was the one candidate worth measuring (does a sync shape place un-administered items +
keys on the device?). It does not have to: an administered-window shape — the delivery shape scoped
by a server-owned attempt cursor to exactly the current item, keys withheld — plus a pessimistic
advance (
POST /api/mutations/unitthat scores + flips the cursor) is byte-for-byte equivalent to the one-at-a-time REST posture (same in-memory exposure, same “can’t see ahead”, same server-gated reveal), using pgxsinkit’s already-proven subquery move-out/move-in churn to stream the delete-of-previous + insert-of-next. Full pattern, per-case study (CAT/workflow/cat-sessions, QTI server-anchored), and the one candidate upstream enrichment (write-only pessimistic units, for cases that want the authoritative write with no local read shape) →secure-delivery-single-item-shape.md. It needs a measured prototype (CAT first), but it is a conversion, not an exception.
Not rows at all (orthogonal to the rule):
- Bytes: media/document/web-content/package-runtime delivery + serve proxies; TUS uploads (submission artifacts, media, documents, QTI item assets); entity-image / wallet-badge-art / PCI module / QTI-asset serve; cartridge / QTI / package import + export files.
- Secret mint: issuer/holder key provisioning + credential signing/export (private key never syncs; award rows + signed artifacts do). Credential verify/validate is a pure function over user-supplied input — no persistent row to sync.
Sequencing
Section titled “Sequencing”-
Finish the two pending UI cutovers (
offering_resourceteacher UI,cat_pool_itemmembership). -
✅ DONE — built the org-scoped teacher read filter (prereq #2) + org-teacher write RLS and converted
grade_categoryend-to-end (schema/applier/registry/offline-data/erasure/migration + UI cutover, REST retired). The filter (buildTeacherManagedOrganizationRowFilterWhere+buildClaimAwareOrganizationTeacherManageNativePolicies) proved reusable:media_resource+media_assignmentnow ride the teacher rail (see below). The read filter carries verbatim to the teacher-sidecat_poolread — still open. The write builder gained an optionalofferingReachbranch (four-branch: also an offering-teacher whose offering is in the row’s org) formedia_resource. -
✅ DONE (media) —
media_resource(teacher-only, org filter + four-branch write) +media_assignment(offering fan-out, learner-read) converted end-to-end with the MediaTab cutover; the media-authoring REST CRUD is retired (only a bytes-only upload byte door remains). ✅ DONE (offering-contents) — the learner offering-contents discovery list converted end-to-end:external_package(+external_package_version),external_package_assignment,learning_unit(read), andlearning_unit_external_linkare learner-readonly on the sync rail (offering fan-out; the package chain scoped by a published-assignment 2-/3-level nested subquery), the client assembles the list, andGET /contentsis retired. ✅ DONE (issuer_profile) — admin rail + IssuerProfilePanel cutover; profile-fields REST retired. ✅ DONE (cat_pool_assignment + the teachercat_poolread) — teacher rail end-to-end with the bounded competency-label projections; the assignment REST retired. ✅ DONE (entity_image selection) — the last offering-scoped Wave-2 (a) candidate: one staffreadwriteentry (teacher offering-kind + admin achievement/organization), kind-branched read filter- kind-CASE / offering-
rowConditionwrite RLS, bytes split to a bytes-only upload door, and the external-URL + clear REST retired. The remaining Wave-2 work is the Wave-3 pessimistic set (below) + secure-delivery.
- kind-CASE / offering-
-
Convert the pessimistic rows onto
/api/mutations/unit:learning_unit,assessment_definitionauthoring, offering CRUD/status, membership/roster, attempt finalize/evaluate,DONE,survey/questionnaire_responsequalification_award, and the LTI/identity server-written row sets (nrps_*,ags_grade_publication,proctoring_*, review queues) as admin-console reads.✅ The unit-EXPANDER tier now exists (landed with the survey slice, ADR-0046):
/api/mutations/unitcan validate a member, pre-read the DB for advisory guards, and emit SERVER-DERIVED children on the ONE apply transaction. Every remaining Wave-3 row that fans a client write out into server-derived children (e.g.assessment_definition→ items,learning_unitrenumber) can ride this tier rather than a bespoke endpoint.