Block Sync & Image Bank Field Updates — PRD
Context
Two independent changes ship together on prd/block-sync-n-image-bank-updates. They share a branch, not a feature.
-
Block sync (new capability). Blocks are the first-class content pieces of a docket/MCQ (
blockscollection,short_uidprefixBLK-). Today the only way a mobile client receives block content is embedded inside the MCQ sync response (block_details) — a leftover from the MCQ-first model where blocks hung off MCQs. We are now also moving to a docket-first model, where a docket owns its blocks directly (docket.block_ids) and the same blocks are reused across dockets and MCQs. Blocks therefore need to be pullable on their own. This PRD adds a dedicatedGET /v1/blocks/syncand removes the embedded block payload from the v1 MCQ responses. -
Image Bank field additions (delta). The Image Bank repository already ships — linked-entities, taxonomy filter, and non-destructive archive (PR #758). This PRD is a delta that adds four fields missed in that PRD —
source_url,confidence_score,hide_title,needs_review— bringing Image Bank to parity with the Flowchart and Table repositories. It does not re-spec linked-entities, the taxonomy filter, or archive.
Part 1 — Block Sync
Scope
MVP — give mobile a first-class, incremental block feed it can render in either a docket or an MCQ:
- Block sync endpoint —
GET /v1/blocks/sync, cursor-paginated bycourse_id, mirroring taxonomy/docket sync exactly. - Render-ready block payload — each block carries identity, encrypted content,
type, and references to its linked assets (flowcharts/tables/images/nested blocks) so a client can render it standalone. - Drop embedded blocks from v1 MCQ — remove
block_detailsfrom all v1 MCQ responses; clients stitch blocks from block sync using the block references already on the MCQ. - Block references on docket sync — docket sync exposes the docket’s ordered
block_idsso the docket-first flow knows which blocks to fetch and in what order.
Data Model (requirements-level)
Sync mechanics mirror docket sync (decided). Block sync reuses the existing v1 cursor protocol unchanged:
course_idscope, base64next_cursor,limit ≤ 120(default 10), keyset sort on(updated_at, _id),limit+1over-fetch forhas_more, and the inline two-phaseis_deletedtombstone (live rows first; once exhausted the cursor’sinclude_is_deletedflips andis_deleted=truerows ride inline in the samedataarray — no separate deleted-id list). Blocks have no publishing/statuslifecycle (onlyis_deleted), so block sync has nopublished_only/status filter — it is structurally identical to docket sync, not MCQ sync.
Dual identifier exposure (decided). Each synced block carries both its Mongo
_id(asid) and itsshort_uid. Docket→block references use the Mongo_id(docket.block_idsis sent through as-is, no resolution); MCQ→block references use theshort_uid(mcq.ordered_block_ids). Theshort_uidis also the CryptoV2.1 key for decryptingconte. ⚠️ Clients must not assume a single identifier space — see Risks.
Content is ciphertext only (decided). The payload returns
conte(CryptoV2.1 ciphertext, keyed byshort_uid) and never plaintextcontent, matching the existing v1 MCQ block-embed convention. Clients decrypt on device.
No new filters (decided). Block sync takes
course_id+ cursor +limitonly — a whole-corpus incremental pull. Noyear/taxonomy_id/docket_idfilter (unlike MCQ sync). Mobile pulls all blocks for a course and indexes them locally.
Block sync item (GET /v1/blocks/sync → data: [ ... ]):
| Field | Type | Notes |
|---|---|---|
id | string (ObjectId) | Mongo _id. Stitches to docket.block_ids. |
short_uid | string | Prefix BLK-. Stable identity; CryptoV2.1 key for conte; stitches to mcq.ordered_block_ids. |
docket_id | string (ObjectId) | Optional. Origin docket the block was authored under. |
course_id | enum | Course scope (echoes the query param). |
title | string | Optional. |
hidden_title | string | Optional. Hidden title / keywords for categorization. |
type | enum | BlockTypeEnum — HTML / TEXT / IMAGE / PLATEJS. The client’s render mode. |
conte | string | CryptoV2.1 ciphertext of the block content; decrypt with short_uid. No plaintext content. |
flowchart_ids | array | Flowchart short_uids referenced inline. |
linked_table_ids | array | Table short_uids referenced inline. |
linked_image_ids | array | Image short_uids referenced inline; client resolves each to a CDN URL via the deterministic short_uid→URL pattern. |
linked_blocks | array | short_uids of nested/linked blocks (PlateJS block-link nodes). |
updated_at | int (epoch ms) | Incremental cursor key; auto-bumped on every write. |
is_deleted | boolean | Tombstone signal. true rows arrive in phase two. |
Deliberately excluded (admin-only denormalization / audit, not needed to render): reverse MCQ links (linked_mcq_ids, pri_mcq_ids, sec_mcq_ids, pri_mcq_count, sec_mcq_count), old_uuid, created_at, created_by, updated_by.
MCQ sync changes
- Remove
block_detailsfrom all v1 MCQ responses —GET /v1/mcqs/syncandGET /v1/mcqs/by-ids. Both v1 routes share the same response shape and both currently enrich blocks; that enrichment is dropped. - Keep
ordered_block_ids(ordered blockshort_uids) on the v1 MCQ response so clients stitch blocks pulled from block sync. - v2
GET /v2/mcqs/by-idsis unchanged — it keeps its embedded blocks (web editor/preview path).
Docket sync changes
- Add
block_idsto the docket sync response: an ordered list of block Mongo_ids, passed through fromdocket.block_idswith no resolution toshort_uid. This is the docket-first rendering order. - Everything else on docket sync (
mcq_ids,mcq_count, taxonomy, tombstone) is unchanged.
Client stitching model
- Mobile runs block sync per course → caches each block indexed by both
idandshort_uid. - To render a docket: read
docket.block_ids(Mongo_ids) from docket sync → look up cached blocks byid, in order. - To render an MCQ: read
mcq.ordered_block_ids(short_uids) → look up cached blocks byshort_uid, in order. - For each block, decrypt
contewith itsshort_uid, then resolvelinked_image_ids/flowchart_ids/linked_table_ids/linked_blocksagainst their own caches/CDN. is_deleted=trueblocks are evicted from the local cache.
Part 2 — Image Bank Field Additions
Context
The Image Bank entity (ImageBankDBModel, collection image_bank, short_uid prefix IMG) already ships with linked_entities, the denormalized taxonomy filter (used_in_l{1,2,3}_ids), and a non-destructive status archive (PUBLISHED/ARCHIVED). This delta adds four fields that the earlier PRD missed, matching what Flowchart and Table already carry. Nothing else about Image Bank changes.
Data Model (requirements-level)
sourceis replaced bysource_url(decided). The existing free-textsourcefield is removed and a dedicatedsource_urlis added (Flowchart naming). No row currently holds asourcevalue, so this is a clean field swap — no data backfill.
Field visibility (decided).
hide_titleis exposed on av1 (admin write+read) + v2 (web read) because clients need it to decide whether to render the title caption.source_url,confidence_score, andneeds_revieware av1-only (editorial/internal). v1 is untouched — Image Bank has no v1/mobile surface.
confidence_scorereuses Flowchart bounds (decided). Optional integer, validated0–100, same range constants as Flowchart.
Fields added to ImageBankDBModel:
| Field | Type | av1 | v2 | Notes |
|---|---|---|---|---|
source_url | string (URL), optional | write + read | — | Provenance link. Replaces the removed free-text source. |
confidence_score | number, optional | write + read | — | 0–100, validated. Mirrors Flowchart. |
hide_title | boolean, default false | write + read | read | If true, the title is not rendered as a caption. Mirrors Flowchart/Table. |
needs_review | boolean, default false | write + read | — | “Mark for review” editorial flag. Mirrors Flowchart. |
Removed: source (free-text). No migration beyond dropping the field.
Admin listing: add a needs_review filter to the av1 image-bank list (parity with Flowchart’s filter).
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | Block sync endpoint | Mobile pulls all blocks for a course incrementally via GET /v1/blocks/sync; deletes propagate via the two-phase tombstone. | ✅ shipped (keystone PR #764 → dev) | BE plan |
| 2 | Drop blocks from v1 MCQ | v1 MCQ sync + by-ids responses no longer embed block_details; payload shrinks; ordered_block_ids retained. | ✅ shipped (keystone PR #764 → dev) | BE plan |
| 3 | Block refs on docket sync | Docket sync returns ordered block_ids; docket-first rendering knows its blocks and order. | ✅ shipped (keystone PR #764 → dev) | BE plan |
| 4 | Image Bank fields | Image Bank gains source_url, confidence_score, hide_title, needs_review; source removed; av1/v2 surfaced; needs_review filter added. | ✅ shipped — backend (keystone PR #764 → dev) + admin UI (keystone-web PR #429 → dev) | BE plan + FE plan |
Plan key:
BE plan=keystone/docs/superpowers/specs/2026-06-11-block-sync-and-image-bank-updates-design.md(spec) +keystone/docs/superpowers/plans/2026-06-11-block-sync-and-image-bank-updates.md(plan).FE plan=keystone-web/docs/superpowers/plans/2026-06-11-image-bank-fields-admin-ui.md. All four milestones are backend-only in the keystone repo; the M4 admin UI lands in keystone-web; mobile (parixa) consumes the block-sync contract separately.
Sequencing note (Milestones 1 → 2). Ship block sync (M1) and the mobile release that consumes it before removing
block_detailsfrom v1 MCQ (M2), or gate M2 behind that mobile cutover — see Risks (“Breaking older clients”).
Open Questions
- None remaining — all resolved during PRD review (surface = v1-only; payload = render-ready; v1 MCQ drop covers both sync and by-ids; docket sends raw
_ids; image resolution via deterministic CDN URL; one combined file).
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Breaking older clients. Removing block_details from v1 MCQ responses breaks app versions that read embedded blocks. | Med | High — blocks vanish on stale apps | Ship block sync (M1) + the consuming mobile release first; gate the v1 MCQ payload change (M2) behind that cutover. |
Mixed identifier spaces. A client assumes blocks are keyed by only _id or only short_uid and fails to stitch docket or MCQ refs. | Med | Med — blocks don’t render on one surface | Block payload exposes both id and short_uid; the contract documents docket→_id, MCQ→short_uid. |
| First-sync volume. Whole block corpus per course on the first pull is large. | Low | Low — slower first sync only | limit ≤ 120 paging (same as existing syncs); incremental on every subsequent sync. |
| Image short_uid → URL coupling. Block image refs rely on the deterministic CDN URL pattern; if that pattern changes, blocks lose images. | Low | Med — broken images | Pattern is already used by MCQ thumbnails; no new coupling introduced. |
Ref ↔ tombstone drift on docket sync. A block deleted but still listed in docket.block_ids shows a gap. | Low | Low | Block tombstone evicts it client-side; docket ordering tolerates missing ids. |
Technical Details (as built)
Shipped. Every anticipated name below was confirmed in the merged code (no renames). Deviations are listed under Decisions & deviations below.
Naming: PRD vs code (as built — confirmed)
| PRD term | Code (as built) |
|---|---|
| Block entity | BlockDBModel (collection blocks) / BlockProjection, prefix BLK- |
| Block sync endpoint | new GET /v1/blocks/sync in src/api/v1/block/ (new client-facing module; today blocks are av1-only) |
| Block sync cursor | new BlockTimestampCursor {id, updated_at, include_is_deleted} (mirror DocketTimestampCursor) |
| Block sync response item | new BlockSyncResponse (mirror DocketSyncListResponse shape) backed by a BlockSyncProjection |
| Block content ciphertext | conte (CryptoV2.1, key = short_uid); plaintext content/encryption helpers already on BlockDBModel |
| MCQ → block refs | mcq.ordered_block_ids (= block_order, block short_uids) on MCQDetailsListResponse |
| Removed MCQ embed | block_details: List[BlockForMCQDetailsResponse] on the v1 MCQDetailsListResponse + BlockService.get_blocks_for_mcqs calls in both v1 mcq routes |
| Docket → block refs | block_ids on DocketSyncListProjection (List[ObjectId]) → exposed as List[str] (stringified _ids) on DocketSyncListResponse, passed through from DocketDBModel.block_ids |
| Image fields | source_url, confidence_score, hide_title, needs_review on ImageBankDBModel / ImageBankProjection; source removed |
| Image confidence bounds | added new IMAGE_CONFIDENCE_SCORE_MIN/MAX (= 0/100) in src/constants/limits.py (did not reuse the Flowchart constants — decoupled per review) |
Decisions & deviations from the PRD (as built)
- M2 scope broadened to v1 custom tests. The PRD named only
GET /v1/mcqs/sync+/by-ids, but those share the v1 block-embed path with the custom-test responses. Resolved during planning to also stripblock_detailsfrom the v1 custom-test responses (CustomTestMCQDetailsPreSubmissionResponse/…PostSubmissionResponse, via baseMCQDetailsResponse).BlockService.get_blocks_for_mcqsandBlockForMCQDetailsResponsewere removed as now-dead code. v2 MCQ (get_attached_blocks_for_mcqs+SingleBlock) is untouched and still embeds blocks. - No client-compat gating for M2. Product is pre-launch, so the v1 MCQ payload change landed inline (no feature flag, no split PR, no M1→M2 sequencing constraint). The PRD’s “ship M1 + mobile cutover before M2” risk was moot.
- Block content stays ciphertext-only. Confirmed against MCQ: the field-encryption env
V1_CLIENT_FIELD_ENCRYPTION_ENABLED(present in.env) is dead — not infeature_flags_config.py, not read anywhere; the only wired read-time toggle is whole-payloadV1_CLIENT_WHOLE_PAYLOAD_ENCRYPTION_ENABLED. Block sync returnsconteonly (no plaintextcontent), matching the v1 MCQ block-embed convention. The dead flag was deliberately not wired. - Confidence constants decoupled. Added new
IMAGE_CONFIDENCE_SCORE_MIN/MAXrather than reusing Flowchart’s, to keep Image Bank validation independent. sourceremoval cascaded further than the field itself. Backend also dropped thesourcefacet fromget_distinct_filter_valuesandsourcesfromImageBankFilterResponse, and removedsourcefrom the v2 image-bank read response. No data migration (no row held asourcevalue; Mongo schemaless).- PERF (review-driven, post-plan): compound block-sync index. Added
block_course_updated_id_idx (course_id, updated_at, _id)toBlockDBModel(commitdc7d7f8e). Without it, the keyset sort blocked over the whole non-deleted per-course set each page; the compound index makes the sort index-ordered in both tombstone phases (is_deletedstays a cheap residual in phase 1; phase 2 iscourse_id-only and fully covered). Extra pagination tests were added alongside (limit=1 walk, has_more keyset continuation, phase-1→phase-2 flip, empty-page termination). - Admin UI (keystone-web, M4) — full parity with Flowchart. All four fields added to the upload form, the edit modal (with
source_urlURL validation), aneeds_reviewlist column (amber check / dash), and aneeds_reviewlist filter;sourcefully removed across shared types, upload, the live grid, and the deprecatedgallery-oldcomponent, plusFiltersResponse.sources.confidence_scorerange (0–100) is validated client-side before save. Upload sends fields via multipartFormData; update sends JSON PATCH. - Docket sync
block_idsexposed as strings. The projection holdsList[ObjectId]; the response stringifies toList[str](consistent with the block-sync item’s stringifiedid), passed through with no resolution toshort_uid.
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| v1 (mobile, read) | GET /v1/blocks/sync (new) | encrypted; cursor by (updated_at, _id); two-phase is_deleted; no status filter. |
| v1 (mobile, read) | GET /v1/mcqs/sync, GET /v1/mcqs/by-ids (changed) | block_details removed; ordered_block_ids retained. |
| v1 (mobile, read) | GET /v1/dockets/sync (changed) | adds block_ids (ordered Mongo _ids). |
| av1 (admin) | image-bank create/update/list (changed) | source_url/confidence_score/hide_title/needs_review writable; source removed; needs_review filter added. |
| v2 (web, read) | image-bank read (changed) | hide_title exposed. |
- New error codes: none added — reused existing block/image error blocks.
confidence_scoreout-of-range raisesBaseError(ErrorCode.INVALID_PARAMETERS)via the model validator.
Migration (as built — no data migration ran)
- Blocks: no backfill —
updated_at/is_deleted/short_uidalready present on every block; block sync reads existing data. (A new indexblock_course_updated_id_idxwas added — Beanie builds it on startup; no data change.) - Docket sync
block_ids: no backfill —docket.block_idsalready maintained byBlockService.attach_to_docket/ reorder. - Image Bank:
sourceremoved at the model level only (no$unsetscript — no row held a value); new fields default in place (hide_title=false,needs_review=false,source_url/confidence_scorenull).