CLUE-550 Stage 2: Class-wide slots + get-or-create via pointer - #2928
CLUE-550 Stage 2: Class-wide slots + get-or-create via pointer#2928scytacki wants to merge 32 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## CLUE-550-concurrent-kind-axes #2928 +/- ##
=================================================================
+ Coverage 86.14% 86.18% +0.04%
=================================================================
Files 937 937
Lines 53374 53453 +79
Branches 14100 14122 +22
=================================================================
+ Hits 45981 46071 +90
+ Misses 7377 7366 -11
Partials 16 16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-550-class-wide-slots
|
| Run status |
|
| Run duration | 09m 46s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
5
|
|
|
0
|
|
|
220
|
| View all changes introduced in this branch ↗︎ | |
CLUE-550] Move canonical-document pointers out of the interleaved classes/<id>/... tree into a top-level, versioned `canonical/v1/classes/.../slots/<label>` collection. Because pointers no longer share paths with class/offering/group data, the rules state the uniform concerns once on a recursive-wildcard match (class-member read; immutable) and keep only create per-scope (each scope authorizes its own slot claim; Firestore ORs allow-rules, so a single wildcard create couldn't be tightened per-scope). The `v1` segment versions the layout and restores even path depth. No migration: pointers exist only for unreleased group docs, and an orphaned old-path pointer falls back to the existing no-pointer path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group the three canonical-pointer match blocks under a single
match /canonical/v1/classes/{classId} so classId is captured once and
shared. Inline the single-use canonicalPointerReadable helper into the
wildcard read rule and canonicalPointerCreatable. Rule semantics are
unchanged; the emulator canonical-pointers-rules suite passes 24/24.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
] Add a target-architecture section deciding how document metadata types evolve once `type` is decomposed into axes: the kind-discriminated DBDocumentMetadata union dissolves into a generic base plus structural requirement types narrowed by axis-field type guards (e.g. hasOfferingScope), never by `type` — the type-system form of the core "read axes, not kind" rule. Notes that DBGroupDocMetadata and DBClassWideDocMetadata already share `type: "group"` with different shapes, so `type` already fails to discriminate shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hoist `kind` out of the classWide bundle into an explicit parameter on createDocument, getOrCreateCanonicalDocument, and createFirestoreMetadataDocument (defaulting to `type`, since every non-class-wide kind equals its type). createFirestoreMetadataDocument now always stamps getDocumentKindMetadataFields(kind) instead of reconstructing it from classWide?.kind ?? metadata.type. Two simplifications fall out: IClassWideCreateInfo drops `kind` (keeping only unit/syntheticUid), and createFirestoreMetadataDocument drops its classWide object param for a bare `unit?`, keying the class+unit scope branch on unit != null. Group documents now pass kind === type explicitly, which is where type and kind will diverge once `type` decomposes to a generic value. check:types clean; db.test.ts 36/36; eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold pointerPath/type/kind into IGetOrCreateCanonicalDocumentOpts so the method takes one options object instead of mixing positional args with an opts bag. Move class-wide kind registration from getOrCreateClassWideDocument into createDeclaredClassWideDocuments, so registration lives in the declaring function rather than the get-or-create path. check:types clean; db.test.ts 36/36; eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the owner (authoring identity, stored as `uid`) derivation into the kind registry: kinds declare an ownerScope (user | group | class) and getDocumentOwner(kind, ctx) in document-kinds maps it to the concrete uid from runtime context, replacing the type switch in createDocument. This is the first slice of the `owner` axis living in one place; the document-axes roadmap marks the axis in progress. Make the class-wide owner the class (class_<classHash>), shared across all of a class's units, instead of class_<unitCode>. The unit belongs to the document's canonical slot, not its ownership -- mirroring how a group document's owner is group_<off>_<grp>. Reads rebuild the RTDB path from the stored metadata uid and documents are located by canonical pointer, so the owner value is not load-bearing and no migration is needed. Decouple the pointer createdBy from the owner: it now records the real user.id of whoever won the creation race (or backfilled a legacy pointer), added provenance. Orphan cleanup reads the owner back from the created metadata's uid. With createdBy and the owner both sourced elsewhere, the groupUserId opt and the classWide syntheticUid field drop out, and createDocument passes `owner` straight to the storage-path helpers (getUserPath falls back to user.id), removing the syntheticUserId variable. check:types clean; lint clean; document-kinds + db suites 45/45; emulator canonical-pointers-rules 24/24. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move a group-typed document's scope association fields into the kind layer:
getDocumentScopeFields(kind, ctx) in document-kinds returns { groupId } for
group scope and { unit } for class scope (keyed on the owner scope, which is
unambiguous for these two fields). This is the first slice of the `scope`
axis; other kinds' scope fields stay on the createDocument type switch.
With scope (and owner) now kind-driven, createDocument no longer needs the
classWide descriptor: for a type:"group" document it distinguishes the group
doc from a class-wide doc by the kind's registered scope. IClassWideCreateInfo
is deleted and getOrCreateCanonicalDocument carries a plain title.
getDocumentScopeFields is called inside createFirestoreMetadataDocument, which
reads the runtime values from the stores, so groupId/unit are no longer passed
as parameters. The context-validity check the inline group reject provided is
preserved as an explicit validateDocumentKindCreation(kind) in db.ts (it has
the stores access the registry lacks), which throws before anything is written.
Stamped fields are unchanged on every path (group / class-wide / personal /
problem); the refactor only relocates where they are derived. The document-axes
roadmap marks the scope axis in progress.
check:types clean; lint clean; document-kinds + db suites 48/48.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Stop returning the RTDB metadata object from createDocument. It is only
used internally (written to RTDB, read back for the Firestore doc); no
caller consumed the returned copy. The two publish methods that forwarded
it now resolve { document } only (their sole consumer ignored the value),
and the now-unused DBPublicationDocumentMetadata import is dropped.
- Rename the internal metadata local to rtdbMetadata for clarity.
- Deduplicate the common metadata fields via `const common = {...} as const`
(as const preserves the version "1.0" literal so the discriminated-union
members still match); `type` stays a fresh literal per branch.
- Add a default branch that throws for unhandled document types (e.g. the
deprecated "section"). The switch is not exhaustive, and because
rtdbMetadata is read inside a .then() closure TypeScript's used-before-
assigned analysis can't see the gap, so guard it explicitly rather than
silently set(undefined).
- Note the write-only, problem-family-only top-level classHash on the
problem metadata and in metadata-fields.md.
check:types clean; lint clean; db suite 36/36.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ds explicitly [CLUE-550] Group and class-wide documents now store only base RTDB metadata (version/self/ createdAt/type); their scope, owner, title, and kind are stamped into the Firestore metadata by createFirestoreMetadataDocument. DBClassWideDocMetadata is removed and DBGroupDocMetadata reduced to the bare shared type:"group" shape. createFirestoreMetadataDocument now takes title directly and resolves every scope field explicitly (context_id, unit/investigation/problem, offering/group) via getDocumentScopeFields, rather than letting offeringId ride the metadata spread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing to group docs [CLUE-550] Separate the document-kind registry into independent ownerType and scopeType axes (renamed from ownerScope) and register every built-in kind, so getDocumentScopeFields resolves scope for all document types from the registry + stores context rather than from round-tripped RTDB metadata. createFirestoreMetadataDocument now resolves every scope field (context_id, unit/investigation/problem, offering/group) in one call. kind/concurrent are stamped into Firestore metadata only for type:"group" docs (group + class-wide); other kinds are registered for scope/owner resolution but their kind is deliberately not persisted yet, to avoid migrating publication kinds later. The on-open concurrent backfill is likewise explicitly gated to GroupDocument. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t the RTDB metadata [CLUE-550] createFirestoreMetadataDocument no longer receives the round-tripped RTDB metadata object. It takes a single options object { documentKey, type, kind, owner, createdAt, title? } and reads context_id/offeringId/groupId from the user store. Every field it writes to Firestore is now explicit rather than copied from the RTDB metadata via a cleanedMetadata spread, making the write self-evident and paving the way for removing the RTDB metadata entirely. The RTDB round-trip remains only to resolve the server createdAt timestamp, which is the sole value still sourced from it. uid is passed as owner (equivalent to the prior type-based branch, since userContext.uid === user.id === the owner for user-scoped kinds). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment [CLUE-550] Class-wide documents now resolve their display title live from the kind registry instead of stamping an authored title into each document's Firestore metadata — mirroring how problem documents derive their title from the unit config. An author changing a slot's title applies to every document of that kind, with no stored title and no migration. - document-kinds.ts: kinds may register a static `title`; getDocumentTitle(doc) resolves a title by kind (class-wide static title, else the group-doc label for other type:"group" docs, else undefined so the caller falls back). The group-doc label keys on `type` (always present), not `kind` (null on legacy/un-backfilled metadata records); the class-wide lookup keys on `kind`, which these new docs always carry. - document-utils.ts: getDocumentDisplayTitle consults getDocumentTitle first; its type===GroupDocument branch is removed. - db.ts: class-wide slots register their title on the kind; the authored title no longer threads through the canonical creation path (createDocument/createFirestoreMetadataDocument keep title for personal/learning-log docs, which do persist and display a stored title). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure [CLUE-550] Add the Stage-2 design spec describing what the class-wide-slots PR delivers, including the post-implementation refactors (kind-driven owner/scope derivation, createFirestoreMetadataDocument options object, collapsed RTDB group metadata, titles resolved by kind). Update the document-axes roadmap and target-architecture to match: owner/scope axes described via ownerType/scopeType, and scope's read side reframed as field/axis guards with a unified scope getter left as an open question. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-550] Rename the classWideDocuments config-entry variables and params (slot/slots -> classWideDoc/classWideDocs) so .kind/.title read naturally and do not overload the precise `kind` term; the canonical pointer-slot terminology is preserved. Also tighten several surrounding comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert multi-line JSDoc to block style (delimiters on their own lines), shorten/reword several comments, and drop a stale label note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-slots Brings in the Stage-1 comment/tidy refinements. Resolved the document-kinds.ts conflicts by taking the parent's concise base-comment wording while keeping the Stage-2 additions (owner/scope/title functions and the registerDocumentKind(kind, info) signature). The db.ts backfill auto-merge combined the parent's concurrent derivation with our type === GroupDocument write-back guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Stage 2 support for class-wide collaborative “slot” documents (e.g., DQB) by extending the kind/axis registry, generalizing canonical pointer paths to be scope-field-driven, and auto-creating declared class-wide documents on unit load. This fits into the document-axes refactor by moving more creation-time behavior (owner/scope derivation) behind the kind registry while reusing the existing canonical-pointer get-or-create transaction for race-free convergence.
Changes:
- Introduces
classWideDocumentsin unit configuration and exposes it viaConfigurationManager/AppConfigModel. - Generalizes canonical pointer paths into a versioned
canonical/v1/.../slots/<label>layout and updates Firestore rules + emulator tests accordingly. - Adds class-wide doc creation (
class_<classHash>owner, class+unit scope) and fire-and-forget auto-creation on unit open, plus Jest coverage for metadata stamping and pointer convergence logic.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/models/stores/unit-configuration.ts | Adds classWideDocuments authored unit config surface. |
| src/models/stores/configuration-manager.ts | Exposes classWideDocuments via config manager getter. |
| src/models/stores/app-config-model.ts | Surfaces classWideDocuments on stores.appConfig. |
| src/models/stores/app-config-model.test.ts | Tests config resolution for classWideDocuments. |
| src/models/document/document-utils.ts | Uses kind registry to resolve display titles first (incl. class-wide slot titles). |
| src/models/document/document-utils.test.ts | Adds tests for group vs class-wide title resolution behavior. |
| src/models/document/document-kinds.ts | Expands kind registry to include owner/scope derivation + optional static titles. |
| src/models/document/document-kinds.test.ts | Adds coverage for owner selection, scope field selection, and title resolution. |
| src/lib/scoped-document-pointers.ts | Replaces group-only pointer builder with scope-driven canonical/v1/.../slots/... builder. |
| src/lib/scoped-document-pointers.test.ts | Tests pointer paths for group scope vs class+unit scope. |
| src/lib/db.ts | Implements kind-driven owner/scope stamping, class-wide get-or-create, and unit-load auto-creation. |
| src/lib/db.test.ts | Adds unit tests for Firestore metadata stamping and class-wide pointer get-or-create flows. |
| src/lib/db-types.ts | Collapses group/class-wide RTDB metadata shape under transitional type:"group". |
| firestore.rules | Moves pointers under canonical/v1, adds class+unit create scope, updates canonical path helper. |
| firebase-test/src/canonical-pointers-rules.test.ts | Adds emulator tests for class+unit pointer rules + canonical-claim integrity. |
| docs/superpowers/specs/2026-07-23-clue-550-stage-2-class-wide-slots-design.md | New design spec documenting Stage 2 scope, pointer layout, and creation strategy. |
| docs/document-metadata/metadata-fields.md | Updates metadata-field documentation around RTDB classHash handling. |
| docs/document-axes/target-architecture.md | Refines axis/scope guidance toward field guards over kind/type branching. |
| docs/document-axes/README.md | Updates roadmap/status notes for Stage 2 progress. |
Comments suppressed due to low confidence (1)
src/lib/db.ts:759
- getOrCreateGroupDocument() builds a pointer path using user.offeringId but doesn’t validate it. If offeringId is empty/undefined, getCanonicalPointerPath() will produce a path that doesn’t match the allowed pointer create scopes (and the group owner uid can be malformed), causing hard-to-diagnose failures. Fail fast when offeringId is missing.
const groupId = user.currentGroupId;
if (!groupId) {
return Promise.reject("Cannot create group document because user is not in a group.");
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…e kinds [CLUE-550] registerDocumentKind is now the single home for kind checking: a kind must be a camelCase identifier (safe as a Firestore path segment / registry key) and must be registered exactly once; both violations throw. isValidDocumentKind moved from document-types.ts to document-kinds.ts alongside it. createDeclaredClassWideDocuments catches those throws and skips the offending class-wide entry rather than crashing startup. Built-in kinds register via registerBuiltInDocumentKinds(); resetDocumentKindRegistryForTests() lets tests work with the now-throwing registry. Rename the example class-wide kinds driving-question-board -> drivingQuestionBoard and word-wall -> wordWall, and the hyphenated test kinds to camelCase. Document the kind format and the classWideDocuments option in unit-configuration.md, and add a CLAUDE.md reminder to keep that doc in sync with authorable config changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…UE-550] The icon field was authored but inert — nothing read it. Remove it from the config type and the authoring doc; icon presentation (and its config shape) is a Stage-3 decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reflect the changes made during review: registry-derived owner/scope
(ownerType/scopeType), the createFirestoreMetadataDocument options object,
collapsed RTDB metadata (DBClassWideDocMetadata removed), titles resolved by
kind, kind validation + uniqueness in registerDocumentKind, the classWideDoc
rename, and dropping the unused icon field (classWideDocuments is { kind, title }).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p owners [CLUE-550] Firestore rules `hasCanonicalScope` used a bare `!= ""` test, which reads a field written as null (class-scoped docs store `unit: null`) as present, since `null != ""` is true in rules. Add a `hasScopeField` helper requiring a non-empty string and use it for both the scope check and the pointer-path branch selection, so a null-valued field counts as absent scope. Group owner ids are `group_<offeringId>_<groupId>`, so require both offeringId and currentGroupId when creating group-owned documents (was currentGroupId only), in validateDocumentKindCreation and the getOrCreateGroupDocument guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s/qa unit [CLUE-550] Lets the class-wide create/pointer flow be exercised end-to-end against a loadable demo unit: opening qa auto-creates one class-wide document per class per unit and claims its canonical pointer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…spec [CLUE-550] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLUE-550 Stage 2 — Class-wide slots + get-or-create via pointer
Stage 2 of the class-wide collaborative documents work. It makes the app auto-create a class-wide collaborative document — the Driving Question Board (DQB) is the default slot — exactly once per class per unit, server-race-free, reusing the CLUE-524 canonical-pointer engine. There is no UI yet (Sort Work sectioning, titling, presentation, and editing are Stage 3); this PR creates the documents but shows nothing.
Builds on the
concurrent/kindaxes and kind registry from Stage 1 (base branchCLUE-550-concurrent-kind-axes). The target architecture and per-axis status live indocs/document-axes/README.md(this PR flips the roadmap's creation-factory row to in progress and extends thecanonicalrow to class+unit).How a class-wide document is modeled
It is the same generic collaborative document as a group doc, differentiated only by orthogonal fields — no new document
type:concurrent: true(Stage-1 axis)kind(e.g.drivingQuestionBoard), registered withmetadataFields: { concurrent: true }context_id(classHash) +unit; noofferingId/groupIdcanonical: <kind>(label = kind)type: "group"(transitional, Stages 1–3)class_<classHash>(mirrors group docs'group_<offeringId>_<groupId>)Because it carries
concurrent: trueandtype: "group", Stage-1's behavior wiring (concurrent history manager, non-owner write-sync, class-wide read) already applies to it with no new branches.What's in the PR
classWideDocumentsunit config — an authored array of{ kind, title }slots onUnitConfiguration, read asstores.appConfig.classWideDocuments. This is the correct home for the authored DQB title.getGroupCanonicalPointerPathwith onegetCanonicalPointerPath(scope, label)that derives the path from the scope fields present:canonical/v1/classes/<classHash> / (offerings/<offeringId> | units/<unit>) / [groups/<groupId>] / slots/<label>. Theunits/segment is used only when there is no offering, so the group pointer path is byte-identical (no migration). A future problem/planning pointer (offering + owner) slots in as ausers/<owner>segment — documented, not built.canonicalPointerPathrewritten to build the same path field-by-field (path()+ string concat), gated byhasCanonicalScope(a non-empty offering or unit field — a field written asnullcounts as absent scope); plus a new immutable pointer write-guard match blockcanonical/v1/classes/{classId}/units/{unit}/slots/{label}.createDocument({ type: "group", kind }); its registeredownerType: "class"/scopeType: "classUnit"drive the rest.getDocumentOwnerreturns the synthetic class ownerclass_<classHash>,getDocumentScopeFieldsstampscontext_id+unit(noofferingId/groupId), andgetDocumentKindMetadataFields(kind)stampskind+concurrent— so the document is stamped correctly with no class-wide-specific branch, and thekind(e.g.drivingQuestionBoard) is not mis-stamped as"group".getOrCreateClassWideDocument({ kind, title })— builds the class+unit pointer path with the slot's kind as the label and delegates to the existing race-safegetOrCreateCanonicalDocument.createDeclaredClassWideDocuments()registers each declared slot's kind and fires onegetOrCreateClassWideDocumentper slot insideDB.connect()'sunitLoadedPromise.thenblock, fire-and-forget (the pointer transaction converges all class members to one doc per slot). A malformed or duplicate kind throws inregisterDocumentKindand is caught and skipped, so one bad author entry never blocks the others.Notes for reviewers
context_id; the create carries nocanonical, which is granted only by the pointer-claim update).history.length === 0weakening lived only in the superseded original DQB PR and is in neither this branch nor master. The metadata-before-history ordering is structurally guaranteed becausegetOrCreateCanonicalDocumentwrites the Firestore metadata before the document is opened; the emulator test verifies this rather than changing drift-guard code.classWideDocuments. Thedemo/units/qaunit now declares onedrivingQuestionBoardslot so the create/pointer flow can be exercised end-to-end (see Testing); production curriculum units declare none.type:"group"doc with no offering/group doesn't leak into existing type-based enumerations), and confirming the concurrent history-write path is authorized for theclass_<classHash>synthetic owner when editing is wired.Testing
getOrCreateClassWideDocumentfast/create paths; per-slot auto-creation.canonical-pointers-rules): class-member create/read of the class+unit pointer; immutability; cross-class denial; canonical-claim-confirmed-by-pointer; two-client convergence; Curriculum/Investigation/Problem/Section models #6 readability; plus a class-scoped (unit: null) doc cannot claim canonical. 25/25 green.check:typesandlint:buildclean.demo/units/qaunit declares adrivingQuestionBoardclass-wide slot (src/public/demo/units/qa/content.json). Loading that unit auto-creates one class-wide document per class+unit and claims its canonical pointer, so the Firestore metadata document (ownerclass_<classHash>,unit,kind: "drivingQuestionBoard",concurrent: true) and theclasses/<classHash>/units/<unit>/slots/drivingQuestionBoardpointer can be inspected in the emulator/console.🤖 Generated with Claude Code