Owner Burhan

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.

  1. Block sync (new capability). Blocks are the first-class content pieces of a docket/MCQ (blocks collection, short_uid prefix BLK-). 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 dedicated GET /v1/blocks/sync and removes the embedded block payload from the v1 MCQ responses.

  2. 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:

  1. Block sync endpointGET /v1/blocks/sync, cursor-paginated by course_id, mirroring taxonomy/docket sync exactly.
  2. 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.
  3. Drop embedded blocks from v1 MCQ — remove block_details from all v1 MCQ responses; clients stitch blocks from block sync using the block references already on the MCQ.
  4. Block references on docket sync — docket sync exposes the docket’s ordered block_ids so 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_id scope, base64 next_cursor, limit ≤ 120 (default 10), keyset sort on (updated_at, _id), limit+1 over-fetch for has_more, and the inline two-phase is_deleted tombstone (live rows first; once exhausted the cursor’s include_is_deleted flips and is_deleted=true rows ride inline in the same data array — no separate deleted-id list). Blocks have no publishing/status lifecycle (only is_deleted), so block sync has no published_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 (as id) and its short_uid. Docket→block references use the Mongo _id (docket.block_ids is sent through as-is, no resolution); MCQ→block references use the short_uid (mcq.ordered_block_ids). The short_uid is also the CryptoV2.1 key for decrypting conte. ⚠️ Clients must not assume a single identifier space — see Risks.

Content is ciphertext only (decided). The payload returns conte (CryptoV2.1 ciphertext, keyed by short_uid) and never plaintext content, matching the existing v1 MCQ block-embed convention. Clients decrypt on device.

No new filters (decided). Block sync takes course_id + cursor + limit only — a whole-corpus incremental pull. No year / taxonomy_id / docket_id filter (unlike MCQ sync). Mobile pulls all blocks for a course and indexes them locally.

Block sync item (GET /v1/blocks/syncdata: [ ... ]):

FieldTypeNotes
idstring (ObjectId)Mongo _id. Stitches to docket.block_ids.
short_uidstringPrefix BLK-. Stable identity; CryptoV2.1 key for conte; stitches to mcq.ordered_block_ids.
docket_idstring (ObjectId)Optional. Origin docket the block was authored under.
course_idenumCourse scope (echoes the query param).
titlestringOptional.
hidden_titlestringOptional. Hidden title / keywords for categorization.
typeenumBlockTypeEnumHTML / TEXT / IMAGE / PLATEJS. The client’s render mode.
contestringCryptoV2.1 ciphertext of the block content; decrypt with short_uid. No plaintext content.
flowchart_idsarrayFlowchart short_uids referenced inline.
linked_table_idsarrayTable short_uids referenced inline.
linked_image_idsarrayImage short_uids referenced inline; client resolves each to a CDN URL via the deterministic short_uid→URL pattern.
linked_blocksarrayshort_uids of nested/linked blocks (PlateJS block-link nodes).
updated_atint (epoch ms)Incremental cursor key; auto-bumped on every write.
is_deletedbooleanTombstone 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_details from all v1 MCQ responsesGET /v1/mcqs/sync and GET /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 block short_uids) on the v1 MCQ response so clients stitch blocks pulled from block sync.
  • v2 GET /v2/mcqs/by-ids is unchanged — it keeps its embedded blocks (web editor/preview path).

Docket sync changes

  • Add block_ids to the docket sync response: an ordered list of block Mongo _ids, passed through from docket.block_ids with no resolution to short_uid. This is the docket-first rendering order.
  • Everything else on docket sync (mcq_ids, mcq_count, taxonomy, tombstone) is unchanged.

Client stitching model

  1. Mobile runs block sync per course → caches each block indexed by both id and short_uid.
  2. To render a docket: read docket.block_ids (Mongo _ids) from docket sync → look up cached blocks by id, in order.
  3. To render an MCQ: read mcq.ordered_block_ids (short_uids) → look up cached blocks by short_uid, in order.
  4. For each block, decrypt conte with its short_uid, then resolve linked_image_ids / flowchart_ids / linked_table_ids / linked_blocks against their own caches/CDN.
  5. is_deleted=true blocks 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)

source is replaced by source_url (decided). The existing free-text source field is removed and a dedicated source_url is added (Flowchart naming). No row currently holds a source value, so this is a clean field swap — no data backfill.

Field visibility (decided). hide_title is 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, and needs_review are av1-only (editorial/internal). v1 is untouched — Image Bank has no v1/mobile surface.

confidence_score reuses Flowchart bounds (decided). Optional integer, validated 0–100, same range constants as Flowchart.

Fields added to ImageBankDBModel:

FieldTypeav1v2Notes
source_urlstring (URL), optionalwrite + readProvenance link. Replaces the removed free-text source.
confidence_scorenumber, optionalwrite + read0–100, validated. Mirrors Flowchart.
hide_titleboolean, default falsewrite + readreadIf true, the title is not rendered as a caption. Mirrors Flowchart/Table.
needs_reviewboolean, default falsewrite + 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

#MilestoneOutcomeStatusPlan
1Block sync endpointMobile 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
2Drop blocks from v1 MCQv1 MCQ sync + by-ids responses no longer embed block_details; payload shrinks; ordered_block_ids retained.✅ shipped (keystone PR #764 → dev)BE plan
3Block refs on docket syncDocket sync returns ordered block_ids; docket-first rendering knows its blocks and order.✅ shipped (keystone PR #764 → dev)BE plan
4Image Bank fieldsImage 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_details from 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

RiskLikelihoodImpactMitigation
Breaking older clients. Removing block_details from v1 MCQ responses breaks app versions that read embedded blocks.MedHigh — blocks vanish on stale appsShip 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.MedMed — blocks don’t render on one surfaceBlock 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.LowLow — slower first sync onlylimit ≤ 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.LowMed — broken imagesPattern 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.LowLowBlock 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 termCode (as built)
Block entityBlockDBModel (collection blocks) / BlockProjection, prefix BLK-
Block sync endpointnew GET /v1/blocks/sync in src/api/v1/block/ (new client-facing module; today blocks are av1-only)
Block sync cursornew BlockTimestampCursor {id, updated_at, include_is_deleted} (mirror DocketTimestampCursor)
Block sync response itemnew BlockSyncResponse (mirror DocketSyncListResponse shape) backed by a BlockSyncProjection
Block content ciphertextconte (CryptoV2.1, key = short_uid); plaintext content/encryption helpers already on BlockDBModel
MCQ → block refsmcq.ordered_block_ids (= block_order, block short_uids) on MCQDetailsListResponse
Removed MCQ embedblock_details: List[BlockForMCQDetailsResponse] on the v1 MCQDetailsListResponse + BlockService.get_blocks_for_mcqs calls in both v1 mcq routes
Docket → block refsblock_ids on DocketSyncListProjection (List[ObjectId]) → exposed as List[str] (stringified _ids) on DocketSyncListResponse, passed through from DocketDBModel.block_ids
Image fieldssource_url, confidence_score, hide_title, needs_review on ImageBankDBModel / ImageBankProjection; source removed
Image confidence boundsadded 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 strip block_details from the v1 custom-test responses (CustomTestMCQDetailsPreSubmissionResponse / …PostSubmissionResponse, via base MCQDetailsResponse). BlockService.get_blocks_for_mcqs and BlockForMCQDetailsResponse were 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 in feature_flags_config.py, not read anywhere; the only wired read-time toggle is whole-payload V1_CLIENT_WHOLE_PAYLOAD_ENCRYPTION_ENABLED. Block sync returns conte only (no plaintext content), matching the v1 MCQ block-embed convention. The dead flag was deliberately not wired.
  • Confidence constants decoupled. Added new IMAGE_CONFIDENCE_SCORE_MIN/MAX rather than reusing Flowchart’s, to keep Image Bank validation independent.
  • source removal cascaded further than the field itself. Backend also dropped the source facet from get_distinct_filter_values and sources from ImageBankFilterResponse, and removed source from the v2 image-bank read response. No data migration (no row held a source value; Mongo schemaless).
  • PERF (review-driven, post-plan): compound block-sync index. Added block_course_updated_id_idx (course_id, updated_at, _id) to BlockDBModel (commit dc7d7f8e). 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_deleted stays a cheap residual in phase 1; phase 2 is course_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_url URL validation), a needs_review list column (amber check / dash), and a needs_review list filter; source fully removed across shared types, upload, the live grid, and the deprecated gallery-old component, plus FiltersResponse.sources. confidence_score range (0–100) is validated client-side before save. Upload sends fields via multipart FormData; update sends JSON PATCH.
  • Docket sync block_ids exposed as strings. The projection holds List[ObjectId]; the response stringifies to List[str] (consistent with the block-sync item’s stringified id), passed through with no resolution to short_uid.

Surface & semantics

SurfaceRoutesNotes
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_score out-of-range raises BaseError(ErrorCode.INVALID_PARAMETERS) via the model validator.

Migration (as built — no data migration ran)

  • Blocks: no backfill — updated_at/is_deleted/short_uid already present on every block; block sync reads existing data. (A new index block_course_updated_id_idx was added — Beanie builds it on startup; no data change.)
  • Docket sync block_ids: no backfill — docket.block_ids already maintained by BlockService.attach_to_docket / reorder.
  • Image Bank: source removed at the model level only (no $unset script — no row held a value); new fields default in place (hide_title=false, needs_review=false, source_url/confidence_score null).