From 3f80849da697f67d57254c19931632c936161a90 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 15:47:39 -0400 Subject: [PATCH 01/51] docs: Stage-3 Sort Work + workspace UI design spec [CLUE-610] Describes what the Stage-3 PR delivers: scope guards resolving the deferred scope-modeling checkpoint, explicit-null scope fields making the class+unit scope queryable (plus a backfill pass on the renamed axes script), a unit-scoped Sort Work listener, Whole Class sectioning with structured sort keys, presentation driven by the concurrent/kind axes, one shared edit predicate for Sort Work and the resources pane, and an emulator test establishing history-write authorization for synthetic document owners. Co-Authored-By: Claude Opus 5 (1M context) --- ...27-clue-550-stage-3-sort-work-ui-design.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md new file mode 100644 index 0000000000..526fd49463 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -0,0 +1,424 @@ +# CLUE-550 Stage 3 — Sort Work + workspace UI for class-wide documents — Design + +> **Status:** Design spec for this PR. Self-contained: it describes exactly what this PR delivers and cites only +> docs already in the repo. +> +> **Where this fits.** Stage 1 landed the two stored axes (`concurrent`, `kind`) and rebased group-document +> *behavior* onto them +> ([2026-07-23-clue-550-stage-1-document-axes-design.md](2026-07-23-clue-550-stage-1-document-axes-design.md)). +> Stage 2 made the app auto-create a class-wide collaborative document — the Driving Question Board (DQB) is the +> default slot — exactly once per class per unit, with **no UI** +> ([2026-07-23-clue-550-stage-2-class-wide-slots-design.md](2026-07-23-clue-550-stage-2-class-wide-slots-design.md)). +> Stage 3 makes those documents **visible, sectioned, titled, and editable**. The roadmap lives at +> [../../document-axes/README.md](../../document-axes/README.md). +> +> **Builds on:** +> - **Stage 1** — the stored `concurrent`/`kind` axes and the kind registry. +> - **Stage 2** — class-wide slot declaration (`classWideDocuments`), registry-derived owner/scope at creation, +> and title resolution by kind (`getDocumentTitle`). + +## Summary — what this PR delivers + +1. **Scope guards** — two named predicates over the stored association fields (`hasGroupScope`, + `hasClassUnitScope`) in a new leaf module. This resolves the scope-modeling checkpoint the project deferred to + its richest consumer: **narrow named guards, no `scopeLevel` enum and no unified `scope` struct.** +2. **Explicit-null scope fields for the `classUnit` scope**, so "scoped to a unit but not to a problem" is + directly queryable, following the convention that a scope field written as `null` means *absent scope* — + with a backfill pass added to Stage 1's one-shot script for documents created before the change. +3. **A unit-scoped Sort Work listener** so class-wide documents survive the Investigation and Problem filters — + a scope-driven query naming neither `type` nor `kind`. +4. **Sectioning by scope** — `byGroup` puts class-scoped collaborative documents in a "Whole Class" section + ahead of the numbered groups; `byName` puts them in a "No Name" section. `byGroup` ordering now comes from a + structured sort key rather than parsing the section label (a slice of review issue #7). +5. **Presentation off the axes** — the workspace title bar and the thumbnail treatment stop branching on + `type === "group"` and read `concurrent`, with the title resolved from the `kind` registry. +6. **One shared edit predicate** (`canUserEditDocument`) used by both Sort Work and the resources pane, + replacing the two inline gates (resolves review issue #4). +7. **History-write authorization for synthetic document owners**, established by an emulator test first. + +The stored `type` value stays `"group"` (the Stage 1–3 transitional convention). Retiring it is Stage 4. + +## The scope-modeling checkpoint, resolved + +The project deliberately deferred the question "does scope need a helper at all, and if so what shape?" to the +stage with the most scope consumers. Stage 3 is that stage. Working through its five consumers shows that only +one of them needs to know anything about scope: + +| Consumer | Branched on today | What it actually asks | +|---|---|---| +| Workspace title bar (`document.tsx`) | `document.isGroup` | `kind` → registry presentation | +| Thumbnail treatment (`thumbnail-document-item.tsx`) | `type === GroupDocument` | `concurrent` → collaborative styling | +| Edit gate (`sort-work-document-area.tsx`) | `uid === user.id` | `concurrent` + is the user inside the document's scope | +| Sort Work listener (`sorted-documents.ts`) | — | scope *breadth*: a unit-scoped document must survive a problem filter | +| `byGroup` / `byName` sectioning (`document-group.ts`) | `type === GroupDocument` | which cohort the document belongs to | + +Four of the five are answered by an axis a consumer already has (`kind`, `concurrent`) or by the query it +already writes. No consumer needs a "this is a class-wide document" flag, and none is introduced — a +consumer that asked that question would be branching on identity again, which is exactly what this project +exists to remove. + +**Decision: two narrow guards, no general scope model.** A new leaf module +`src/models/document/document-scope.ts` — structural parameter types only, no model imports, the same shape as +`document-kinds.ts`: + +```ts +hasGroupScope(doc) = !!doc.groupId +hasClassUnitScope(doc) = !!doc.unit && !doc.investigation && !doc.groupId +``` + +These read only stored association fields — no `type`, no `kind`, no `concurrent`. That matters for a real case: +under the "All" filter Sort Work lists documents from *other* units of the same class, and a class-wide document +from another unit has a `kind` that was never registered in this session (kinds are registered when the current +unit loads). A registry lookup would silently misfile it; a field read cannot. + +`hasClassUnitScope` is unambiguous across every document shape CLUE stores today, which is why it needs no +`concurrent` or `type` term to disambiguate: + +| document | `unit` | `investigation` | `groupId` | matches? | +|---|---|---|---|---| +| personal, learning log | `null` | — | — | no (`unit` is null) | +| problem, planning, publications | set | set | — | no (has `investigation`) | +| group | set | set | set | no | +| exemplar (from curriculum) | unset | unset | — | no (`unit` unset) | +| class-wide slot | set | `null` | — | **yes** | + +`docs/document-scope.md` gains a section recording these guards, the table above, and the decision not to +introduce a `scopeLevel` enum — with the reasoning that the existing scopes differ along more than one axis (a +personal document is class+owner scoped; a class-wide document is class+unit scoped), so a single ordered level +would be ambiguous. + +## Making the scope queryable: explicit-null fields + +The bottom row of that table is only true once the `classUnit` scope writes its absent curriculum fields +explicitly. `getDocumentScopeFields`'s `classUnit` case currently returns `{ unit, context_id }`, leaving +`investigation` and `problem` off the Firestore document entirely: + +```ts +case "classUnit": return { + unit: ctx.unit, + context_id: ctx.context_id, + investigation: null, // added: absent scope, stated explicitly + problem: null // added +}; +``` + +This follows the convention the rules already encode — `hasScopeField` in `firestore.rules` treats a +field written as `null` as *absent scope*, precisely because class-scoped documents (personal, learning log) +already store `unit: null`. Stamping the same way for `classUnit` makes the client read (`!doc.investigation`) +and the Firestore query (`where("investigation", "==", null)`) agree, and makes a class-wide document's stored +scope self-describing rather than defined by which fields happen to be missing. + +### Backfilling existing documents + +A class-wide document created before this change has neither field, so `hasClassUnitScope` still accepts it +client-side (a missing field is falsy just as `null` is) but the new Firestore query does not match it — it +would silently disappear from Sort Work under the Investigation and Problem filters. Class-wide documents are +unreleased, so only dev/QA partitions hold any, but the fix is cheap and the tooling already exists: Stage 1's +one-shot backfill script, `scripts/backfill-group-concurrent.ts` — renamed here to +`scripts/backfill-group-document-axes.ts` (see below). + +Its collection-group query — `where("type", "==", "group")` — already returns exactly the right superset, since +group and class-wide documents share the transitional type. The script gains a second, independent pass over +the same snapshot: + +| pass | selects | stamps | +|---|---|---| +| existing | `concurrent !== true` **and has a `groupId`** | `{ concurrent: true, kind: "group" }` | +| new | no `groupId`, and `investigation`/`problem` absent | `{ investigation: null, problem: null }` | + +Both remain additive, idempotent, and batched, and `BackfillResult` grows a counter per pass so a dry run +reports each separately. + +Two details worth stating: + +- **The existing pass is narrowed to documents that have a `groupId`.** Today it stamps `kind: "group"` on any + `type == "group"` document missing `concurrent`. A class-wide document is created with `concurrent: true`, so + in practice it is already filtered out — but if one ever lacked the field it would be mis-stamped with the + *group* kind, silently breaking its title and its canonical-pointer slot. Selecting on `groupId` makes the + two passes select disjoint sets by scope rather than relying on a value that a partial write could leave + missing. This is the same group-scope question `hasGroupScope` asks, expressed in the script. +- **The script is renamed to match what it now does.** `backfill-group-concurrent.ts` → + `backfill-group-document-axes.ts`, with `backfillGroupConcurrent` → `backfillGroupDocumentAxes` and the test + file renamed alongside it. It is no longer a one-field backfill: it normalizes the stored axes of every + group-typed document, and a name naming only `concurrent` would misdirect the next person who needs to add a + pass. Its header comment — which carries the dry-run/`APPLY=1` usage, since `scripts/README.md` documents + only the shared setup and does not list individual scripts — is updated to describe both passes. + +## The Sort Work query + +Class-wide documents carry no `investigation` or `problem`, so the two filters that add those equality clauses +drop them: + +| `docFilter` | existing filtered query | class-wide docs included? | +|---|---|---| +| All | `context_id ==` | yes | +| Unit | `+ unit in ` | yes | +| Investigation | `+ investigation ==` | **no** | +| Problem | `+ investigation == , problem ==` | **no** | + +`sorted-documents.ts` gains a third listener — a sibling of the existing `metadataDocsWithoutUnit` listener, +which exists for the same reason one level up (it picks up class-scoped personal documents when a unit filter is +applied): + +```ts +// active only when the filter is "Investigation" or "Problem" +const queryForUnitScoped = baseQuery + .where("unit", "in", this.curriculumConfig.getUnitCodeVariants(unit)) + .where("investigation", "==", null); +``` + +Its snapshot lands in a `metadataDocsUnitScoped` map, merged into `firestoreMetadataDocs` and deduped by key +alongside the other two maps, and its disposer joins the returned composite disposer. + +The query names no `type` and no `kind` — it asks for documents scoped to this unit but not to a problem, which +is a question about scope breadth, not about what the document *is*. This is the concrete replacement for PR +#2890's `queryForDqb` (`where("type", "==", "drivingQuestionBoard")` plus client-side unit filtering). It is +equality-only, so Firestore serves it from single-field indexes with no composite index to add. + +## Sectioning + +### `byGroup` + +```ts +if (hasGroupScope(doc)) return `${groupTerm} ${doc.groupId}`; +if (hasClassUnitScope(doc)) return kWholeClassSectionLabel; // "Whole Class" +const group = this.stores.groups.groupForUser(doc.uid); +return group ? `${groupTerm} ${group.id}` : `No ${groupTerm}`; +``` + +Group scope is tested first: a group document has both a `groupId` and a `unit`, and the group question is the +more specific one. The final branch is unchanged — a document owned by a class user is filed under that user's +current group. + +### `byName` + +Class-scoped collaborative documents go to a "No Name" section, matching PR #2890. The class-wide document has +no personal author, and listing it under every class member (the way a group document is listed under each of +its group's members) would repeat one document across the entire list. "No Name" is a distinct label from the +existing "Unknown" bucket, which is used for a document whose owner is not in the class store. No change is made +to `sortNameSectionLabels`, so "No Name" sorts alphabetically among the student names, as in #2890. + +### Sort keys instead of label parsing (part of review issue #7) + +`sortGroupSectionLabels` currently recovers a group number by stripping non-digits from the display label +(`parseInt(a.replace(/^\D+/g, ''), 10)`). Adding a non-numeric "Whole Class" section makes that worse — #2890 +had to special-case the label *string* inside the comparator, and any label whose text does not contain a number +sorts as `NaN`. + +`byGroup` instead builds a side sort-key map alongside its document map and passes it to the comparator, exactly +as `byDate` already passes `docMapWithDates` to `sortDateSectionLabels` and `byProblem` already carries a side +`labelMap`: + +```ts +type GroupSectionSortKey = + | { scope: "class" } // Whole Class — first + | { scope: "group"; groupId: string } // Group N — numeric ascending + | { scope: "none" }; // No Group — last + +sortGroupSections(labels, sortKeyMap) // class < group(asc) < none +``` + +**`label` remains both the display text and the section identity.** The sort key is used only for ordering. +That keeps three existing label-as-identity uses untouched and needs no migration of persisted state: + +| Use | Where | Persisted? | +|---|---|---| +| which sections are expanded | `ui.expandedSortWorkSections` (array of labels) | no — session `ui` store | +| which section holds the open document | `persistentUI` tab state `currentDocumentGroupId` = JSON of `{primaryLabel, primaryType, …}` | **yes** | +| re-finding that section on render | `sortedDocumentGroups.findIndex(g => g.label === primaryLabel)` | reads the persisted value | + +The rest of review issue #7 — that the open-document state round-trips section structure through a +JSON-stringified display label — is **not** addressed here and stays open. Replacing it means retyping +`IOpenDocumentsGroupMetadata` and touching `sorted-section`, `document-scroller`, `sort-work-view`, and +`persistent-ui`, which is larger than this feature and unrelated to class-wide documents. + +## Presentation + +Title resolution by `kind` already landed in Stage 2 (`getDocumentTitle` → `getDocumentDisplayTitle`). Stage 3 +wires the two remaining presentation sites, and both of them turn out to be `concurrent` questions rather than +`kind` questions — a class-wide document and a group document *look* alike because they are the same kind of +thing; only their titles differ. + +- **Workspace title bar** (`document.tsx`): `renderTitleBar`'s `document.isGroup` branch becomes + `document.concurrent`, and `renderGroupDocumentTitleBar` becomes a collaborative title bar that takes its + title from `getDocumentDisplayTitle(unit, document, appConfig)` — the registry lookup — instead of the + hardcoded `` `Group ${document.groupId} Document` ``. The scss hook passed as `docType` becomes + `document.kind ?? document.type`, so a group document still renders `.titlebar.group` with no style change, + while a class-wide document gets a class named for its kind. +- **Thumbnail treatment** (`thumbnail-document-item.tsx`): `document.type === GroupDocument` becomes + `!!document.concurrent` for the purple border and corner badge. + +**Deliberate deviation from PR #2890:** its absolutely-positioned, full-width centered title for the DQB +title bar is not ported. It is cosmetic, and it would put `drivingQuestionBoard` in a stylesheet — a +kind-specific rule cuts against the project's requirement that adding another class-wide document be a +*configuration* change. This is the one intentional difference from #2890's UX and should be confirmed in the +parity check. + +**No icon is authored.** Stage 2 removed the unused `icon` field from the `classWideDocuments` declaration and +left the question to this stage; nothing here needs one, so no icon field is added to the unit config or to the +kind registry. Adding one later is additive. + +## The edit gate (review issue #4) + +Two inline gates decide whether the Edit button appears: + +```ts +// sort-work-document-area.tsx: only the user's own documents +const showEdit = openDocument?.uid === user.id; +// document-view.tsx: a tab check that stands in for "these tabs only show your own documents" +const showEdit = !openDocument.isRemote && ((tab === "my-work") || (tab === "learningLog")); +``` + +Both are replaced by one predicate in `document-utils.ts`, beside its sibling question +`isDocumentAccessibleToUser`, and taking the same `{ document, documentMetadata, user }` parameters: + +```ts +canUserEditDocument({ document, documentMetadata, user }) + // own document -> true + // not concurrent -> false + // class-unit scope: context_id === user.classHash -> true (any class member) + // otherwise (group scope): groupId === user.currentGroupId +``` + +Fields are read preferring the reactive Firestore metadata and falling back to the lazily-fetched full document, +per field — the full document's `groupId` can still be undefined while a groupmate's document is loading, which +would otherwise hide the button until a reload. + +`document-view.tsx` adopts it as `!openDocument.isRemote && canUserEditDocument(...)`. That is a real behavior +change beyond class-wide documents — a group document surfaced in the class-work tab becomes editable, and a +classmate's shared document remains non-editable — so it gets its own test coverage and its own line in the +manual check. + +**Relationship to CLUE-525 / PR #2930.** That in-flight PR introduces the same rule for group documents as +`canEditSortWorkDocument` in `src/components/document/sort-work-edit-permission.ts`, with five loose primitive +parameters and the metadata merge spelled out at the call site. If it lands in the reshaped form (moved to +`document-utils.ts`, object parameters, the type test isolated behind a named `isCollaborativeDoc` local), this +stage's entire delta is inside the function body: swap that local for `!!concurrent` and add the class-scoped +arm. If it lands as originally written, this stage performs the move and reshape as part of resolving issue #4. +Either way the end state is the same file, name, and signature. + +`DocumentModel`'s `metadata` getter does not currently include `context_id` (the model stores it as `contextId`, +from CLUE-576), so it is added there to let the document-fallback path answer the class-scoped arm. + +## History-write authorization for synthetic owners + +Concurrent documents write history entries to `documents//history/`. That rule gates create and +read on `userOwnsDocument()`, which resolves through the **parent** document — `getDocumentPath()` builds +`.../documents/$(docId)` from the enclosing `match /documents/{docId}`, and `getDocumentOwner()` returns that +document's `uid`. `request.resource.data` — the entry being written — is never consulted, and the rules' helper +for that (`userIsRequestUser()`, used by the comments rules) is not applied here. + +A class-wide document's owner is the synthetic `class_`, which never equals a student's +`platform_user_id`. The same is already true of a group document's `group__`, so if this +denies history writes it denies them for group documents too — a pre-existing gap that has gone unnoticed +because group documents are unreleased and are exercised in permissive dev/QA partitions. + +**This is established by an emulator test before anything is changed.** The test writes a metadata document +whose `uid` is a synthetic group owner and has a class member attempt to create a history entry under it: + +- If the write is **denied**, the rule is rebased onto the axis — create and read allowed when the parent + document carries `concurrent: true` and the requester's `class_hash` matches its `context_id`, in addition to + `userOwnsDocument()`. That continues Stage 1's pattern of rebasing rules onto `concurrent`, and covers group + and class-wide documents with one clause. It deliberately does **not** narrow group-document history to the + owning group: the auth token carries no group id, so the rules cannot express that, and the RTDB rules already + grant write on the whole `classes/` subtree, so this matches the existing write surface rather than + widening it. +- If the write is **allowed**, no rules change is needed and the test documents why. + +Either way the test remains as the regression guard, and the outcome is recorded in this spec before the PR +opens. + +## Carried forward from Stage 2 + +Stage 2's whole-branch review flagged two items to verify before a slot is turned on. `src/public/demo/units/qa` +declares a `drivingQuestionBoard` slot, so they are live now. + +- **Type-based enumeration leak** — a `type: "group"` document with no `offeringId`/`groupId` must not be picked + up as an ordinary group document. Audited: + - `documents.byType` / `byTypeForUser` are never called with `GroupDocument`; every call site names an + exemplar, publication, personal, planning, or problem type. + - The 4-up view resolves documents through group *users* (`getUserDocument`), not a type scan. + - `document-group.ts`'s `byGroup`/`byName` were the real leak — a class-wide document landed in + `Group undefined`. That is what this stage's sectioning fixes. + - `tile-activity-badges.tsx` gates on `type === GroupDocument`, so a class-wide document passes it. The + activity listener is group-scoped, so only same-group members' presence appears on a class-wide document — + incomplete rather than incorrect, and exactly what Stage 4's unified class-scoped channel completes. No + change here. + - `document-workspace.tsx`'s `guaranteeInitialDocuments` re-opens a `type: "group"` primary document after a + reload (group documents are not loaded automatically); a class-wide document restored as the primary + document is covered by that same branch, which is the behavior we want. +- **Eager-open cost.** `createDeclaredClassWideDocuments` runs on every unit load, including the fast path, and + opens each declared slot's document into `stores.documents` (subscribing its history manager). This stage + measures that cost on a unit that declares a slot and records the result; if it is material, the fix is to + defer the open rather than the get-or-create, and it is called out as such rather than folded in silently. + +## Roadmap update + +[../../document-axes/README.md](../../document-axes/README.md), in this PR: + +- `kind` → **done** — presentation now reads the registry (title in Stage 2, title bar here) and no consumer + branches on kind. +- `scope` → stays **in progress**, with the read side recorded: narrow named guards (`hasGroupScope`, + `hasClassUnitScope`) over stored association fields, and the checkpoint outcome that no `scopeLevel` enum or + unified `scope` struct is introduced. +- behavior modules → the edit-gate predicate and the `concurrent`-driven presentation added to the list of + behaviors reading axes rather than `type`; the history-write rule outcome recorded once known. + +## Boundaries and non-goals + +- **Presence is Stage 4.** The parallel group/class activity listener and broadcaster, the session/offering + dimension (review issue #5), and unified activity badges are not touched. +- **The legacy type stays.** Documents still store `type: "group"`; flipping it to `"generic"` and removing + `GroupDocument`/`isGroup` is the Stage 4 closing cleanup. +- **Read access still keys on `type`.** `isDocumentAccessibleToUser` continues to grant class-wide read via + `metadata.type === GroupDocument`, which covers class-wide documents for free. Rebasing read access is + deferred to the `permissions` axis, per Stage 1. +- **Review issue #7 is only partly addressed** — section *ordering* stops parsing labels; the persisted + label round-trip does not change. +- **No general scope model.** Two guards, no enum, no struct. +- **No icon authoring surface.** + +## Testing + +- **Unit (Jest):** the two scope guards across every document shape in the table above; `getDocumentScopeFields` + stamping `investigation: null`/`problem: null` for `classUnit` and nothing else changing for other scope types; + `byGroup` sectioning (group scope → `Group N`; class-unit scope → `Whole Class`; owner's group; `No Group`) and + its sort-key ordering (class first, groups numeric ascending, `No Group` last, non-numeric labels + deterministic); `byName` filing a class-scoped document under `No Name`; the unit-scoped listener firing only + under the Investigation/Problem filters, and its documents merging and deduping into `firestoreMetadataDocs`; + `canUserEditDocument` across own / own-group / other-group / other-student / class-wide-as-class-member / + class-wide-as-outsider / non-concurrent cases, including the per-field metadata-preferring merge with a + still-loading document. +- **Backfill script (Jest, mock Firestore)** — the existing `backfill-group-concurrent.test.ts` cases carried + over to the renamed `scripts/backfill-group-document-axes.test.ts`, plus: the new + pass stamps `{ investigation: null, problem: null }` only on group-typed documents with no `groupId` that + lack those fields; the existing pass stamps `{ concurrent: true, kind: "group" }` only on documents that + *have* a `groupId`, so a class-wide document is never stamped with the group kind; the two passes select + disjoint sets; both are idempotent (a fully-migrated set writes nothing) and a dry run writes nothing while + reporting each pass's count. +- **Component:** the title bar rendering the registry title for a class-wide document and the unchanged + `Group N Document` for a group document; the thumbnail collaborative treatment driven by `concurrent`. +- **Rules (emulator):** the synthetic-owner history-write test described above, plus whichever outcome it + establishes. +- **Manual end-to-end** on `demo/units/qa`, which declares a `drivingQuestionBoard` slot: the document appears + in Sort Work under "Whole Class" for every filter (All, Unit, Investigation, Problem) and under "No Name" when + sorting by name; its title is the authored one; the Edit button appears for any class member; editing it from + two browser sessions persists from both; a group document's title, thumbnail, and Edit button are unchanged; + and the resources-pane Edit button behaves correctly in the my-work, learning-log, and class-work tabs. +- Full `npm test`, `npm run check:types`, `npm run lint:build`, and the `firebase-test` rules suite green. + +## References + +- Stage 1 design: + [2026-07-23-clue-550-stage-1-document-axes-design.md](2026-07-23-clue-550-stage-1-document-axes-design.md). +- Stage 2 design: + [2026-07-23-clue-550-stage-2-class-wide-slots-design.md](2026-07-23-clue-550-stage-2-class-wide-slots-design.md). +- Document-axes roadmap: [../../document-axes/README.md](../../document-axes/README.md). +- Scoping model this stage extends: [../../document-scope.md](../../document-scope.md). +- Key code sites: `src/models/document/document-scope.ts` (new), `src/models/document/document-kinds.ts`, + `src/models/document/document-utils.ts`, `src/models/document/document.ts`, + `src/models/stores/document-group.ts`, `src/models/stores/sorted-documents.ts`, + `src/utilities/sort-document-utils.ts`, `src/components/document/document.tsx`, + `src/components/document/sort-work-document-area.tsx`, `src/components/navigation/document-view.tsx`, + `src/components/thumbnail/thumbnail-document-item.tsx`, `firestore.rules`, + `scripts/backfill-group-document-axes.ts` (Stage 1's one-shot backfill, extended and renamed here). From 93d4af6a13ca5a5c26fa5f7aa41d60f9ed49dd93 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 17:20:01 -0400 Subject: [PATCH 02/51] feat: scope guards for group and class+unit scoped documents [CLUE-610] Add pure predicates to read document scope from stored association fields without consulting the kind registry, enabling consumers like Sort Work to distinguish documents by scope regardless of whether their kind is registered in the current session. These guard functions pin the implementation against all stored document shapes: personal, problem, group, exemplar, class-wide, and legacy class-wide documents. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-scope.md | 26 +++++++++++++ src/models/document/document-scope.test.ts | 40 ++++++++++++++++++++ src/models/document/document-scope.ts | 43 ++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/models/document/document-scope.test.ts create mode 100644 src/models/document/document-scope.ts diff --git a/docs/document-scope.md b/docs/document-scope.md index afb57d16e7..5e9249dd68 100644 --- a/docs/document-scope.md +++ b/docs/document-scope.md @@ -50,6 +50,32 @@ So the environment service has to be created, then the root node created with th The top level properties of the environment object are not supposed to be modified after it is created, based on this "shallowly immutable" note here: https://mobx-state-tree.js.org/concepts/dependency-injection However we are doing this when the appConfig is added to the environment object in `Documents#add` +## Reading a document's scope in code + +Consumers that need to know a document's scope read its stored association fields through the guards +in `src/models/document/document-scope.ts`, rather than branching on the document `type`: + +- `hasGroupScope(doc)` — the document is scoped to a single group (`groupId` is set). +- `hasClassUnitScope(doc)` — the document is scoped to a class and a unit and nothing narrower: a + class-wide collaborative document. + +No other stored shape satisfies `hasClassUnitScope`: + +| document | `unit` | `investigation` | `groupId` | class+unit scoped? | +|---|---|---|---|---| +| personal, learning log | `null` | — | — | no | +| problem, planning, publications | set | set | — | no | +| group | set | set | set | no | +| exemplar (from curriculum) | unset | unset | — | no | +| class-wide slot | set | `null` | — | **yes** | + +**No `scopeLevel` enum and no unified `scope` struct.** Scope is multi-dimensional — a personal +document is class+owner scoped while a class-wide document is class+unit scoped — so a single +ordered level would be ambiguous. Named guards are added as consumers need them. + +A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents +from other units, whose kinds are not registered in the current session. + # View layer ## React Context diff --git a/src/models/document/document-scope.test.ts b/src/models/document/document-scope.test.ts new file mode 100644 index 0000000000..02305db509 --- /dev/null +++ b/src/models/document/document-scope.test.ts @@ -0,0 +1,40 @@ +import { hasClassUnitScope, hasGroupScope } from "./document-scope"; + +describe("document scope guards", () => { + // One case per document shape CLUE stores, so the guards are pinned against every shape they + // must distinguish rather than only the two this feature introduces. + const personal = { unit: null, investigation: null, groupId: null }; + const problem = { unit: "sas", investigation: "1", problem: "2", groupId: null }; + const group = { unit: "sas", investigation: "1", problem: "2", groupId: "3" }; + const exemplar = { unit: undefined, investigation: undefined, groupId: undefined }; + const classWide = { unit: "sas", investigation: null, groupId: null }; + const legacyClassWide = { unit: "sas" }; // created before investigation/problem were stamped + + describe("hasGroupScope", () => { + it("is true only when the document carries a group id", () => { + expect(hasGroupScope(group)).toBe(true); + expect(hasGroupScope(personal)).toBe(false); + expect(hasGroupScope(problem)).toBe(false); + expect(hasGroupScope(exemplar)).toBe(false); + expect(hasGroupScope(classWide)).toBe(false); + }); + }); + + describe("hasClassUnitScope", () => { + it("is true only for a document scoped to a unit and nothing narrower", () => { + expect(hasClassUnitScope(classWide)).toBe(true); + expect(hasClassUnitScope(legacyClassWide)).toBe(true); + }); + + it("is false for every other document shape", () => { + expect(hasClassUnitScope(personal)).toBe(false); // no unit + expect(hasClassUnitScope(problem)).toBe(false); // has an investigation + expect(hasClassUnitScope(group)).toBe(false); // has an investigation and a group + expect(hasClassUnitScope(exemplar)).toBe(false); // no unit + }); + + it("treats an empty-string unit as no unit", () => { + expect(hasClassUnitScope({ unit: "", investigation: null, groupId: null })).toBe(false); + }); + }); +}); diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts new file mode 100644 index 0000000000..36f50bb057 --- /dev/null +++ b/src/models/document/document-scope.ts @@ -0,0 +1,43 @@ +/** + * Guards over a document's stored scope association fields. + * + * A document's scope lives in its association fields (`context_id`, `unit`, `investigation`, + * `problem`, `offeringId`, `groupId`), stamped at creation from the kind's registered `scopeType` + * (see document-kinds.ts). Consumers that need a document's scope read it through these guards + * rather than branching on `type` or looking `kind` up in the registry: a document listed in Sort + * Work may belong to another unit whose kind is not registered in the current session, but its + * stored fields are always present. + * + * These are narrow named predicates by design. Scope is multi-dimensional — a personal document is + * class+owner scoped while a class-wide document is class+unit scoped — so a single ordered + * "scope level" would be ambiguous. See docs/document-scope.md. + */ + +/** The scope fields the guards read. Structural, so this stays a leaf module. */ +export interface IDocumentScopeFields { + unit?: string | null; + investigation?: string | null; + groupId?: string | null; +} + +/** + * True when the document is scoped to a single group. + * + * In Firestore metadata only group-scoped documents carry a `groupId`; other documents deliberately + * leave it unset so a stale group id can never be read back (see DocumentMetadataModel.groupId). + */ +export function hasGroupScope(doc: IDocumentScopeFields): boolean { + return !!doc.groupId; +} + +/** + * True when the document is scoped to a class and a unit and nothing narrower — a class-wide + * collaborative document. + * + * No other stored shape matches: class-scoped documents (personal, learning log) have `unit: null`; + * offering-scoped documents (problem, planning, publications) carry an `investigation`; group + * documents carry both an `investigation` and a `groupId`; curriculum exemplars carry no `unit`. + */ +export function hasClassUnitScope(doc: IDocumentScopeFields): boolean { + return !!doc.unit && !doc.investigation && !doc.groupId; +} From 0271579011ef36fdcf4c47b41ae726d61c56d375 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 17:40:51 -0400 Subject: [PATCH 03/51] feat: state the class+unit scope's absent curriculum fields explicitly [CLUE-610] Stamp investigation:null and problem:null on classUnit-scoped documents instead of omitting them, so Firestore can query for documents scoped to a unit but not a problem (a null-valued field is queryable; a missing one is not). Widens IDocumentScopeContext.investigation/.problem to accept null to match. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/db.test.ts | 6 +++ src/models/document/document-kinds.test.ts | 48 ++++++++++++++++++++-- src/models/document/document-kinds.ts | 12 ++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts index 897637cdbd..4639c31ff8 100644 --- a/src/lib/db.test.ts +++ b/src/lib/db.test.ts @@ -476,6 +476,12 @@ describe("db", () => { kind: "drivingQuestionBoard", concurrent: true, uid: "class_class-1" }); expect(setPayloads[0].title).toBeUndefined(); + // The class+unit scope states its absent curriculum fields explicitly so the scope is + // queryable; it must still carry no offering or group. + expect(setPayloads.some((d: any) => + d.investigation === null && d.problem === null && + d.offeringId === undefined && d.groupId === undefined + )).toBe(true); }); }); diff --git a/src/models/document/document-kinds.test.ts b/src/models/document/document-kinds.test.ts index 6923279985..9b95567efa 100644 --- a/src/models/document/document-kinds.test.ts +++ b/src/models/document/document-kinds.test.ts @@ -1,7 +1,8 @@ import { GroupDocument, PersonalDocument, ProblemDocument } from "./document-types"; import { getDocumentKindInfo, getDocumentKindMetadataFields, getDocumentOwner, getDocumentOwnerType, - getDocumentScopeFields, getDocumentTitle, isValidDocumentKind, registerDocumentKind + getDocumentScopeFields, getDocumentTitle, isValidDocumentKind, registerDocumentKind, + resetDocumentKindRegistryForTests } from "./document-kinds"; describe("isValidDocumentKind", () => { @@ -99,10 +100,12 @@ describe("document kinds registry", () => { }); }); - it("returns the unit and context_id for a class-unit kind", () => { + it("returns the unit and context_id for a class-unit kind, with curriculum scope stated as absent", () => { registerDocumentKind("testWordWall", { metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit" }); - expect(getDocumentScopeFields("testWordWall", ctx)).toEqual({ unit: "msu", context_id: "class-h" }); + expect(getDocumentScopeFields("testWordWall", ctx)).toEqual({ + unit: "msu", context_id: "class-h", investigation: null, problem: null + }); }); it("returns offering scope plus the problem context for an offering kind", () => { @@ -139,4 +142,43 @@ describe("document kinds registry", () => { expect(getDocumentTitle({ type: "unregistered" })).toBeUndefined(); }); }); + + describe("getDocumentScopeFields for a classUnit kind", () => { + const ctx = { + unit: "sas", investigation: "1", problem: "2", + context_id: "class-hash", groupId: "3", offeringId: "off-1" + }; + + beforeEach(() => { + resetDocumentKindRegistryForTests(); + registerDocumentKind("testClassWideKind", { + metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit" + }); + }); + + it("stamps the unit and class, and states the absent curriculum scope explicitly", () => { + // `investigation`/`problem` are written as null rather than omitted: a null scope field means + // "absent scope" (firestore.rules hasScopeField), which is what makes the class+unit scope + // queryable — `where("investigation", "==", null)` cannot match a missing field. + expect(getDocumentScopeFields("testClassWideKind", ctx)).toEqual({ + unit: "sas", + context_id: "class-hash", + investigation: null, + problem: null + }); + }); + + it("does not stamp an offering or a group", () => { + const fields = getDocumentScopeFields("testClassWideKind", ctx); + expect(fields.offeringId).toBeUndefined(); + expect(fields.groupId).toBeUndefined(); + }); + + it("leaves the group scope unchanged", () => { + expect(getDocumentScopeFields(GroupDocument, ctx)).toEqual({ + unit: "sas", investigation: "1", problem: "2", + context_id: "class-hash", offeringId: "off-1", groupId: "3" + }); + }); + }); }); diff --git a/src/models/document/document-kinds.ts b/src/models/document/document-kinds.ts index 9fca31eadc..33cc527f33 100644 --- a/src/models/document/document-kinds.ts +++ b/src/models/document/document-kinds.ts @@ -111,8 +111,8 @@ export function getDocumentOwner(kind: string|null|undefined, ctx: IDocumentOwne */ export interface IDocumentScopeContext { unit: string | null; - investigation?: string; - problem?: string; + investigation?: string | null; + problem?: string | null; context_id: string; groupId?: string; offeringId?: string; @@ -136,7 +136,13 @@ export function getDocumentScopeFields( }; case "classUnit": return { unit: ctx.unit, - context_id: ctx.context_id + context_id: ctx.context_id, + // Stated explicitly rather than omitted. A scope field written as null means "absent scope" + // (firestore.rules `hasScopeField`), the same convention class-scoped documents use for + // `unit: null`. It is what lets Sort Work query for documents scoped to a unit but not to a + // problem — Firestore cannot match a field that is missing. + investigation: null, + problem: null }; case "class": return { unit: null, From 46435fcb1d9aa99090420d22ffc524215f081948 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 17:49:24 -0400 Subject: [PATCH 04/51] feat: backfill class-wide curriculum scope; scope-select both axes passes [CLUE-610] Co-Authored-By: Claude Opus 5 (1M context) --- scripts/backfill-group-concurrent.test.ts | 46 --------- scripts/backfill-group-concurrent.ts | 77 -------------- scripts/backfill-group-document-axes.test.ts | 81 +++++++++++++++ scripts/backfill-group-document-axes.ts | 100 +++++++++++++++++++ 4 files changed, 181 insertions(+), 123 deletions(-) delete mode 100644 scripts/backfill-group-concurrent.test.ts delete mode 100644 scripts/backfill-group-concurrent.ts create mode 100644 scripts/backfill-group-document-axes.test.ts create mode 100644 scripts/backfill-group-document-axes.ts diff --git a/scripts/backfill-group-concurrent.test.ts b/scripts/backfill-group-concurrent.test.ts deleted file mode 100644 index 0b38b22d85..0000000000 --- a/scripts/backfill-group-concurrent.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Firestore } from "firebase-admin/firestore"; -import { backfillGroupConcurrent } from "./backfill-group-concurrent"; - -// Minimal Firestore-admin stand-in: a collection-group query returning canned docs, and a batch recorder. -function makeDb(docs: any[]) { - const writes: any[] = []; - const batch = { - set: (ref: any, data: any, opts: any) => { writes.push({ ref, data, opts }); }, - commit: () => Promise.resolve(), - }; - return { - writes, - collectionGroup: () => ({ - where: () => ({ get: () => Promise.resolve({ size: docs.length, docs }) }), - }), - batch: () => batch, - }; -} -const mkDoc = (key: string, concurrent?: boolean) => ({ - ref: { path: `authed/p/documents/${key}` }, - get: (field: string) => (field === "concurrent" ? concurrent : undefined), -}); - -describe("backfillGroupConcurrent", () => { - it("dry run reports counts and writes nothing", async () => { - const db = makeDb([mkDoc("a"), mkDoc("b", true)]); - const res = await backfillGroupConcurrent(db as unknown as Firestore, { dryRun: true, log: () => undefined }); - expect(res).toEqual({ total: 2, updated: 0 }); - expect(db.writes.length).toBe(0); - }); - - it("APPLY writes concurrent+kind only to group docs missing concurrent", async () => { - const db = makeDb([mkDoc("a"), mkDoc("b", true), mkDoc("c")]); - const res = await backfillGroupConcurrent(db as unknown as Firestore, { dryRun: false, log: () => undefined }); - expect(res).toEqual({ total: 3, updated: 2 }); - expect(db.writes.map((w: any) => w.ref.path)).toEqual(["authed/p/documents/a", "authed/p/documents/c"]); - expect(db.writes[0]).toMatchObject({ data: { concurrent: true, kind: "group" }, opts: { merge: true } }); - }); - - it("is idempotent — a fully-migrated set writes nothing", async () => { - const db = makeDb([mkDoc("a", true), mkDoc("b", true)]); - const res = await backfillGroupConcurrent(db as unknown as Firestore, { dryRun: false, log: () => undefined }); - expect(res).toEqual({ total: 2, updated: 0 }); - expect(db.writes.length).toBe(0); - }); -}); diff --git a/scripts/backfill-group-concurrent.ts b/scripts/backfill-group-concurrent.ts deleted file mode 100644 index 85e20ad346..0000000000 --- a/scripts/backfill-group-concurrent.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Backfill the `concurrent` axis onto pre-existing group documents. -// -// Group documents created before the `concurrent` field existed have no stored `concurrent`, so their -// history manager would run in single-writer mode. This stamps `{ concurrent: true, kind: "group" }` onto -// every `type == "group"` document that lacks `concurrent`. Additive, idempotent, batched. -// -// Requires a Firebase service account key at scripts/serviceAccountKey.json (see scripts/README.md). The -// `documents` collection-group query needs a single-field COLLECTION_GROUP index on `type` -// (firestore.indexes.json; deploy with `firebase deploy --only firestore:indexes`, or use the one-click link -// Firestore prints on first run). -// -// Dry run (reports counts, writes nothing): cd scripts && npx tsx backfill-group-concurrent.ts -// Apply (performs the writes): cd scripts && APPLY=1 npx tsx backfill-group-concurrent.ts - -import type { Firestore } from "firebase-admin/firestore"; - -export interface BackfillResult { - total: number; - updated: number; -} - -/** - * Stamp `{ concurrent: true, kind: "group" }` onto every `type == "group"` document missing `concurrent`. - * Pure (no admin initialization) so it can be unit-tested with a mock Firestore. - */ -export async function backfillGroupConcurrent( - db: Firestore, - { dryRun = true, log = console.log }: { dryRun?: boolean; log?: (message: string) => void } = {} -): Promise { - const snap = await db.collectionGroup("documents").where("type", "==", "group").get(); - const needing = snap.docs.filter((d) => d.get("concurrent") !== true); - log(`group docs: ${snap.size} total, ${needing.length} missing concurrent`); - if (dryRun) { - log("DRY RUN — set APPLY=1 to write"); - return { total: snap.size, updated: 0 }; - } - - let batch = db.batch(); - let n = 0; - let updated = 0; - for (const doc of needing) { - batch.set(doc.ref, { concurrent: true, kind: "group" }, { merge: true }); - if (++n === 400) { - await batch.commit(); - updated += n; - batch = db.batch(); - n = 0; - } - } - if (n > 0) { - await batch.commit(); - updated += n; - } - log(`updated ${updated} documents`); - return { total: snap.size, updated }; -} - -async function main() { - // Imported lazily so the Jest test can import backfillGroupConcurrent without loading firebase-admin - // or the import.meta-using script-utils module. - const admin = (await import("firebase-admin")).default; - const { getScriptRootFilePath } = await import("./lib/script-utils.js"); - admin.initializeApp({ - credential: admin.credential.cert(getScriptRootFilePath("serviceAccountKey.json")), - }); - const result = await backfillGroupConcurrent(admin.firestore(), { dryRun: process.env.APPLY !== "1" }); - console.log("done", result); - process.exit(0); -} - -// Run only when invoked directly (via tsx), never when imported by the Jest test. -if (!process.env.JEST_WORKER_ID) { - main().catch((err) => { - console.error(err); - process.exit(1); - }); -} diff --git a/scripts/backfill-group-document-axes.test.ts b/scripts/backfill-group-document-axes.test.ts new file mode 100644 index 0000000000..0c8cac251a --- /dev/null +++ b/scripts/backfill-group-document-axes.test.ts @@ -0,0 +1,81 @@ +import type { Firestore } from "firebase-admin/firestore"; +import { backfillGroupDocumentAxes } from "./backfill-group-document-axes"; + +// Minimal Firestore-admin stand-in: a collection-group query returning canned docs, and a batch recorder. +function makeDb(docs: any[]) { + const writes: any[] = []; + const batch = { + set: (ref: any, data: any, opts: any) => { writes.push({ ref, data, opts }); }, + commit: () => Promise.resolve(), + }; + return { + writes, + collectionGroup: () => ({ + where: () => ({ get: () => Promise.resolve({ size: docs.length, docs }) }), + }), + batch: () => batch, + }; +} + +// A group-scoped document: carries a groupId. +const mkGroupDoc = (key: string, concurrent?: boolean) => ({ + ref: { path: `authed/p/documents/${key}` }, + get: (field: string) => ({ concurrent, groupId: "3" } as Record)[field], +}); +// A class-wide document: no groupId. `fields` supplies whatever scope fields it already has. +const mkClassWideDoc = (key: string, fields: Record = {}) => ({ + ref: { path: `authed/p/documents/${key}` }, + get: (field: string) => ({ concurrent: true, ...fields } as Record)[field], +}); + +const quiet = { log: () => undefined }; + +describe("backfillGroupDocumentAxes", () => { + it("dry run reports both passes and writes nothing", async () => { + const db = makeDb([mkGroupDoc("a"), mkGroupDoc("b", true), mkClassWideDoc("c")]); + const res = await backfillGroupDocumentAxes(db as unknown as Firestore, { dryRun: true, ...quiet }); + expect(res).toEqual({ total: 3, concurrentUpdated: 0, scopeUpdated: 0 }); + expect(db.writes.length).toBe(0); + }); + + it("stamps concurrent+kind only on group-scoped docs missing concurrent", async () => { + const db = makeDb([mkGroupDoc("a"), mkGroupDoc("b", true), mkGroupDoc("c")]); + const res = await backfillGroupDocumentAxes(db as unknown as Firestore, { dryRun: false, ...quiet }); + expect(res).toEqual({ total: 3, concurrentUpdated: 2, scopeUpdated: 0 }); + expect(db.writes.map((w: any) => w.ref.path)).toEqual(["authed/p/documents/a", "authed/p/documents/c"]); + expect(db.writes[0]).toMatchObject({ data: { concurrent: true, kind: "group" }, opts: { merge: true } }); + }); + + it("stamps null curriculum scope only on class-wide docs that lack it", async () => { + const db = makeDb([ + mkClassWideDoc("old"), // needs both fields + mkClassWideDoc("new", { investigation: null, problem: null }) // already migrated + ]); + const res = await backfillGroupDocumentAxes(db as unknown as Firestore, { dryRun: false, ...quiet }); + expect(res).toEqual({ total: 2, concurrentUpdated: 0, scopeUpdated: 1 }); + expect(db.writes).toEqual([{ + ref: { path: "authed/p/documents/old" }, + data: { investigation: null, problem: null }, + opts: { merge: true } + }]); + }); + + it("never stamps the group kind onto a class-wide document", async () => { + // A class-wide document that somehow lacks `concurrent` must not be swept into the group pass: + // kind:"group" would break both its title resolution and its canonical-pointer slot. + const db = makeDb([mkClassWideDoc("cw", { concurrent: undefined })]); + const res = await backfillGroupDocumentAxes(db as unknown as Firestore, { dryRun: false, ...quiet }); + expect(res.concurrentUpdated).toBe(0); + expect(db.writes.every((w: any) => w.data.kind === undefined)).toBe(true); + }); + + it("is idempotent — a fully-migrated set writes nothing", async () => { + const db = makeDb([ + mkGroupDoc("a", true), + mkClassWideDoc("c", { investigation: null, problem: null }) + ]); + const res = await backfillGroupDocumentAxes(db as unknown as Firestore, { dryRun: false, ...quiet }); + expect(res).toEqual({ total: 2, concurrentUpdated: 0, scopeUpdated: 0 }); + expect(db.writes.length).toBe(0); + }); +}); diff --git a/scripts/backfill-group-document-axes.ts b/scripts/backfill-group-document-axes.ts new file mode 100644 index 0000000000..6cd7523f4d --- /dev/null +++ b/scripts/backfill-group-document-axes.ts @@ -0,0 +1,100 @@ +// Normalize the stored axes of `type == "group"` documents — both regular group documents and +// class-wide collaborative documents, which share that transitional type. +// +// Two independent passes over one collection-group query, selected by scope so they cover disjoint +// sets. A group-scoped document carries a groupId; a class-wide document does not. +// +// group-scoped, missing `concurrent` -> { concurrent: true, kind: "group" } +// class-wide, missing curriculum scope -> { investigation: null, problem: null } +// +// The first pass restores the concurrent history manager for group documents created before the +// `concurrent` axis existed. The second states a class-wide document's absent curriculum scope +// explicitly, which is what makes it findable by Sort Work's unit-scoped query. Both are additive, +// idempotent, and batched. +// +// Requires a Firebase service account key at scripts/serviceAccountKey.json (see scripts/README.md). +// The `documents` collection-group query needs a single-field COLLECTION_GROUP index on `type` +// (firestore.indexes.json; deploy with `firebase deploy --only firestore:indexes`, or use the +// one-click link Firestore prints on first run). +// +// Dry run (reports counts, writes nothing): cd scripts && npx tsx backfill-group-document-axes.ts +// Apply (performs the writes): cd scripts && APPLY=1 npx tsx backfill-group-document-axes.ts + +import type { Firestore } from "firebase-admin/firestore"; + +export interface BackfillResult { + total: number; + concurrentUpdated: number; + scopeUpdated: number; +} + +/** + * Run both backfill passes. Pure (no admin initialization) so it can be unit-tested with a mock + * Firestore. + */ +export async function backfillGroupDocumentAxes( + db: Firestore, + { dryRun = true, log = console.log }: { dryRun?: boolean; log?: (message: string) => void } = {} +): Promise { + const snap = await db.collectionGroup("documents").where("type", "==", "group").get(); + + // Select each pass by scope, not by the value it is about to write: a partial write could leave a + // class-wide document without `concurrent`, and stamping it kind:"group" would break both its + // title resolution and its canonical-pointer slot (the slot label is the kind). + const needingConcurrent = snap.docs.filter((d) => !!d.get("groupId") && d.get("concurrent") !== true); + const needingScope = snap.docs.filter((d) => + !d.get("groupId") && (d.get("investigation") === undefined || d.get("problem") === undefined)); + + log(`group-typed docs: ${snap.size} total, ` + + `${needingConcurrent.length} missing concurrent, ${needingScope.length} missing curriculum scope`); + if (dryRun) { + log("DRY RUN — set APPLY=1 to write"); + return { total: snap.size, concurrentUpdated: 0, scopeUpdated: 0 }; + } + + const writes = [ + ...needingConcurrent.map((d) => ({ ref: d.ref, data: { concurrent: true, kind: "group" } })), + ...needingScope.map((d) => ({ ref: d.ref, data: { investigation: null, problem: null } })), + ]; + + let batch = db.batch(); + let n = 0; + for (const write of writes) { + batch.set(write.ref, write.data, { merge: true }); + if (++n % 400 === 0) { + await batch.commit(); + batch = db.batch(); + } + } + if (n % 400 !== 0) { + await batch.commit(); + } + + log(`updated ${needingConcurrent.length} concurrent, ${needingScope.length} curriculum scope`); + return { + total: snap.size, + concurrentUpdated: needingConcurrent.length, + scopeUpdated: needingScope.length, + }; +} + +async function main() { + // Imported lazily so the Jest test can import backfillGroupDocumentAxes without loading + // firebase-admin or the import.meta-using script-utils module. + const admin = (await import("firebase-admin")).default; + const { getScriptRootFilePath } = await import("./lib/script-utils.js"); + admin.initializeApp({ + credential: admin.credential.cert(getScriptRootFilePath("serviceAccountKey.json")), + }); + const result = await backfillGroupDocumentAxes(admin.firestore(), { dryRun: process.env.APPLY !== "1" }); + console.log("done", result); + process.exit(0); +} + +// Run only when invoked directly (via tsx), never when imported by the Jest test. +if (!process.env.JEST_WORKER_ID) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} From 229c0cde16447a4aeac81d15c39eb9b1330ba78e Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 18:40:53 -0400 Subject: [PATCH 05/51] feat: section class-wide documents under Whole Class, ordered by scope [CLUE-610] byGroup now reads a document's stored scope (hasGroupScope/hasClassUnitScope) instead of branching on doc.type === GroupDocument, and files class-wide documents into their own "Whole Class" section. sortGroupSectionLabels is replaced by sortGroupSections, which orders sections from a per-label GroupSectionSortKey (class/group/none) carried alongside the display label, rather than parsing group numbers out of the (translatable) label text. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/stores/document-group.test.ts | 74 ++++++++++++++++++----- src/models/stores/document-group.ts | 33 +++++++--- src/utilities/sort-document-utils.test.ts | 43 +++++++++++++ src/utilities/sort-document-utils.ts | 45 ++++++++++++-- 4 files changed, 166 insertions(+), 29 deletions(-) diff --git a/src/models/stores/document-group.test.ts b/src/models/stores/document-group.test.ts index 1adc23b3b5..3e9acc8dfe 100644 --- a/src/models/stores/document-group.test.ts +++ b/src/models/stores/document-group.test.ts @@ -42,6 +42,10 @@ const mockDocumentsData: DocumentModelSnapshotType[] = [ { uid: "1", //Joe (group doc for group 5) type: GroupDocument, key:"Group 5 Group Doc", groupId: "5", createdAt: 6, content: { tiles: [] } as DocumentContentSnapshotType + }, + { uid: "class_mock", // the class-wide synthetic owner + type: GroupDocument, key:"Class Wide Doc", createdAt: 7, unit: "sas", + content: { tiles: [] } as DocumentContentSnapshotType } ]; @@ -93,6 +97,16 @@ const mockMetadataDocuments: SnapshotIn = { groupId: "5", investigation: "1", problem: "1" + }, + "Class Wide Doc": { + uid: "class_mock", + type: GroupDocument, key: "Class Wide Doc", createdAt: 7, + tools: [], + unit: "sas", + investigation: null, + problem: null, + kind: "drivingQuestionBoard", + concurrent: true } }; @@ -239,8 +253,11 @@ describe('DocumentGroup Model', () => { const documentsByGroup = sortedDocuments.sortBy("Group"); + // documentsByGroup[0] is "Whole Class" (the class-wide document, not bookmarked); group + // sections follow it in numeric order. + // Group 3: Scott (bookmarked), Kirk (not), Group 3 group doc (not) - const documentCollection = documentsByGroup[0].byBookmarked; + const documentCollection = documentsByGroup[1].byBookmarked; expect(documentCollection.length).toBe(2); expect(documentCollection[0].label).toBe("Bookmarked"); expect(documentCollection[0].documents.length).toBe(1); @@ -248,7 +265,7 @@ describe('DocumentGroup Model', () => { expect(documentCollection[1].documents.length).toBe(2); // Group 5: Joe (bookmarked), Group 5 group doc (not) - const documentCollection2 = documentsByGroup[1].byBookmarked; + const documentCollection2 = documentsByGroup[2].byBookmarked; expect(documentCollection2.length).toBe(2); expect(documentCollection2[0].label).toBe("Bookmarked"); expect(documentCollection2[0].documents.length).toBe(1); @@ -256,7 +273,7 @@ describe('DocumentGroup Model', () => { expect(documentCollection2[1].documents.length).toBe(1); // Group 9: Dennis (not bookmarked) - const documentCollection3 = documentsByGroup[2].byBookmarked; + const documentCollection3 = documentsByGroup[3].byBookmarked; expect(documentCollection3.length).toBe(2); expect(documentCollection3[0].label).toBe("Bookmarked"); expect(documentCollection3[0].documents.length).toBe(0); @@ -328,13 +345,30 @@ describe('DocumentGroup Model', () => { expect(collection3[0].documents.length).toBe(2); }); + it('puts a class-wide collaborative document in its own Whole Class section, ordered first', () => { + const byGroupDocs = sortedDocuments.sortBy("Group"); + expect(byGroupDocs.map(d => d.label)).toEqual(["Whole Class", "Group 3", "Group 5", "Group 9"]); + + const wholeClass = byGroupDocs[0]; + expect(wholeClass.documents.map(d => d.key)).toEqual(["Class Wide Doc"]); + }); + + it('does not file a class-wide document under a numbered group', () => { + const byGroupDocs = sortedDocuments.sortBy("Group"); + const groupSections = byGroupDocs.filter(d => d.label !== "Whole Class"); + expect(groupSections.some(s => s.documents.some(d => d.key === "Class Wide Doc"))).toBe(false); + }); + }); describe("byName Function", () => { it ('should return a document collection alphabetized by last name with the correct documents per user', () => { const byGroupDocs = sortedDocuments.sortBy("Group"); + // byGroupDocs[0] is "Whole Class" (the class-wide document produces no name section); group + // sections follow it in numeric order. + // Group 3: Scott, Kirk, and Group 3 group doc - const documentGroup = byGroupDocs[0]; + const documentGroup = byGroupDocs[1]; const documentCollection = documentGroup.byName; expect(documentCollection.length).toBe(2); expect(documentCollection[0].label).toBe("Cytacki, Scott"); @@ -345,7 +379,7 @@ describe('DocumentGroup Model', () => { expect(documentCollection[1].documents.length).toBe(2); // Group 5: Joe and Group 5 group doc - const documentGroup2 = byGroupDocs[1]; + const documentGroup2 = byGroupDocs[2]; const documentCollection2 = documentGroup2.byName; expect(documentCollection2.length).toBe(1); expect(documentCollection2[0].label).toBe("Bacal, Joe"); @@ -353,7 +387,7 @@ describe('DocumentGroup Model', () => { expect(documentCollection2[0].documents.length).toBe(2); // Group 9: Dennis (no group doc for group 9) - const documentGroup3 = byGroupDocs[2]; + const documentGroup3 = byGroupDocs[3]; const documentCollection3 = documentGroup3.byName; expect(documentCollection3.length).toBe(1); expect(documentCollection3[0].label).toBe("Cao, Dennis"); @@ -448,9 +482,10 @@ describe('DocumentGroup Model', () => { describe("byTools Function", () => { it ('should return a document collection sorted by tool with the correct documents per tool', () => { const byGroupDocs = sortedDocuments.sortBy("Group"); + // byGroupDocs[0] is "Whole Class"; group sections follow it in numeric order. // Group 3: Scott (Text), Kirk (no tools), Group 3 group doc (no tools) - const documentCollection = byGroupDocs[0].byTools; + const documentCollection = byGroupDocs[1].byTools; expect(documentCollection.length).toBe(2); expect(documentCollection[0].label).toBe("Text"); expect(documentCollection[0].documents.length).toBe(1); @@ -458,13 +493,13 @@ describe('DocumentGroup Model', () => { expect(documentCollection[1].documents.length).toBe(2); // Group 5: Joe (no tools), Group 5 group doc (no tools) - const documentCollection2 = byGroupDocs[1].byTools; + const documentCollection2 = byGroupDocs[2].byTools; expect(documentCollection2.length).toBe(1); expect(documentCollection2[0].label).toBe("No Tools"); expect(documentCollection2[0].documents.length).toBe(2); // Group 9: Dennis (Drawing) - const documentCollection3 = byGroupDocs[2].byTools; + const documentCollection3 = byGroupDocs[3].byTools; expect(documentCollection3.length).toBe(1); expect(documentCollection3[0].label).toBe("Drawing"); expect(documentCollection3[0].documents.length).toBe(1); @@ -475,21 +510,22 @@ describe('DocumentGroup Model', () => { it('should return a document collection sorted by problem with correct documents per problem', () => { // Primary sort by Group, then secondary sort by Problem const byGroupDocs = sortedDocuments.sortBy("Group"); + // byGroupDocs[0] is "Whole Class"; group sections follow it in numeric order. // Group 3: Scott (1.2), Kirk (1.2), Group 3 group doc (1.2) - const documentCollection = byGroupDocs[0].byProblem; + const documentCollection = byGroupDocs[1].byProblem; expect(documentCollection.length).toBe(1); expect(documentCollection[0].label).toBe("Problem 1.2"); expect(documentCollection[0].documents.length).toBe(3); // Group 5: Joe (1.1), Group 5 group doc (1.1) - const documentCollection2 = byGroupDocs[1].byProblem; + const documentCollection2 = byGroupDocs[2].byProblem; expect(documentCollection2.length).toBe(1); expect(documentCollection2[0].label).toBe("Problem 1.1"); expect(documentCollection2[0].documents.length).toBe(2); // Group 9: Dennis (2.1) - const documentCollection3 = byGroupDocs[2].byProblem; + const documentCollection3 = byGroupDocs[3].byProblem; expect(documentCollection3.length).toBe(1); expect(documentCollection3[0].label).toBe("Problem 2.1"); expect(documentCollection3[0].documents.length).toBe(1); @@ -497,14 +533,17 @@ describe('DocumentGroup Model', () => { it('should sort problems in correct order (by investigation then problem)', () => { const byProblemDocs = sortedDocuments.sortBy("Problem"); - expect(byProblemDocs.length).toBe(3); - // Should be sorted: 1.1, 1.2, 2.1 + // The class-wide document has no investigation/problem, so it falls into "No Problem". + expect(byProblemDocs.length).toBe(4); + // Should be sorted: 1.1, 1.2, 2.1, No Problem expect(byProblemDocs[0].label).toBe("Problem 1.1"); expect(byProblemDocs[0].documents.length).toBe(2); // Joe + Group 5 group doc expect(byProblemDocs[1].label).toBe("Problem 1.2"); expect(byProblemDocs[1].documents.length).toBe(3); // Scott + Kirk + Group 3 group doc expect(byProblemDocs[2].label).toBe("Problem 2.1"); expect(byProblemDocs[2].documents.length).toBe(1); + expect(byProblemDocs[3].label).toBe("No Problem"); + expect(byProblemDocs[3].documents.length).toBe(1); // Class Wide Doc }); it('labels groups with the problem title when the unit provides one (keeping ordinal order)', () => { @@ -516,7 +555,9 @@ describe('DocumentGroup Model', () => { }) }; const byProblemDocs = sortedDocuments.sortBy("Problem"); - expect(byProblemDocs.map(g => g.label)).toEqual(["Storm 1-1", "Storm 1-2", "Storm 2-1"]); + // The class-wide document has no investigation/problem, so it has no title to resolve and + // keeps the generic "No Problem" label. + expect(byProblemDocs.map(g => g.label)).toEqual(["Storm 1-1", "Storm 1-2", "Storm 2-1", "No Problem"]); // Grouping/ordering is unchanged — only the displayed label differs. expect(byProblemDocs[1].documents.length).toBe(3); }); @@ -542,7 +583,8 @@ describe('DocumentGroup Model', () => { expect(byProblemDocs[2].label).toBe("Problem 2.1"); expect(byProblemDocs[2].documents.length).toBe(1); expect(byProblemDocs[3].label).toBe("No Problem"); - expect(byProblemDocs[3].documents.length).toBe(1); + // Student 1 Problem Doc Group 5 (modified to have no problem info) + the class-wide document + expect(byProblemDocs[3].documents.length).toBe(2); }); }); diff --git a/src/models/stores/document-group.ts b/src/models/stores/document-group.ts index e320eaf82c..a0b9d50a1c 100644 --- a/src/models/stores/document-group.ts +++ b/src/models/stores/document-group.ts @@ -2,11 +2,13 @@ import { FC, SVGProps } from "react"; import { makeAutoObservable } from "mobx"; import { - createDocMapByBookmarks, createTileTypeToDocumentsMap, getTagsWithDocs, - sortDateSectionLabels, sortGroupSectionLabels, sortNameSectionLabels, sortProblemSectionLabels + createDocMapByBookmarks, createTileTypeToDocumentsMap, getTagsWithDocs, GroupSectionSortKey, + kWholeClassSectionLabel, sortDateSectionLabels, sortGroupSections, sortNameSectionLabels, + sortProblemSectionLabels } from "../../utilities/sort-document-utils"; import { upperWords } from "../../utilities/string-utils"; import { translate } from "../../utilities/translation/translate"; +import { hasClassUnitScope, hasGroupScope } from "../document/document-scope"; import { IDocumentMetadataModel } from "../document/document-metadata-model"; import { GroupDocument } from "../document/document-types"; import { getTileComponentInfo } from "../tiles/tile-component-info"; @@ -190,22 +192,35 @@ export class DocumentGroup { get byGroup(): DocumentGroup[] { const groupTerm = upperWords(translate("studentGroup")); const documentMap: Map = new Map(); + // Ordering information per section, so the comparator never parses the display label. + const sortKeys: Map = new Map(); + + const groupSection = (groupId: string) => + ({ sectionLabel: `${groupTerm} ${groupId}`, sortKey: { scope: "group", groupId } as GroupSectionSortKey }); + this.documents.forEach((doc) => { - const sectionLabel = (() => { - if (doc.type === GroupDocument) { - return `${groupTerm} ${doc.groupId}`; + const { sectionLabel, sortKey } = (() => { + // A document scoped to a group belongs to that group, whoever created it. + if (hasGroupScope(doc)) return groupSection(doc.groupId as string); + // A document scoped to the class and unit belongs to the class as a whole. + if (hasClassUnitScope(doc)) { + return { sectionLabel: kWholeClassSectionLabel, sortKey: { scope: "class" } as GroupSectionSortKey }; } - const userId = doc.uid; - const group = this.stores.groups.groupForUser(userId); - return group ? `${groupTerm} ${group.id}` : `No ${groupTerm}`; + // Otherwise it belongs to its owner, and so to whichever group its owner is in now. + const group = this.stores.groups.groupForUser(doc.uid); + return group + ? groupSection(group.id) + : { sectionLabel: `No ${groupTerm}`, sortKey: { scope: "none" } as GroupSectionSortKey }; })(); if (!documentMap.has(sectionLabel)) { documentMap.set(sectionLabel, []); } documentMap.get(sectionLabel)?.push(doc); + sortKeys.set(sectionLabel, sortKey); }); - const sortedSectionLabels = sortGroupSectionLabels(Array.from(documentMap.keys())); + + const sortedSectionLabels = sortGroupSections(Array.from(documentMap.keys()), sortKeys); return this.buildDocumentCollection({sortedSectionLabels, sortType: "Group", docMap: documentMap}); } diff --git a/src/utilities/sort-document-utils.test.ts b/src/utilities/sort-document-utils.test.ts index 710ee00333..2842e2a74f 100644 --- a/src/utilities/sort-document-utils.test.ts +++ b/src/utilities/sort-document-utils.test.ts @@ -7,6 +7,8 @@ import { createDocMapByBookmarks, createTileTypeToDocumentsMap, getTagsWithDocs, + GroupSectionSortKey, + sortGroupSections, sortProblemSectionLabels } from "./sort-document-utils"; import { clearTermOverrides, setTermOverrides } from "./translation/translate"; @@ -203,4 +205,45 @@ describe("sort-document-utils", () => { expect(sorted).toEqual(["Problem 1.1"]); }); }); + + describe("sortGroupSections", () => { + const keys = (entries: Array<[string, GroupSectionSortKey]>) => new Map(entries); + + it("puts the whole-class section first, groups in numeric order, and no-group last", () => { + const labels = ["No Group", "Group 10", "Whole Class", "Group 2"]; + const sorted = sortGroupSections(labels, keys([ + ["No Group", { scope: "none" }], + ["Group 10", { scope: "group", groupId: "10" }], + ["Whole Class", { scope: "class" }], + ["Group 2", { scope: "group", groupId: "2" }], + ])); + expect(sorted).toEqual(["Whole Class", "Group 2", "Group 10", "No Group"]); + }); + + it("orders non-numeric group ids after numeric ones, alphabetically", () => { + const labels = ["Group b", "Group 3", "Group a"]; + const sorted = sortGroupSections(labels, keys([ + ["Group b", { scope: "group", groupId: "b" }], + ["Group 3", { scope: "group", groupId: "3" }], + ["Group a", { scope: "group", groupId: "a" }], + ])); + expect(sorted).toEqual(["Group 3", "Group a", "Group b"]); + }); + + it("does not read the label text — a renamed group term still sorts numerically", () => { + // The comparator must not infer order from the display string; `studentGroup` is overridable + // per unit, so "Team 2" and "Group 2" must sort identically. + const labels = ["Team 10", "Team 2"]; + const sorted = sortGroupSections(labels, keys([ + ["Team 10", { scope: "group", groupId: "10" }], + ["Team 2", { scope: "group", groupId: "2" }], + ])); + expect(sorted).toEqual(["Team 2", "Team 10"]); + }); + + it("treats a label with no sort key as no-group", () => { + const sorted = sortGroupSections(["Mystery", "Whole Class"], keys([["Whole Class", { scope: "class" }]])); + expect(sorted).toEqual(["Whole Class", "Mystery"]); + }); + }); }); diff --git a/src/utilities/sort-document-utils.ts b/src/utilities/sort-document-utils.ts index 5de21e64ba..16141b66cd 100644 --- a/src/utilities/sort-document-utils.ts +++ b/src/utilities/sort-document-utils.ts @@ -41,11 +41,48 @@ export const sortDateSectionLabels = ( }); }; -export const sortGroupSectionLabels = (docMapKeys: string[]) => { +/** Section label for documents that belong to the class as a whole rather than to a group. */ +export const kWholeClassSectionLabel = "Whole Class"; + +/** + * The ordering information for one "by group" section. Carried alongside the section label so the + * comparator never has to recover structure from the display text, which is translatable + * (`studentGroup` is overridable per unit) and has no number at all for some sections. + */ +export type GroupSectionSortKey = + | { scope: "class" } + | { scope: "group"; groupId: string } + | { scope: "none" }; + +const kGroupSectionScopeOrder: Record = { + class: 0, + group: 1, + none: 2, +}; + +/** + * Order "by group" sections: the whole class first, then groups by ascending numeric id, then the + * no-group section. A section with no sort key is ordered as if it had none. + */ +export const sortGroupSections = (docMapKeys: string[], sortKeys: Map) => { + const keyFor = (label: string): GroupSectionSortKey => sortKeys.get(label) ?? { scope: "none" }; return docMapKeys.sort((a, b) => { - const numA = parseInt(a.replace(/^\D+/g, ''), 10); - const numB = parseInt(b.replace(/^\D+/g, ''), 10); - return numA - numB; + const keyA = keyFor(a); + const keyB = keyFor(b); + if (keyA.scope !== keyB.scope) { + return kGroupSectionScopeOrder[keyA.scope] - kGroupSectionScopeOrder[keyB.scope]; + } + if (keyA.scope === "group" && keyB.scope === "group") { + const numA = parseInt(keyA.groupId, 10); + const numB = parseInt(keyB.groupId, 10); + // Group ids are numeric in practice; order any non-numeric id after the numeric ones rather + // than comparing NaN. + if (!isNaN(numA) && !isNaN(numB)) return numA - numB; + if (!isNaN(numA)) return -1; + if (!isNaN(numB)) return 1; + return keyA.groupId.localeCompare(keyB.groupId); + } + return a.localeCompare(b); }); }; From 84a87c5c196a390d8c6dee9ae9ba8b259fae3e21 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 19:01:27 -0400 Subject: [PATCH 06/51] feat: file class-wide documents under No Name in the by-name sort [CLUE-610] Add kNoNameSectionLabel constant and update byName sorting to file class-wide collaborative documents in their own "No Name" section rather than under an unknown author. Update all affected tests to account for the new section's alphabetic placement between "Cytacki" and "Swenson". Co-Authored-By: Claude Opus 5 (1M context) --- src/models/stores/document-group.test.ts | 58 +++++++++++++++++------- src/models/stores/document-group.ts | 17 ++++--- src/utilities/sort-document-utils.ts | 3 ++ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/models/stores/document-group.test.ts b/src/models/stores/document-group.test.ts index 3e9acc8dfe..ab306e60be 100644 --- a/src/models/stores/document-group.test.ts +++ b/src/models/stores/document-group.test.ts @@ -309,10 +309,11 @@ describe('DocumentGroup Model', () => { expect(collection2[0].documents.length).toBe(2); // Swenson, Kirk: 1 personal doc + 1 Group 3 group doc, all in Group 3 - const collection3 = byNameGroups[3].byGroup; - expect(collection3.length).toBe(1); - expect(collection3[0].label).toBe("Group 3"); - expect(collection3[0].documents.length).toBe(2); + // (index 4 because "No Name" section sorts alphabetically at index 3) + const collection4 = byNameGroups[4].byGroup; + expect(collection4.length).toBe(1); + expect(collection4[0].label).toBe("Group 3"); + expect(collection4[0].documents.length).toBe(2); }); it('should use custom group term when term override is set', () => { @@ -339,10 +340,11 @@ describe('DocumentGroup Model', () => { expect(collection2[0].documents.length).toBe(2); // Swenson, Kirk: Group 3 - const collection3 = byNameGroups[3].byGroup; - expect(collection3.length).toBe(1); - expect(collection3[0].label).toBe("Team 3"); - expect(collection3[0].documents.length).toBe(2); + // (index 4 because "No Name" section sorts alphabetically at index 3) + const collection4 = byNameGroups[4].byGroup; + expect(collection4.length).toBe(1); + expect(collection4[0].label).toBe("Team 3"); + expect(collection4[0].documents.length).toBe(2); }); it('puts a class-wide collaborative document in its own Whole Class section, ordered first', () => { @@ -397,8 +399,8 @@ describe('DocumentGroup Model', () => { it('should include group documents under each member of the group', () => { // Sort directly by Name to test the top-level byName behavior const byNameDocs = sortedDocuments.sortBy("Name"); - // Should have 4 name sections (alphabetical): Bacal, Cao, Cytacki, Swenson - expect(byNameDocs.length).toBe(4); + // Should have 5 name sections (alphabetical): Bacal, Cao, Cytacki, No Name, Swenson + expect(byNameDocs.length).toBe(5); // Bacal, Joe (Group 5) - own doc + Group 5 group doc expect(byNameDocs[0].label).toBe("Bacal, Joe"); @@ -418,10 +420,11 @@ describe('DocumentGroup Model', () => { expect(byNameDocs[2].documents.some(d => d.key === "Group 3 Group Doc")).toBe(true); // Swenson, Kirk (Group 3) - own doc + Group 3 group doc - expect(byNameDocs[3].label).toBe("Swenson, Kirk"); - expect(byNameDocs[3].documents.length).toBe(2); - expect(byNameDocs[3].documents.some(d => d.key === "Student 4 Problem Doc Group 3")).toBe(true); - expect(byNameDocs[3].documents.some(d => d.key === "Group 3 Group Doc")).toBe(true); + // (index 4 because "No Name" section sorts alphabetically at index 3) + expect(byNameDocs[4].label).toBe("Swenson, Kirk"); + expect(byNameDocs[4].documents.length).toBe(2); + expect(byNameDocs[4].documents.some(d => d.key === "Student 4 Problem Doc Group 3")).toBe(true); + expect(byNameDocs[4].documents.some(d => d.key === "Group 3 Group Doc")).toBe(true); }); it('should not create a separate name section for group documents', () => { @@ -429,7 +432,18 @@ describe('DocumentGroup Model', () => { // Group documents should not appear as their own name entry const labels = byNameDocs.map(d => d.label); expect(labels).not.toContain("Unknown"); - expect(labels).toEqual(["Bacal, Joe", "Cao, Dennis", "Cytacki, Scott", "Swenson, Kirk"]); + expect(labels).toEqual(["Bacal, Joe", "Cao, Dennis", "Cytacki, Scott", "No Name", "Swenson, Kirk"]); + }); + + it('files a class-wide collaborative document under No Name, not under any student', () => { + const byNameDocs = sortedDocuments.sortBy("Name"); + const noName = byNameDocs.find(d => d.label === "No Name"); + expect(noName).toBeDefined(); + expect(noName?.documents.map(d => d.key)).toEqual(["Class Wide Doc"]); + + // It has no author, so it must not be repeated under the students the way a group document is. + const studentSections = byNameDocs.filter(d => d.label !== "No Name"); + expect(studentSections.some(s => s.documents.some(d => d.key === "Class Wide Doc"))).toBe(false); }); }); @@ -467,8 +481,20 @@ describe('DocumentGroup Model', () => { expect(documentCollection3[2].label).toBe("Not Tagged"); expect(documentCollection3[2].documents.length).toBe(2); + // No Name: class-wide document with no strategies + // (index 3 because "No Name" section sorts alphabetically between "Cytacki" and "Swenson") + const documentCollectionNoName = byNameGroups[3].byStrategy; + expect(documentCollectionNoName.length).toBe(3); + expect(documentCollectionNoName[0].label).toBe("foo"); + expect(documentCollectionNoName[0].documents.length).toBe(0); + expect(documentCollectionNoName[1].label).toBe("bar"); + expect(documentCollectionNoName[1].documents.length).toBe(0); + expect(documentCollectionNoName[2].label).toBe("Not Tagged"); + expect(documentCollectionNoName[2].documents.length).toBe(1); // Class Wide Doc + // Swenson, Kirk: problem doc has ["bar"] + Group 3 group doc with no strategies - const documentCollection4 = byNameGroups[3].byStrategy; + // (index 4 because "No Name" section sorts alphabetically at index 3) + const documentCollection4 = byNameGroups[4].byStrategy; expect(documentCollection4.length).toBe(3); expect(documentCollection4[0].label).toBe("foo"); expect(documentCollection4[0].documents.length).toBe(0); diff --git a/src/models/stores/document-group.ts b/src/models/stores/document-group.ts index a0b9d50a1c..8cb01fbfdc 100644 --- a/src/models/stores/document-group.ts +++ b/src/models/stores/document-group.ts @@ -3,14 +3,13 @@ import { makeAutoObservable } from "mobx"; import { createDocMapByBookmarks, createTileTypeToDocumentsMap, getTagsWithDocs, GroupSectionSortKey, - kWholeClassSectionLabel, sortDateSectionLabels, sortGroupSections, sortNameSectionLabels, + kNoNameSectionLabel, kWholeClassSectionLabel, sortDateSectionLabels, sortGroupSections, sortNameSectionLabels, sortProblemSectionLabels } from "../../utilities/sort-document-utils"; import { upperWords } from "../../utilities/string-utils"; import { translate } from "../../utilities/translation/translate"; import { hasClassUnitScope, hasGroupScope } from "../document/document-scope"; import { IDocumentMetadataModel } from "../document/document-metadata-model"; -import { GroupDocument } from "../document/document-types"; import { getTileComponentInfo } from "../tiles/tile-component-info"; import { getTileContentInfo } from "../tiles/tile-content-info"; import { UnitModelType } from "../curriculum/unit"; @@ -226,17 +225,23 @@ export class DocumentGroup { get byName(): DocumentGroup[] { const documentMap: Map = new Map(); - const addDocForUser = (doc: IDocumentMetadataModel, user: ClassUserModelType | undefined) => { - const sectionLabel = user ? `${user.lastName}, ${user.firstName}` : "Unknown"; + const addDocToSection = (doc: IDocumentMetadataModel, sectionLabel: string) => { if (!documentMap.has(sectionLabel)) { documentMap.set(sectionLabel, []); } documentMap.get(sectionLabel)?.push(doc); }; + const addDocForUser = (doc: IDocumentMetadataModel, user: ClassUserModelType | undefined) => { + const sectionLabel = user ? `${user.lastName}, ${user.firstName}` : "Unknown"; + addDocToSection(doc, sectionLabel); + }; this.documents.forEach((doc) => { - if (doc.type === GroupDocument) { - // Add group documents to each user in the group + if (hasClassUnitScope(doc)) { + // A class-wide collaborative document belongs to the class, so it has no personal author. + addDocToSection(doc, kNoNameSectionLabel); + } else if (hasGroupScope(doc)) { + // A group document is listed under every member of the group that owns it. const groupId = doc.groupId ?? "unknownGroup"; const group = this.stores.groups.getGroupById(groupId); group?.users.forEach(user => { diff --git a/src/utilities/sort-document-utils.ts b/src/utilities/sort-document-utils.ts index 16141b66cd..8d6fe8bb6d 100644 --- a/src/utilities/sort-document-utils.ts +++ b/src/utilities/sort-document-utils.ts @@ -44,6 +44,9 @@ export const sortDateSectionLabels = ( /** Section label for documents that belong to the class as a whole rather than to a group. */ export const kWholeClassSectionLabel = "Whole Class"; +/** Section label, in the "by name" sort, for documents that have no personal author. */ +export const kNoNameSectionLabel = "No Name"; + /** * The ordering information for one "by group" section. Carried alongside the section label so the * comparator never has to recover structure from the display text, which is translatable From 47345e32880247327a6156564bf3b1ad9e766cf2 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 19:11:54 -0400 Subject: [PATCH 07/51] feat: fetch unit-scoped documents under the investigation and problem filters [CLUE-610] Adds a third Firestore listener to watchFirestoreMetaDataDocs that fetches documents scoped to the unit but not to a problem (class-wide collaborative documents) whenever the Investigation or Problem filter is applied, since the filtered query's investigation clause would otherwise exclude them. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/stores/sorted-documents.test.ts | 111 +++++++++++++++++++++ src/models/stores/sorted-documents.ts | 31 +++++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/models/stores/sorted-documents.test.ts b/src/models/stores/sorted-documents.test.ts index 3e8a607933..b2b2c9feca 100644 --- a/src/models/stores/sorted-documents.test.ts +++ b/src/models/stores/sorted-documents.test.ts @@ -245,3 +245,114 @@ describe('Sorted Documents Model', () => { }); }); }); + +type WhereClause = [string, string, any]; + +interface IMockQueryRecord { + clauses: WhereClause[]; + emit: (docs: any[]) => void; + disposed: boolean; +} + +// Records every query built off db.firestore and lets a test drive each listener's snapshot. +function makeMockFirestore() { + const listeners: IMockQueryRecord[] = []; + const makeQuery = (clauses: WhereClause[]): any => ({ + withConverter: () => makeQuery(clauses), + where: (field: string, op: string, value: any) => makeQuery([...clauses, [field, op, value]]), + onSnapshot: (cb: (snap: any) => void) => { + const record: IMockQueryRecord = { + clauses, + emit: (docs: any[]) => cb({ docs: docs.map(d => ({ data: () => d })) }), + disposed: false + }; + listeners.push(record); + return () => { record.disposed = true; }; + } + }); + return { listeners, collection: () => makeQuery([]) }; +} + +describe("SortedDocuments.watchFirestoreMetaDataDocs", () => { + let firestore: ReturnType; + let sortedDocuments: SortedDocuments; + + const classWideMetadata = { + uid: "class_mock", type: "group", key: "Class Wide Doc", createdAt: 7, + unit: "sas", investigation: null, problem: null, kind: "drivingQuestionBoard", concurrent: true + }; + + beforeEach(() => { + firestore = makeMockFirestore(); + const documentMetadata = new DocumentMetadataStore( + { db: {}, user: { classHash: "mock" }, documents: { exemplarDocuments: [] } } as any + ); + const mockStores: DeepPartial = { + documents: { all: [], exemplarDocuments: [] }, + db: { firestore } as any, + user: { classHash: "mock" }, + curriculumConfig: { getUnitCodeVariants: (unit: string) => [unit] }, + documentMetadata, + }; + sortedDocuments = new SortedDocuments(mockStores as ISortedDocumentsStores); + }); + + const unitScopedListener = () => + firestore.listeners.find(l => + l.clauses.some(([field, , value]) => field === "investigation" && value === null)); + + it("adds a unit-scoped listener under the Problem filter", () => { + sortedDocuments.watchFirestoreMetaDataDocs("Problem", "sas", 1, 2); + const listener = unitScopedListener(); + expect(listener).toBeDefined(); + expect(listener?.clauses).toEqual([ + ["context_id", "==", "mock"], + ["unit", "in", ["sas"]], + ["investigation", "==", null], + ]); + }); + + it("adds a unit-scoped listener under the Investigation filter", () => { + sortedDocuments.watchFirestoreMetaDataDocs("Investigation", "sas", 1, 2); + expect(unitScopedListener()).toBeDefined(); + }); + + it("adds no unit-scoped listener under the All or Unit filters, which already include those docs", () => { + sortedDocuments.watchFirestoreMetaDataDocs("All", "sas", 1, 2); + expect(unitScopedListener()).toBeUndefined(); + + firestore.listeners.length = 0; + sortedDocuments.watchFirestoreMetaDataDocs("Unit", "sas", 1, 2); + expect(unitScopedListener()).toBeUndefined(); + }); + + it("surfaces unit-scoped documents in firestoreMetadataDocs", () => { + sortedDocuments.watchFirestoreMetaDataDocs("Problem", "sas", 1, 2); + unitScopedListener()?.emit([classWideMetadata]); + expect(sortedDocuments.firestoreMetadataDocs.map(d => d.key)).toEqual(["Class Wide Doc"]); + }); + + it("does not list a document twice when both listeners return it", () => { + sortedDocuments.watchFirestoreMetaDataDocs("Problem", "sas", 1, 2); + firestore.listeners[0].emit([classWideMetadata]); // the filtered listener + unitScopedListener()?.emit([classWideMetadata]); + expect(sortedDocuments.firestoreMetadataDocs.map(d => d.key)).toEqual(["Class Wide Doc"]); + }); + + it("disposes the unit-scoped listener with the others", () => { + const dispose = sortedDocuments.watchFirestoreMetaDataDocs("Problem", "sas", 1, 2); + const listener = unitScopedListener(); + dispose(); + expect(listener?.disposed).toBe(true); + }); + + it("clears previously fetched unit-scoped documents when the filter no longer needs them", () => { + sortedDocuments.watchFirestoreMetaDataDocs("Problem", "sas", 1, 2); + unitScopedListener()?.emit([classWideMetadata]); + expect(sortedDocuments.firestoreMetadataDocs.length).toBe(1); + + firestore.listeners.length = 0; + sortedDocuments.watchFirestoreMetaDataDocs("All", "sas", 1, 2); + expect(sortedDocuments.firestoreMetadataDocs.length).toBe(0); + }); +}); diff --git a/src/models/stores/sorted-documents.ts b/src/models/stores/sorted-documents.ts index 4550c165b1..cee09c7c40 100644 --- a/src/models/stores/sorted-documents.ts +++ b/src/models/stores/sorted-documents.ts @@ -41,6 +41,10 @@ export class SortedDocuments { // `metadataDocsWithoutUnit` picks up unit-less docs (e.g. personal documents) when a unit // filter is applied. metadataDocsWithoutUnit = MetadataDocMapModel.create(); + // `metadataDocsUnitScoped` picks up documents scoped to the unit but not to a problem (class-wide + // collaborative documents) when an investigation or problem filter is applied, which would + // otherwise exclude them. + metadataDocsUnitScoped = MetadataDocMapModel.create(); docsReceived = false; // Maps from document ID to the history entry ID that the user requested to view. documentHistoryViewRequests: Record = {}; @@ -54,6 +58,7 @@ export class SortedDocuments { // We only want MobX observability + MST serialization, not MST actions, on these maps. unprotect(this.metadataDocsFiltered); unprotect(this.metadataDocsWithoutUnit); + unprotect(this.metadataDocsUnitScoped); this.rootDocumentGroup = new DocumentGroup({ stores, sortType: "All", @@ -151,11 +156,29 @@ export class SortedDocuments { this.metadataDocsWithoutUnit.clear(); } - // A disposing function that calls the two disposers from the + let disposeUnitScopedListener: (() => void) | undefined; + if (filter === "Investigation" || filter === "Problem") { + // A class-wide collaborative document is scoped to the unit, not to a problem, so it stores + // `investigation: null` and the filtered query above excludes it. Fetch by scope — the query + // names no document type or kind. + const queryForUnitScoped = baseQuery + .where("unit", "in", this.curriculumConfig.getUnitCodeVariants(unit)) + .where("investigation", "==", null); + disposeUnitScopedListener = queryForUnitScoped.onSnapshot(snapshot => { + const mstSnapshot = this.stores.documentMetadata.getMSTSnapshotFromFBSnapshot(snapshot); + applySnapshot(this.metadataDocsUnitScoped, mstSnapshot); + }); + } else { + // The All and Unit filters already include these documents. + this.metadataDocsUnitScoped.clear(); + } + + // A disposing function that calls the disposers from the // onSnapshot listeners. return () => { disposeFilteredListener(); disposeDocsWithoutUnitListener?.(); + disposeUnitScopedListener?.(); }; } @@ -182,6 +205,12 @@ export class SortedDocuments { docsArray.push(doc); matchedDocKeys.add(doc.key); }); + this.metadataDocsUnitScoped.forEach(doc => { + // Also present in the filtered map under the All and Unit filters; dedupe by key. + if (matchedDocKeys.has(doc.key)) return; + docsArray.push(doc); + matchedDocKeys.add(doc.key); + }); this.exemplarMetadataDocs.forEach(doc => { // If there is a duplicate, it will have been merged with one of the previous // maps by the firestore snapshot listeners. So we ignore the duplicate here. From d56dcd6da2cb820db01434d9a7791579fa733e42 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 21:49:08 -0400 Subject: [PATCH 08/51] fix: reword comments to describe live constraint, not history [CLUE-610] Co-Authored-By: Claude Opus 5 (1M context) --- scripts/backfill-group-document-axes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/backfill-group-document-axes.ts b/scripts/backfill-group-document-axes.ts index 6cd7523f4d..f13babef2b 100644 --- a/scripts/backfill-group-document-axes.ts +++ b/scripts/backfill-group-document-axes.ts @@ -7,8 +7,8 @@ // group-scoped, missing `concurrent` -> { concurrent: true, kind: "group" } // class-wide, missing curriculum scope -> { investigation: null, problem: null } // -// The first pass restores the concurrent history manager for group documents created before the -// `concurrent` axis existed. The second states a class-wide document's absent curriculum scope +// The first pass restores the concurrent history manager for group documents that carry no stored +// `concurrent` value. The second states a class-wide document's absent curriculum scope // explicitly, which is what makes it findable by Sort Work's unit-scoped query. Both are additive, // idempotent, and batched. // From a1aae2ac0c4a9f79f8f92a8553b6e01ca0701959 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 22:07:23 -0400 Subject: [PATCH 09/51] feat: one edit predicate for Sort Work and the resources pane [CLUE-610] Replace two disagreeing Edit-button checks (Sort Work's ownership-only check, the resources pane's my-work/learningLog tab check) with a single canUserEditDocument predicate: own document always editable, otherwise only a concurrent document from inside its scope (class-wide by class membership, group by group membership). Fields are read per-field from the reactive Firestore metadata, falling back to the document, so the Edit button appears as soon as a groupmate's document metadata syncs. Behavior change: a bookmarked document owned by another student no longer shows Edit in the My Work tab, since the old tab check allowed it regardless of ownership or scope. Co-Authored-By: Claude Opus 5 (1M context) --- .../document/sort-work-document-area.tsx | 6 +- src/components/navigation/document-view.tsx | 4 +- src/models/document/document-utils.test.ts | 90 ++++++++++++++++++- src/models/document/document-utils.ts | 50 +++++++++++ src/models/document/document.ts | 6 +- 5 files changed, 148 insertions(+), 8 deletions(-) diff --git a/src/components/document/sort-work-document-area.tsx b/src/components/document/sort-work-document-area.tsx index 3645802a56..b9b5b8bd5e 100644 --- a/src/components/document/sort-work-document-area.tsx +++ b/src/components/document/sort-work-document-area.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import { useStores } from "../../hooks/use-stores"; import { LogEventName } from "../../lib/logger-types"; import { isExemplarType } from "../../models/document/document-types"; -import { isDocumentAccessibleToUser } from "../../models/document/document-utils"; +import { canUserEditDocument, isDocumentAccessibleToUser } from "../../models/document/document-utils"; import { logDocumentEvent } from "../../models/document/log-document-event"; import { DocumentGroup } from "../../models/stores/document-group"; import { ENavTab } from "../../models/view/nav-tabs"; @@ -62,7 +62,9 @@ export const SortWorkDocumentArea: React.FC = observer(function SortWork document: openDocument, documentMetadata: openDocumentMetadata, user, documents }); const showPlayback = user.isResearcher || (user.type && appConfig.enableHistoryRoles.includes(user.type)); - const showEdit = openDocument?.uid === user.id; //only show if doc is owned by the user who opened it + const showEdit = canUserEditDocument({ + document: openDocument, documentMetadata: openDocumentMetadata, user + }); const showExemplarShare = user.type === "teacher" && openDocument && isExemplarType(openDocument.type); const sectionClass = openDocument?.type === "learningLog" ? "learning-log" : ""; diff --git a/src/components/navigation/document-view.tsx b/src/components/navigation/document-view.tsx index 6decf6af16..5c74a764c7 100644 --- a/src/components/navigation/document-view.tsx +++ b/src/components/navigation/document-view.tsx @@ -5,7 +5,7 @@ import classNames from "classnames"; import { useAppConfig, useLocalDocuments, useStores, usePersistentUIStore } from "../../hooks/use-stores"; import { useUserContext } from "../../hooks/use-user-context"; -import { isDocumentAccessibleToUser } from "../../models/document/document-utils"; +import { canUserEditDocument, isDocumentAccessibleToUser } from "../../models/document/document-utils"; import { ISubTabModel, NavTabModelType, kBookmarksTabTitle } from "../../models/view/nav-tabs"; import { DocumentType } from "../../models/document/document-types"; import { logDocumentViewEvent } from "../../models/document/log-document-event"; @@ -228,7 +228,7 @@ const DocumentArea = ({openDocument, subTab, tab, sectionClass, isVisible, isSec const {appConfig, persistentUI, ui, user} = useStores(); const showPlayback = user.type && !openDocument?.isPublished ? appConfig.enableHistoryRoles.includes(user.type) : false; - const showEdit = !openDocument.isRemote && ((tab === "my-work") || (tab === "learningLog")); + const showEdit = !openDocument.isRemote && canUserEditDocument({ document: openDocument, user }); function handleCloseButtonClick() { persistentUI.closeDocumentGroupPrimaryDocument(); diff --git a/src/models/document/document-utils.test.ts b/src/models/document/document-utils.test.ts index 92083e95e9..a2a4bd7313 100644 --- a/src/models/document/document-utils.test.ts +++ b/src/models/document/document-utils.test.ts @@ -1,8 +1,10 @@ import { UnitModel } from "../curriculum/unit"; import { AppConfigModel } from "../stores/app-config-model"; import { DocumentMetadataModel } from "../document/document-metadata-model"; +import { UserModel } from "../stores/user"; +import { createDocumentModel } from "./document"; import { GroupDocument, PersonalDocument, ProblemDocument, SupportPublication } from "./document-types"; -import { getDocumentDisplayTitle, isDocumentAccessibleToUser } from "./document-utils"; +import { canUserEditDocument, getDocumentDisplayTitle, isDocumentAccessibleToUser } from "./document-utils"; import { registerDocumentKind } from "./document-kinds"; import { unitConfigDefaults } from "../../test-fixtures/sample-unit-configurations"; @@ -202,6 +204,92 @@ describe("document utils", () => { }); }); }); + + describe("canUserEditDocument", () => { + const student = UserModel.create({ id: "me", type: "student", name: "Me", classHash: "class-1" }); + const groupedStudent = UserModel.create({ + id: "me", type: "student", name: "Me", classHash: "class-1", currentGroupId: "3" + }); + + const metadata = (props: Record) => + DocumentMetadataModel.create({ uid: "someone-else", type: GroupDocument, key: "k", ...props }); + + it("allows a user to edit their own document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ uid: "me", type: ProblemDocument }), user: student + })).toBe(true); + }); + + it("refuses another student's single-writer document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ type: ProblemDocument }), user: student + })).toBe(false); + }); + + it("allows a member of the owning group to edit a group document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + user: groupedStudent + })).toBe(true); + }); + + it("refuses another group's document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ concurrent: true, groupId: "7", unit: "sas", investigation: "1" }), + user: groupedStudent + })).toBe(false); + }); + + it("refuses a group document when the user is not in a group", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + user: student + })).toBe(false); + }); + + it("allows any member of the class to edit a class-wide document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ + concurrent: true, unit: "sas", investigation: null, context_id: "class-1" + }), + user: student + })).toBe(true); + }); + + it("refuses a class-wide document belonging to another class", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ + concurrent: true, unit: "sas", investigation: null, context_id: "class-2" + }), + user: student + })).toBe(false); + }); + + it("refuses a document that is not concurrent even inside the user's own scope", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ unit: "sas", investigation: null, context_id: "class-1" }), + user: student + })).toBe(false); + }); + + it("returns false when neither a document nor metadata is supplied", () => { + expect(canUserEditDocument({ user: student })).toBe(false); + }); + + it("prefers the reactive metadata's group id over a still-loading document", () => { + // A groupmate's document syncs into the metadata before its content finishes loading; reading + // the metadata per field is what makes the Edit button appear without a reload. + const stillLoading = createDocumentModel({ + uid: "someone-else", type: GroupDocument, key: "k", concurrent: true + }); + expect(stillLoading.groupId).toBeUndefined(); + expect(canUserEditDocument({ + document: stillLoading, + documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + user: groupedStudent + })).toBe(true); + }); + }); }); describe("isDocumentAccessibleToUser — group documents", () => { diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index e953eb1a52..058ff56376 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -10,6 +10,7 @@ import { AppConfigModelType } from "../stores/app-config-model"; import { UserModelType } from "../stores/user"; import { DocumentModelType, IExemplarVisibilityProvider } from "./document"; import { DocumentContentModelType } from "./document-content"; +import { hasClassUnitScope } from "./document-scope"; import { getDocumentTitle } from "./document-kinds"; import { GroupDocument, isExemplarType, isPlanningType, isProblemType, isPublishedType, isSupportType } from "./document-types"; @@ -120,3 +121,52 @@ export function isDocumentAccessibleToUser ({ } return false; } + +/** + * The metadata fields the edit predicate reads. + * + * Structural, and deliberately not `IDocumentMetadata`: that interface declares + * `properties?: Record` while the MST `DocumentMetadataModel` holds an observable + * map there, so a metadata model instance is not assignable to it. `isDocumentAccessibleToUser` + * sidesteps the same problem by taking `IDocumentMetadataBase`, which has no `properties` — this + * adds the two axis/scope fields the base type lacks. + */ +type IEditPermissionMetadata = IDocumentMetadataBase & { + concurrent?: boolean | null; + context_id?: string | null; +}; + +interface ICanUserEditDocumentParams { + document?: DocumentModelType; + documentMetadata?: IEditPermissionMetadata; + user: UserModelType; +} + +/** + * Whether this user may edit this document — the gate on every Edit button. + * + * A user may always edit their own document. Beyond that, only a `concurrent` (multi-writer) + * document is editable by someone other than its owner, and then only from inside its scope: a + * class-wide document by any member of its class, a group document by any member of its group. + * + * Fields are read from the reactive Firestore metadata, falling back per field to the lazily-fetched + * full document. A groupmate's document appears in the metadata before its content finishes loading, + * and reading it per field is what lets the Edit button appear without a reload. + */ +export function canUserEditDocument({ + document, documentMetadata, user +}: ICanUserEditDocumentParams): boolean { + const uid = documentMetadata?.uid ?? document?.uid; + const concurrent = documentMetadata?.concurrent ?? document?.concurrent; + const groupId = documentMetadata?.groupId ?? document?.groupId; + const unit = documentMetadata?.unit ?? document?.unit; + const investigation = documentMetadata?.investigation ?? document?.investigation; + const contextId = documentMetadata?.context_id ?? document?.contextId; + + if (!!uid && uid === user.id) return true; + if (!concurrent) return false; + if (hasClassUnitScope({ unit, investigation, groupId })) { + return !!contextId && contextId === user.classHash; + } + return !!user.currentGroupId && groupId === user.currentGroupId; +} diff --git a/src/models/document/document.ts b/src/models/document/document.ts index a9826a1c14..cb1bc1371d 100644 --- a/src/models/document/document.ts +++ b/src/models/document/document.ts @@ -148,13 +148,13 @@ export const DocumentModel = Tree.named("Document") return !!self.content; }, get metadata(): IDocumentMetadata { - const { uid, groupId, type, key, createdAt, title, originDoc, properties, visibility, concurrent, - kind } = self; + const { uid, groupId, type, key, createdAt, title, originDoc, properties, visibility, concurrent, kind, + contextId } = self; // `groupId` is undefined for everything but a group document, matching the stored field it mirrors, // so this shape agrees with what Firestore holds. The author's group is deliberately absent: it is // not document metadata. Nothing writes this back to Firestore or Firebase today — it is used for // finding Firestore documents. - return { uid, groupId, type, key, createdAt, title, concurrent, kind, + return { uid, groupId, type, key, createdAt, title, concurrent, kind, context_id: contextId, originDoc, properties: properties.toJSON(), investigation: self.investigation, problem: self.problem, unit: self.unit, offeringId: self.offeringId, visibility } as IDocumentMetadata; }, From 775e463d18533fbf63825f9efc57f15f6a4f404f Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Mon, 27 Jul 2026 23:30:45 -0400 Subject: [PATCH 10/51] fix: exclude published documents and researchers from canUserEditDocument [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canUserEditDocument granted Edit on a user's own published documents, since a publication's uid is the publisher's — the ownership arm alone couldn't distinguish a live document from its read-only published copy. It also granted researchers Edit on class-wide documents whenever their observing classHash matched, though researchers get no write affordance elsewhere in the app. Check isPublishedType before the ownership arm and deny researchers explicitly. Teachers keep editing class-wide documents in their own class, now pinned by a dedicated test. Also cover the document-only call path (no metadata) used by the resources pane, and a case where an empty context_id would otherwise match a user's default empty classHash if the `!!contextId` guard were removed. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/document/document-utils.test.ts | 57 +++++++++++++++++++++- src/models/document/document-utils.ts | 14 ++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/models/document/document-utils.test.ts b/src/models/document/document-utils.test.ts index a2a4bd7313..633350968f 100644 --- a/src/models/document/document-utils.test.ts +++ b/src/models/document/document-utils.test.ts @@ -3,7 +3,8 @@ import { AppConfigModel } from "../stores/app-config-model"; import { DocumentMetadataModel } from "../document/document-metadata-model"; import { UserModel } from "../stores/user"; import { createDocumentModel } from "./document"; -import { GroupDocument, PersonalDocument, ProblemDocument, SupportPublication } from "./document-types"; +import { GroupDocument, PersonalDocument, ProblemDocument, ProblemPublication, SupportPublication } + from "./document-types"; import { canUserEditDocument, getDocumentDisplayTitle, isDocumentAccessibleToUser } from "./document-utils"; import { registerDocumentKind } from "./document-kinds"; import { unitConfigDefaults } from "../../test-fixtures/sample-unit-configurations"; @@ -210,6 +211,8 @@ describe("document utils", () => { const groupedStudent = UserModel.create({ id: "me", type: "student", name: "Me", classHash: "class-1", currentGroupId: "3" }); + const teacher = UserModel.create({ id: "t1", type: "teacher", name: "Teacher", classHash: "class-1" }); + const researcher = UserModel.create({ id: "r1", type: "researcher", name: "Researcher", classHash: "class-1" }); const metadata = (props: Record) => DocumentMetadataModel.create({ uid: "someone-else", type: GroupDocument, key: "k", ...props }); @@ -276,6 +279,44 @@ describe("document utils", () => { expect(canUserEditDocument({ user: student })).toBe(false); }); + it("refuses a user's own published document — publishing copies it under the publisher's uid," + + " it is not a live editable document", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ uid: "me", type: ProblemPublication }), user: student + })).toBe(false); + }); + + it("refuses a researcher editing a class-wide document even though their classHash matches", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ + concurrent: true, unit: "sas", investigation: null, context_id: "class-1" + }), + user: researcher + })).toBe(false); + }); + + it("allows a teacher to edit a class-wide document belonging to their class", () => { + expect(canUserEditDocument({ + documentMetadata: metadata({ + concurrent: true, unit: "sas", investigation: null, context_id: "class-1" + }), + user: teacher + })).toBe(true); + }); + + it("refuses a class-wide document when context_id and the user's classHash are both empty", () => { + // Guards the `!!contextId &&` check: without it, an empty-string context_id would equal a + // user's default empty-string classHash and incorrectly grant access. + const noClassUser = UserModel.create({ id: "me", type: "student", name: "Me" }); + expect(noClassUser.classHash).toBe(""); + expect(canUserEditDocument({ + documentMetadata: metadata({ + concurrent: true, unit: "sas", investigation: null, context_id: "" + }), + user: noClassUser + })).toBe(false); + }); + it("prefers the reactive metadata's group id over a still-loading document", () => { // A groupmate's document syncs into the metadata before its content finishes loading; reading // the metadata per field is what makes the Edit button appear without a reload. @@ -289,6 +330,20 @@ describe("document utils", () => { user: groupedStudent })).toBe(true); }); + + it("allows a user to edit their own document via the document-only path (no metadata)", () => { + const ownDocument = createDocumentModel({ uid: "me", type: ProblemDocument, key: "k" }); + expect(canUserEditDocument({ document: ownDocument, user: student })).toBe(true); + }); + + it("allows any member of the class to edit a class-wide document via the document-only path" + + " (no metadata)", () => { + const classWideDocument = createDocumentModel({ + uid: "someone-else", type: GroupDocument, key: "k", concurrent: true, + unit: "sas", contextId: "class-1" + }); + expect(canUserEditDocument({ document: classWideDocument, user: student })).toBe(true); + }); }); }); diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index 058ff56376..7677396c45 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -145,9 +145,14 @@ interface ICanUserEditDocumentParams { /** * Whether this user may edit this document — the gate on every Edit button. * - * A user may always edit their own document. Beyond that, only a `concurrent` (multi-writer) - * document is editable by someone other than its owner, and then only from inside its scope: a - * class-wide document by any member of its class, a group document by any member of its group. + * A published document is a read-only snapshot, not editable by anyone, including its own + * publisher — publishing copies the document under the publisher's uid, so the ownership check + * alone can't tell a live document from its published copy. A researcher never gets an edit + * affordance, even inside a class or group they observe. Beyond those exclusions, a user may + * always edit their own document; otherwise only a `concurrent` (multi-writer) document is + * editable by someone other than its owner, and then only from inside its scope: a class-wide + * document by any member of its class (teachers included — they belong to the class too), a group + * document by any member of its group. * * Fields are read from the reactive Firestore metadata, falling back per field to the lazily-fetched * full document. A groupmate's document appears in the metadata before its content finishes loading, @@ -157,13 +162,16 @@ export function canUserEditDocument({ document, documentMetadata, user }: ICanUserEditDocumentParams): boolean { const uid = documentMetadata?.uid ?? document?.uid; + const type = documentMetadata?.type ?? document?.type; const concurrent = documentMetadata?.concurrent ?? document?.concurrent; const groupId = documentMetadata?.groupId ?? document?.groupId; const unit = documentMetadata?.unit ?? document?.unit; const investigation = documentMetadata?.investigation ?? document?.investigation; const contextId = documentMetadata?.context_id ?? document?.contextId; + if (type && isPublishedType(type)) return false; if (!!uid && uid === user.id) return true; + if (user.isResearcher) return false; if (!concurrent) return false; if (hasClassUnitScope({ unit, investigation, groupId })) { return !!contextId && contextId === user.classHash; From 4d287d5eb3cc6b41abd753b70a3371e0b8320ec6 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 07:06:00 -0400 Subject: [PATCH 11/51] feat: authorize concurrent-document history writes by class membership [CLUE-610] Characterization tests against a Firestore emulator confirmed history create/read on a concurrent document owned by a synthetic group_ or class_ uid was denied by the pre-existing rule, which gates on userOwnsDocument() resolved through the parent document's real uid. Rebases the history rule onto the concurrent axis: create and read are now also allowed when the parent document carries concurrent: true and the requester's class_hash matches its context_id, matching the existing RTDB write grant on the whole classes/ subtree. Co-Authored-By: Claude Opus 5 (1M context) --- ...27-clue-550-stage-3-sort-work-ui-design.md | 34 +++++++----- firebase-test/src/documents-rules.test.ts | 55 ++++++++++++++++++- firestore.rules | 15 ++++- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 526fd49463..e966b0f99b 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -313,20 +313,26 @@ A class-wide document's owner is the synthetic `class_`, which never denies history writes it denies them for group documents too — a pre-existing gap that has gone unnoticed because group documents are unreleased and are exercised in permissive dev/QA partitions. -**This is established by an emulator test before anything is changed.** The test writes a metadata document -whose `uid` is a synthetic group owner and has a class member attempt to create a history entry under it: - -- If the write is **denied**, the rule is rebased onto the axis — create and read allowed when the parent - document carries `concurrent: true` and the requester's `class_hash` matches its `context_id`, in addition to - `userOwnsDocument()`. That continues Stage 1's pattern of rebasing rules onto `concurrent`, and covers group - and class-wide documents with one clause. It deliberately does **not** narrow group-document history to the - owning group: the auth token carries no group id, so the rules cannot express that, and the RTDB rules already - grant write on the whole `classes/` subtree, so this matches the existing write surface rather than - widening it. -- If the write is **allowed**, no rules change is needed and the test documents why. - -Either way the test remains as the regression guard, and the outcome is recorded in this spec before the PR -opens. +**This was established by an emulator test before anything was changed.** Five characterization tests were added +to the `history entries` block in `firebase-test/src/documents-rules.test.ts` and run against the Firestore +emulator (`firebase emulators:exec --only firestore "npm test"`). A class member's attempt to create a history +entry under a metadata document owned by a synthetic group (`group_myOffering_3`) or class-wide (`class_`) +owner **was denied** — `PERMISSION_DENIED` at the `create` rule for the group case, the class-wide case, and the +corresponding read. The two negative controls (a user outside the class, and a class member on a classmate's +single-writer document) already failed as expected, confirming the axis under test — not just authentication — +was what gated the positive cases. + +Because the writes were denied, the rule was rebased onto the `concurrent` axis: create and read are now allowed +when the parent document carries `concurrent: true` and the requester's `class_hash` matches its `context_id`, in +addition to `userOwnsDocument()`. This continues Stage 1's pattern of rebasing rules onto `concurrent`, and covers +group and class-wide documents with one clause (`isConcurrentClassDocument()` in `firestore.rules`). It +deliberately does **not** narrow group-document history to the owning group: the auth token carries no group id, +so the rules cannot express that, and the RTDB rules already grant write on the whole `classes/` +subtree, so this matches the existing write surface rather than widening it. + +Re-running the full `documents-rules.test.ts` suite (118 tests, including every pre-existing history-entry case) +and the full `firebase-test` suite (364 tests across all 8 rule files) both passed after the change. The five new +tests remain as the regression guard. ## Carried forward from Stage 2 diff --git a/firebase-test/src/documents-rules.test.ts b/firebase-test/src/documents-rules.test.ts index 92adb25d74..da9aa96bdb 100644 --- a/firebase-test/src/documents-rules.test.ts +++ b/firebase-test/src/documents-rules.test.ts @@ -377,7 +377,7 @@ describe("Firestore security rules", () => { describe("history entries", () => { const kDocumentHistoryDocPath = `${kDocumentDocPath}/history/myHistoryEntry`; interface ISpecHisoryDoc { - add?: Record; + add?: Record; remove?: string[]; } function specHistoryEntryDoc(options?: ISpecHisoryDoc) { @@ -552,6 +552,59 @@ describe("Firestore security rules", () => { await expectWriteToSucceed(db, kDocumentHistoryDocPath, specHistoryEntryDoc()); }); + it("class member can create a history entry on a concurrent document owned by a group", async () => { + // A concurrent document's owner is synthetic (`group__` for a group + // document, `class_` for a class-wide one), so it never equals a real user id. + // Every member of the class must still be able to append history to it. + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specHistoryEntryParentDoc({ + add: { uid: "group_myOffering_3", type: "group", concurrent: true, groupId: "3" }, + remove: ["teachers"] + })); + await expectWriteToSucceed(db, kDocumentHistoryDocPath, specHistoryEntryDoc()); + }); + + it("class member can create a history entry on a class-wide concurrent document", async () => { + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specHistoryEntryParentDoc({ + add: { uid: `class_${thisClass}`, type: "group", concurrent: true, unit: "sas" }, + remove: ["teachers"] + })); + await expectWriteToSucceed(db, kDocumentHistoryDocPath, specHistoryEntryDoc()); + }); + + it("class member can read history on a concurrent document they do not own", async () => { + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specHistoryEntryParentDoc({ + add: { uid: `class_${thisClass}`, type: "group", concurrent: true, unit: "sas" }, + remove: ["teachers"] + })); + await adminWriteDoc(kDocumentHistoryDocPath, specHistoryEntryDoc()); + await expectReadToSucceed(db, kDocumentHistoryDocPath); + }); + + it("a user outside the class cannot create a history entry on a concurrent document", async () => { + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specHistoryEntryParentDoc({ + add: { + uid: "class_someOtherClass", type: "group", concurrent: true, unit: "sas", + context_id: otherClass + }, + remove: ["teachers"] + })); + await expectWriteToFail(db, kDocumentHistoryDocPath, specHistoryEntryDoc()); + }); + + it("a class member still cannot create a history entry on a classmate's single-writer document", + async () => { + // The concurrent axis is what grants the write, not class membership on its own. + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specHistoryEntryParentDoc({ + add: { uid: student2Id, type: "problemDocument" }, remove: ["teachers"] + })); + await expectWriteToFail(db, kDocumentHistoryDocPath, specHistoryEntryDoc()); + }); + it ("all updates fail", async () => { db = initFirestore(); await expectUpdateToFail(db, kDocumentHistoryDocPath, {}); diff --git a/firestore.rules b/firestore.rules index 371cfe8f6d..36784feb2e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -513,6 +513,16 @@ service cloud.firestore { return getDocumentOwner() == string(request.auth.token.platform_user_id); } + // A concurrent (multi-writer) document has a synthetic owner — `group__` + // or `class_` — that never equals a real user id, so ownership cannot authorize + // its history. Every member of the document's class may append to it instead. This does not + // narrow a group document's history to its own group: the auth token carries no group id. + function isConcurrentClassDocument() { + let docData = getDocumentData(); + return docData.get("concurrent", false) == true + && request.auth.token.class_hash == docData.context_id; + } + function hasDocumentAccess() { return teacherCanAccessDocument() || researcherCanAccessDocument() || userCanAccessDocument(); } @@ -527,8 +537,9 @@ service cloud.firestore { // For writing individual history entries match /history/{entryId} { - allow create: if isAuthed() && userOwnsDocument(); - allow read: if (isAuthed() && userOwnsDocument()) || teacherCanAccessDocument() || researcherCanAccessDocument(); + allow create: if isAuthed() && (userOwnsDocument() || isConcurrentClassDocument()); + allow read: if (isAuthed() && (userOwnsDocument() || isConcurrentClassDocument())) + || teacherCanAccessDocument() || researcherCanAccessDocument(); allow delete: if false; allow update: if false; } From eae0efda645c8547b945ccfdce719aab526a0c73 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 07:48:41 -0400 Subject: [PATCH 12/51] fix: gate concurrent's write path so it cannot forge history-write authorization [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit concurrent is now an authorization input for the concurrent-document history rule (isConcurrentClassDocument), but nothing kept it read-only: any class member could update a classmate's ordinary document with { concurrent: true }, which was allowed (no read-only field touched, class members can update any in-class document) and forged that classmate's history access for the whole class. type is itself read-only, so it can't be flipped first to route around a type check placed elsewhere. Closes the escalation at the write path: a new concurrentChangeOk() allows a change to concurrent only when the stored document's type is "group", wired into isValidDocumentUpdateRequest(). This stays transitional — two paths still merge-update concurrent onto pre-existing group documents that predate the field (the on-open backfill in src/lib/db.ts, and the one-shot scripts/backfill-group-document-axes.ts) — so concurrent can't yet be made unconditionally read-only. Once both backfills have run everywhere, concurrentChangeOk() should be deleted and concurrent folded into preservesReadOnlyDocumentFields's read-only set. Co-Authored-By: Claude Opus 5 (1M context) --- ...27-clue-550-stage-3-sort-work-ui-design.md | 32 +++++++++++++++---- firebase-test/src/documents-rules.test.ts | 17 ++++++++++ firestore.rules | 17 +++++++++- scripts/backfill-group-document-axes.ts | 7 ++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index e966b0f99b..4e19cc6f46 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -318,9 +318,9 @@ to the `history entries` block in `firebase-test/src/documents-rules.test.ts` an emulator (`firebase emulators:exec --only firestore "npm test"`). A class member's attempt to create a history entry under a metadata document owned by a synthetic group (`group_myOffering_3`) or class-wide (`class_`) owner **was denied** — `PERMISSION_DENIED` at the `create` rule for the group case, the class-wide case, and the -corresponding read. The two negative controls (a user outside the class, and a class member on a classmate's -single-writer document) already failed as expected, confirming the axis under test — not just authentication — -was what gated the positive cases. +corresponding read. The two negative-control tests **passed** (a user outside the class, and a class member on a +classmate's single-writer document): their writes were correctly denied both before and after the fix, confirming +the axis under test — not just authentication — was what gated the positive cases. Because the writes were denied, the rule was rebased onto the `concurrent` axis: create and read are now allowed when the parent document carries `concurrent: true` and the requester's `class_hash` matches its `context_id`, in @@ -330,9 +330,29 @@ deliberately does **not** narrow group-document history to the owning group: the so the rules cannot express that, and the RTDB rules already grant write on the whole `classes/` subtree, so this matches the existing write surface rather than widening it. -Re-running the full `documents-rules.test.ts` suite (118 tests, including every pre-existing history-entry case) -and the full `firebase-test` suite (364 tests across all 8 rule files) both passed after the change. The five new -tests remain as the regression guard. +**Follow-up fix: `concurrent` is itself an authorization input, so its write path needed gating too.** +Code review on this change found that `concurrent` was not in `preservesReadOnlyDocumentFields()`'s read-only +set, and `isValidDocumentUpdateRequest()` lets any class member update any document in their class +(`resourceInUserClass()`). That combination let a class member forge the grant `isConcurrentClassDocument()` +checks: update a classmate's ordinary `problemDocument` with `{ concurrent: true }` — no read-only field is +touched, so the update was allowed — which then made that student, and every classmate, able to read and append +history on the classmate's private document. `type` is itself read-only, so it cannot be flipped first to route +around a `type == "group"` check placed elsewhere. A characterization test run against the pre-fix rules +confirmed the forgery: `expectUpdateToFail(db, kDocumentDocPath, { concurrent: true })` from a classmate's session +against another student's `problemDocument` failed with "Expected request to fail, but it succeeded." + +The fix closes the escalation at the write path rather than narrowing the read path: a new `concurrentChangeOk()` +function (beside `preservesReadOnlyDocumentFields()` in `firestore.rules`) allows a change to `concurrent` only +when the stored document's `type` is `"group"`, and is wired into `isValidDocumentUpdateRequest()`. This is +transitional by design and is commented as such in the rules: two paths still merge-update `concurrent` onto +pre-existing group documents that predate the field — the on-open backfill in `src/lib/db.ts` and the one-shot +`scripts/backfill-group-document-axes.ts` — so it cannot yet be made unconditionally read-only. Once both backfill +paths have run against every environment, `concurrentChangeOk()` should be deleted and `concurrent` added to +`preservesReadOnlyDocumentFields()`'s read-only set, making it settable only at document creation. + +Re-running the full `documents-rules.test.ts` suite (120 tests, including every pre-existing history-entry and +document-update case) and the full `firebase-test` suite (366 tests across all 8 rule files) both passed after +the fix. The seven tests added across both fix rounds remain as the regression guard. ## Carried forward from Stage 2 diff --git a/firebase-test/src/documents-rules.test.ts b/firebase-test/src/documents-rules.test.ts index da9aa96bdb..901a160e8f 100644 --- a/firebase-test/src/documents-rules.test.ts +++ b/firebase-test/src/documents-rules.test.ts @@ -360,6 +360,23 @@ describe("Firestore security rules", () => { await expectUpdateToFail(db, kDocumentDocPath, { title: "new-title" }); }); + it("a class member cannot set concurrent:true on a classmate's non-group document", async () => { + // `concurrent` is an authorization input for the history rule (isConcurrentClassDocument), so + // granting it here would let a class member hand themselves and every classmate history access + // to someone else's document. + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specDocumentDoc({ add: { uid: student2Id }})); + await expectUpdateToFail(db, kDocumentDocPath, { concurrent: true }); + }); + + it("a class member can set concurrent:true on a classmate's group-typed document", async () => { + // The backfill paths (src/lib/db.ts on-open backfill, scripts/backfill-group-document-axes.ts) + // merge-update `concurrent` onto pre-existing group documents, so this must keep working. + db = initFirestore(studentAuth); + await adminWriteDoc(kDocumentDocPath, specDocumentDoc({ add: { uid: student2Id, type: "group" }})); + await expectUpdateToSucceed(db, kDocumentDocPath, { concurrent: true }); + }); + it("authenticated students can't delete documents in their class", async () => { db = initFirestore(studentAuth); await adminWriteDoc(kDocumentDocPath, specDocumentDoc()); diff --git a/firestore.rules b/firestore.rules index 36784feb2e..3d4b415672 100644 --- a/firestore.rules +++ b/firestore.rules @@ -97,6 +97,21 @@ service cloud.firestore { return !affectedFieldsSet.hasAny(readOnlyFieldsSet); } + // TRANSITIONAL: `concurrent` is an authorization input for the history rule below + // (isConcurrentClassDocument), so letting any class member set it on any document would let them + // grant themselves history access to a classmate's document. It stays writable on `type == + // "group"` documents only, because two backfill paths merge-update it onto pre-existing group + // documents that predate the field: the on-open backfill in src/lib/db.ts and the one-shot + // scripts/backfill-group-document-axes.ts. `type` itself is read-only (preservesReadOnlyDocumentFields), + // so it cannot be flipped first to unlock this. Once both backfill paths have run against every + // environment, this function should be deleted and `concurrent` added to + // preservesReadOnlyDocumentFields's readOnlyFieldsSet, making it settable only at document creation. + function concurrentChangeOk() { + let affectedFieldsSet = request.resource.data.diff(resource.data).affectedKeys(); + return !affectedFieldsSet.hasAny(["concurrent"].toSet()) + || resource.data.get("type", "") == "group"; + } + function isValidSupportCreateRequest() { return userIsRequestUser() && classInRequestClasses() && @@ -165,7 +180,7 @@ service cloud.firestore { } function isValidDocumentUpdateRequest() { - return preservesReadOnlyDocumentFields() && + return preservesReadOnlyDocumentFields() && concurrentChangeOk() && ( resourceInUserClass() || userInResourceTeachers() ); } diff --git a/scripts/backfill-group-document-axes.ts b/scripts/backfill-group-document-axes.ts index f13babef2b..a075b2b18e 100644 --- a/scripts/backfill-group-document-axes.ts +++ b/scripts/backfill-group-document-axes.ts @@ -19,6 +19,13 @@ // // Dry run (reports counts, writes nothing): cd scripts && npx tsx backfill-group-document-axes.ts // Apply (performs the writes): cd scripts && APPLY=1 npx tsx backfill-group-document-axes.ts +// +// This script authenticates as a service account, so it writes past Firestore rules regardless of +// what they allow. The client-side backfill in src/lib/db.ts does not: it merge-updates `concurrent` +// as an ordinary authenticated user, which is why the Firestore rule (concurrentChangeOk in +// firestore.rules) transitionally allows any class member to set `concurrent` on a `type == "group"` +// document. Once this script has been run against every environment, tighten that rule so +// `concurrent` is settable only at document creation, and delete concurrentChangeOk. import type { Firestore } from "firebase-admin/firestore"; From e65df98f2d8c2c8f9ac7d6095471e8b004ee4543 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 08:13:46 -0400 Subject: [PATCH 13/51] docs: record Stage-3 axis progress and verification results [CLUE-610] Update the document-axes roadmap's kind/scope/behavior-modules rows and Current-effort paragraph for what Stage 3 delivered, and record the eager-open cost measurement (a real ~700ms fast-path delta against demo/units/qa, obtained via a live Chrome session and Firestore project) and the manual end-to-end check as a pending human-verification checklist. Also correct the concurrentChangeOk transitional-rule breadcrumbs (in firestore.rules, the backfill script, and both stage design specs): once concurrent becomes settable only at document creation, the create path also needs constraining, since isValidDocumentCreateRequest today constrains neither concurrent nor uid. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/README.md | 13 +++-- ...27-clue-550-stage-3-sort-work-ui-design.md | 54 +++++++++++++++---- firestore.rules | 6 ++- scripts/backfill-group-document-axes.ts | 6 ++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/document-axes/README.md b/docs/document-axes/README.md index fc40c20319..2f758a74e6 100644 --- a/docs/document-axes/README.md +++ b/docs/document-axes/README.md @@ -28,12 +28,12 @@ flips the rows it delivers **in the same PR**, and names the stage/ticket under |---|---|---|---| | `canonical` (single pointed-to doc for a scope slot) | scoped pointer slots, rule-enforced | done | CLUE-524; class+unit pointer scope added CLUE-550 Stage 2 | | `concurrent` (multi-writer vs single-writer) | stored per-doc; rule-readable; `DocumentModel` prop sourced from Firestore at open | done | CLUE-550 Stage 1 | -| `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | in progress | CLUE-550 Stage 1 (stored + registry seeded; presentation wiring lands Stage 3); class-wide slot kinds registered and their titles resolved by kind (`getDocumentTitle`) CLUE-550 Stage 2 | +| `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired Stage 3 (workspace title bar reads the registry; no consumer branches on kind) | | `owner` (authoring identity / provenance) | creation: kind-declared `ownerType` → owner `uid` (in the kind registry); read: getter over stored `uid` | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds via `getDocumentOwner`; read-side getter still to come) | -| `scope` (org + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields, narrowing with field/axis **guards** (e.g. `hasOfferingScope`) rather than branching on `type` — whether to also add a single unified `scope` getter is an open question | in progress | CLUE-550 Stage 2 (creation-side scope fields registry-derived for every kind — the `createFirestoreMetadataDocument` type switch is gone) | +| `scope` (org + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields through named guards (`hasGroupScope`, `hasClassUnitScope`) rather than branching on `type` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (read side: guards in `document-scope.ts`; the class+unit scope states its absent curriculum fields explicitly so it is queryable) | | `permissions` (composed grant set) | permission-policy grants (referenced policy) + stored per-doc grants | not started | — | | kind registry (by-kind view) | `register`/`get` map keyed on `kind`; `fn(doc)` API | done | CLUE-550 Stage 1 | -| behavior modules (by-behavior view) | `fn(doc)` reading axis getters / registry; never branch on `kind` | in progress | CLUE-550 Stage 1 (history + write-sync on concurrent; read-access + rules-delete on group type, interim until the permissions axis) | +| behavior modules (by-behavior view) | `fn(doc)` reading axis getters / registry; never branch on `kind` | in progress | CLUE-550 Stage 1 (history + write-sync on concurrent; read-access + rules-delete on group type, interim until the permissions axis); Stage 3 (edit gate `canUserEditDocument`, collaborative thumbnail treatment, and the collaborative title bar all read `concurrent`) | | creation factory (the one `kind → axis` bridge) | reads registry defaults, stamps axis values on a new doc | in progress | CLUE-550 Stage 2 (per-slot class-wide canonical creation; owner `uid` and scope fields stamped from the kind's `ownerType`/`scopeType`) | Status values: `not started` / `in progress` / `done`. @@ -54,3 +54,10 @@ all kinds are registered, `createFirestoreMetadataDocument` derives owner and sc for all document types. The kind axis fields (`kind`/`concurrent`) are stamped only on `type:"group"` documents — avoiding a stamp we would have to migrate if the publication kinds are later folded into the kinds they publish. + +Stage 3 surfaces those documents: Sort Work sections them under "Whole Class" by scope rather than by +type, a unit-scoped listener keeps them visible under the investigation and problem filters, +presentation reads `concurrent` and the kind registry, and one predicate (`canUserEditDocument`) +gates every Edit button. It also settles the deferred scope-modeling question: consumers read narrow +named guards over the stored association fields, with no `scopeLevel` enum and no unified `scope` +struct (see docs/document-scope.md). diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 4e19cc6f46..0248f58fa9 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -348,7 +348,12 @@ transitional by design and is commented as such in the rules: two paths still me pre-existing group documents that predate the field — the on-open backfill in `src/lib/db.ts` and the one-shot `scripts/backfill-group-document-axes.ts` — so it cannot yet be made unconditionally read-only. Once both backfill paths have run against every environment, `concurrentChangeOk()` should be deleted and `concurrent` added to -`preservesReadOnlyDocumentFields()`'s read-only set, making it settable only at document creation. +`preservesReadOnlyDocumentFields()`'s read-only set, making it settable only at document creation — a change that +also requires constraining `isValidDocumentCreateRequest()` (firestore.rules:172-180), which today constrains +neither `concurrent` nor `uid`: a truthy `concurrent` at create should imply `type == "group"`, and/or the create +should require `userIsRequestUser()`, or a class member can create a new document stamped with a classmate's +`uid` and `concurrent: true`. That does not reopen the escalation closed above — a create cannot target an +existing document — but it leaves that vector open once the update-path allowance above is removed. Re-running the full `documents-rules.test.ts` suite (120 tests, including every pre-existing history-entry and document-update case) and the full `firebase-test` suite (366 tests across all 8 rule files) both passed after @@ -373,10 +378,29 @@ declares a `drivingQuestionBoard` slot, so they are live now. - `document-workspace.tsx`'s `guaranteeInitialDocuments` re-opens a `type: "group"` primary document after a reload (group documents are not loaded automatically); a class-wide document restored as the primary document is covered by that same branch, which is the behavior we want. -- **Eager-open cost.** `createDeclaredClassWideDocuments` runs on every unit load, including the fast path, and - opens each declared slot's document into `stores.documents` (subscribing its history manager). This stage - measures that cost on a unit that declares a slot and records the result; if it is material, the fix is to - defer the open rather than the get-or-create, and it is called out as such rather than folded in silently. +- **Eager-open cost — measured.** `createDeclaredClassWideDocuments` runs on every unit load, including the fast + path, and opens each declared slot's document into `stores.documents` (subscribing its history manager). + Measured against `demo/units/qa` (one declared slot, `drivingQuestionBoard`) on a second load — the pointer + and metadata documents already exist, so the fast path (`pointerRef.get()` then + `openCanonicalDocumentByKey()`) runs — by timing `stores.unitLoadedPromise` resolving against the class-wide + document appearing in `stores.documents.all`, in a real Chrome session against a live Firestore project + (`collaborative-learning-ec215`). Two independent reloads: **670ms** and **730ms** (~700ms average) between + the unit-loaded event and the document appearing. That crosses the "material" threshold this bullet set (more + than a few hundred milliseconds), and confirms the reasoning below directly: the fast path is still two + sequential round trips, not one. + Read count could not be measured the way originally planned: the Firestore v8 SDK multiplexes every + listener and one-time read for the whole page (persistent UI, curriculum, class/group listeners, this + slot's pointer and metadata reads) over one shared long-polling WebChannel connection, so individual reads + are not visible as separate Network-panel entries — only channel-level HTTP requests shared across + everything else the page is doing at that moment. By code inspection (not empirical capture), + `getOrCreateCanonicalDocument`'s fast path is `pointerRef.get()` (one read) followed by + `openCanonicalDocumentByKey()` → `findFirestoreMetadata()` (a second read), i.e. two sequential reads per + declared slot before the document opens — consistent with "a handful," but this half of the measurement is + reasoned, not captured. + **This is material and not fixed here, per the brief for this bullet: defer the open, not the + get-or-create.** The get-or-create must still run at unit-load to converge the class on one document per + slot; what should move is subscribing `stores.documents` and the history manager, which is what makes the + 700ms visible on the critical path today. Filed as a follow-up rather than folded into this stage. ## Roadmap update @@ -426,11 +450,21 @@ declares a `drivingQuestionBoard` slot, so they are live now. `Group N Document` for a group document; the thumbnail collaborative treatment driven by `concurrent`. - **Rules (emulator):** the synthetic-owner history-write test described above, plus whichever outcome it establishes. -- **Manual end-to-end** on `demo/units/qa`, which declares a `drivingQuestionBoard` slot: the document appears - in Sort Work under "Whole Class" for every filter (All, Unit, Investigation, Problem) and under "No Name" when - sorting by name; its title is the authored one; the Edit button appears for any class member; editing it from - two browser sessions persists from both; a group document's title, thumbnail, and Edit button are unchanged; - and the resources-pane Edit button behaves correctly in the my-work, learning-log, and class-work tabs. +- **Manual end-to-end — pending human verification.** This requires two browser profiles signed in as + different students in one class, which is outside what an automated session can perform; it was not run as + part of this task. On `demo/units/qa` (declares a `drivingQuestionBoard` slot), with two browser profiles + signed in as different students in one class, verify: + 1. Sort Work shows a "Whole Class" section containing the class-wide document, under each of the All, Unit, + Investigation, and Problem filters. + 2. Sorting by Name shows it under "No Name" and under no student. + 3. Its title is the one authored in `classWideDocuments`. + 4. Its thumbnail has the collaborative (purple/badge) treatment. + 5. The Edit button appears for both students; editing from one appears in the other. + 6. Opening it in the workspace shows the authored title in the title bar, not `Group undefined Document`. + 7. A group document's title, thumbnail, section, and Edit button are unchanged. + 8. In the resources pane: My Work and Learning Log still show Edit for the user's own documents; a + bookmarked document owned by another student does not show Edit. + Record any deviation here rather than silently fixing it. - Full `npm test`, `npm run check:types`, `npm run lint:build`, and the `firebase-test` rules suite green. ## References diff --git a/firestore.rules b/firestore.rules index 3d4b415672..5859530ee7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -105,7 +105,11 @@ service cloud.firestore { // scripts/backfill-group-document-axes.ts. `type` itself is read-only (preservesReadOnlyDocumentFields), // so it cannot be flipped first to unlock this. Once both backfill paths have run against every // environment, this function should be deleted and `concurrent` added to - // preservesReadOnlyDocumentFields's readOnlyFieldsSet, making it settable only at document creation. + // preservesReadOnlyDocumentFields's readOnlyFieldsSet, making it settable only at document creation — + // which also requires constraining isValidDocumentCreateRequest below: today it constrains neither + // `concurrent` nor `uid`, so a class member can create a new document stamped with a classmate's `uid` + // and `concurrent: true`. A truthy `concurrent` at create should imply `type == "group"`, and/or the + // create should require userIsRequestUser(). function concurrentChangeOk() { let affectedFieldsSet = request.resource.data.diff(resource.data).affectedKeys(); return !affectedFieldsSet.hasAny(["concurrent"].toSet()) diff --git a/scripts/backfill-group-document-axes.ts b/scripts/backfill-group-document-axes.ts index a075b2b18e..83b0734e9e 100644 --- a/scripts/backfill-group-document-axes.ts +++ b/scripts/backfill-group-document-axes.ts @@ -25,7 +25,11 @@ // as an ordinary authenticated user, which is why the Firestore rule (concurrentChangeOk in // firestore.rules) transitionally allows any class member to set `concurrent` on a `type == "group"` // document. Once this script has been run against every environment, tighten that rule so -// `concurrent` is settable only at document creation, and delete concurrentChangeOk. +// `concurrent` is settable only at document creation, and delete concurrentChangeOk — and constrain +// isValidDocumentCreateRequest in firestore.rules alongside it, since it constrains neither +// `concurrent` nor `uid` today: a truthy `concurrent` at create should imply `type == "group"`, +// and/or the create should require userIsRequestUser(), or a class member can create a new document +// stamped with a classmate's `uid` and `concurrent: true`. import type { Firestore } from "firebase-admin/firestore"; From caef08a031084e0e7d0f1ab53e7fadd09b30c9b2 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 08:59:42 -0400 Subject: [PATCH 14/51] fix: guard group-document title on groupId, correct exemplar scope docs, narrow hasGroupScope [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDocumentTitle mislabeled a cross-unit class-wide document (type:"group", unregistered kind, no groupId) as "Group undefined Document" by falling through to the group-title branch on type alone; it now also requires groupId. document-scope.md, the Stage-3 design doc, and document-scope.test.ts claimed exemplars carry no unit — they carry both a unit and an investigation, and it's the investigation that excludes them from hasClassUnitScope; docs and the test fixture now reflect that. hasGroupScope is now a type predicate, removing a cast and a dead fallback in document-group.ts. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-scope.md | 2 +- ...026-07-27-clue-550-stage-3-sort-work-ui-design.md | 2 +- src/models/document/document-kinds.test.ts | 7 +++++++ src/models/document/document-kinds.ts | 12 ++++++++---- src/models/document/document-scope.test.ts | 4 ++-- src/models/document/document-scope.ts | 5 +++-- src/models/stores/document-group.ts | 5 ++--- 7 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/document-scope.md b/docs/document-scope.md index 5e9249dd68..ba3d90a6f8 100644 --- a/docs/document-scope.md +++ b/docs/document-scope.md @@ -66,7 +66,7 @@ No other stored shape satisfies `hasClassUnitScope`: | personal, learning log | `null` | — | — | no | | problem, planning, publications | set | set | — | no | | group | set | set | set | no | -| exemplar (from curriculum) | unset | unset | — | no | +| exemplar (from curriculum) | set | set | — | no | | class-wide slot | set | `null` | — | **yes** | **No `scopeLevel` enum and no unified `scope` struct.** Scope is multi-dimensional — a personal diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 0248f58fa9..4a293682e2 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -79,7 +79,7 @@ unit loads). A registry lookup would silently misfile it; a field read cannot. | personal, learning log | `null` | — | — | no (`unit` is null) | | problem, planning, publications | set | set | — | no (has `investigation`) | | group | set | set | set | no | -| exemplar (from curriculum) | unset | unset | — | no (`unit` unset) | +| exemplar (from curriculum) | set | set | — | no (has `investigation`) | | class-wide slot | set | `null` | — | **yes** | `docs/document-scope.md` gains a section recording these guards, the table above, and the decision not to diff --git a/src/models/document/document-kinds.test.ts b/src/models/document/document-kinds.test.ts index 9b95567efa..4f7a043413 100644 --- a/src/models/document/document-kinds.test.ts +++ b/src/models/document/document-kinds.test.ts @@ -141,6 +141,13 @@ describe("document kinds registry", () => { expect(getDocumentTitle({ kind: PersonalDocument, type: PersonalDocument })).toBeUndefined(); expect(getDocumentTitle({ type: "unregistered" })).toBeUndefined(); }); + + it("does not mislabel a class-wide document with an unregistered kind as a group document", () => { + // A class-wide document also stores type:"group" but carries no groupId. If its kind belongs to a + // unit that has not loaded this session, the registry lookup above misses and this must not fall + // through to the group-document label (which would read "Group undefined Document"). + expect(getDocumentTitle({ kind: "unregisteredClassWideKind", type: GroupDocument })).toBeUndefined(); + }); }); describe("getDocumentScopeFields for a classUnit kind", () => { diff --git a/src/models/document/document-kinds.ts b/src/models/document/document-kinds.ts index 33cc527f33..e1c1885161 100644 --- a/src/models/document/document-kinds.ts +++ b/src/models/document/document-kinds.ts @@ -178,10 +178,14 @@ interface IDocumentTitleFields { export function getDocumentTitle(document: IDocumentTitleFields): string | undefined { const registeredTitle = getDocumentKindInfo(document.kind)?.title; if (registeredTitle != null) return registeredTitle; - // Keyed on `type`, not `kind`: pre-existing group documents predate the `kind` axis and may have no stored - // `kind` yet. We backfill the kind on open but we need the title for the lists of documents before they - // are opened. Class-wide docs are new and always carry a `kind`, so their title is resolved above by kind. - if (document.type === GroupDocument) return `Group ${document.groupId} Document`; + // Keyed on `type` plus `groupId`, not `kind`: a group document may have no stored `kind` yet (we backfill + // the kind on open but need the title for the lists of documents before they are opened), so it cannot rely + // on the lookup above. Requiring `groupId` (not just `type === GroupDocument`) matters because a class-wide + // document also stores `type: "group"` but carries no `groupId` — if its `kind` is unregistered in this + // session (e.g. it belongs to a unit that has not loaded), the lookup above misses and execution reaches + // here; without the `groupId` check it would render as "Group undefined Document" instead of falling + // through to `undefined`, which callers already handle. + if (document.type === GroupDocument && document.groupId) return `Group ${document.groupId} Document`; return undefined; } diff --git a/src/models/document/document-scope.test.ts b/src/models/document/document-scope.test.ts index 02305db509..d716db0ef2 100644 --- a/src/models/document/document-scope.test.ts +++ b/src/models/document/document-scope.test.ts @@ -6,7 +6,7 @@ describe("document scope guards", () => { const personal = { unit: null, investigation: null, groupId: null }; const problem = { unit: "sas", investigation: "1", problem: "2", groupId: null }; const group = { unit: "sas", investigation: "1", problem: "2", groupId: "3" }; - const exemplar = { unit: undefined, investigation: undefined, groupId: undefined }; + const exemplar = { unit: "qa", investigation: "1", problem: "1" }; const classWide = { unit: "sas", investigation: null, groupId: null }; const legacyClassWide = { unit: "sas" }; // created before investigation/problem were stamped @@ -30,7 +30,7 @@ describe("document scope guards", () => { expect(hasClassUnitScope(personal)).toBe(false); // no unit expect(hasClassUnitScope(problem)).toBe(false); // has an investigation expect(hasClassUnitScope(group)).toBe(false); // has an investigation and a group - expect(hasClassUnitScope(exemplar)).toBe(false); // no unit + expect(hasClassUnitScope(exemplar)).toBe(false); // has an investigation }); it("treats an empty-string unit as no unit", () => { diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts index 36f50bb057..a2dac390f8 100644 --- a/src/models/document/document-scope.ts +++ b/src/models/document/document-scope.ts @@ -26,7 +26,7 @@ export interface IDocumentScopeFields { * In Firestore metadata only group-scoped documents carry a `groupId`; other documents deliberately * leave it unset so a stale group id can never be read back (see DocumentMetadataModel.groupId). */ -export function hasGroupScope(doc: IDocumentScopeFields): boolean { +export function hasGroupScope(doc: IDocumentScopeFields): doc is IDocumentScopeFields & { groupId: string } { return !!doc.groupId; } @@ -36,7 +36,8 @@ export function hasGroupScope(doc: IDocumentScopeFields): boolean { * * No other stored shape matches: class-scoped documents (personal, learning log) have `unit: null`; * offering-scoped documents (problem, planning, publications) carry an `investigation`; group - * documents carry both an `investigation` and a `groupId`; curriculum exemplars carry no `unit`. + * documents carry both an `investigation` and a `groupId`; curriculum exemplars carry a `unit` but + * also an `investigation`. */ export function hasClassUnitScope(doc: IDocumentScopeFields): boolean { return !!doc.unit && !doc.investigation && !doc.groupId; diff --git a/src/models/stores/document-group.ts b/src/models/stores/document-group.ts index 8cb01fbfdc..79408466a5 100644 --- a/src/models/stores/document-group.ts +++ b/src/models/stores/document-group.ts @@ -200,7 +200,7 @@ export class DocumentGroup { this.documents.forEach((doc) => { const { sectionLabel, sortKey } = (() => { // A document scoped to a group belongs to that group, whoever created it. - if (hasGroupScope(doc)) return groupSection(doc.groupId as string); + if (hasGroupScope(doc)) return groupSection(doc.groupId); // A document scoped to the class and unit belongs to the class as a whole. if (hasClassUnitScope(doc)) { return { sectionLabel: kWholeClassSectionLabel, sortKey: { scope: "class" } as GroupSectionSortKey }; @@ -242,8 +242,7 @@ export class DocumentGroup { addDocToSection(doc, kNoNameSectionLabel); } else if (hasGroupScope(doc)) { // A group document is listed under every member of the group that owns it. - const groupId = doc.groupId ?? "unknownGroup"; - const group = this.stores.groups.getGroupById(groupId); + const group = this.stores.groups.getGroupById(doc.groupId); group?.users.forEach(user => { addDocForUser(doc, user.classUser); }); From 7a2eb9641f023403fa0f501c7dbeee4c951d017f Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 11:12:59 -0400 Subject: [PATCH 15/51] feat: title a class-wide document seen from another unit [CLUE-610] Under the Sort Work "All" filter a class sees documents from every unit it has worked through, and only the current unit's config is loaded. A class-wide document from another unit therefore had no resolvable title and stores none, so it rendered blank; and where two units declare the same kind, the current unit's authored wording was applied to a document it does not govern. Record the declaring unit on the kind registration and return a registered title only for that unit's documents. A document that resolves no title and stores none is named by its kind plus its curriculum scope, read from the stored fields so it holds for any scope shape. getDocumentTitleFromProblem shares the scope label, so the "sas-1.2" format has one definition. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-scope.md | 22 ++++++++++ ...27-clue-550-stage-3-sort-work-ui-design.md | 33 +++++++++++++++ src/lib/db.ts | 5 ++- src/models/document/document-kinds.test.ts | 42 +++++++++++++++++-- src/models/document/document-kinds.ts | 31 +++++++++++++- src/models/document/document-scope.test.ts | 25 ++++++++++- src/models/document/document-scope.ts | 18 ++++++++ src/models/document/document-utils.test.ts | 38 +++++++++++++++++ src/models/document/document-utils.ts | 33 +++++++++++---- 9 files changed, 233 insertions(+), 14 deletions(-) diff --git a/docs/document-scope.md b/docs/document-scope.md index ba3d90a6f8..2f88b8c41a 100644 --- a/docs/document-scope.md +++ b/docs/document-scope.md @@ -76,6 +76,28 @@ ordered level would be ambiguous. Named guards are added as consumers need them. A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents from other units, whose kinds are not registered in the current session. +The same module provides `getCurriculumScopeLabel(doc)`, which names a document's curriculum scope +from those fields — `"sas-1.2"` when it is scoped to a problem, `"sas"` when it is scoped to a unit +and nothing narrower. Titles use it as a stand-in when a document's real title cannot be resolved. + +## Titling a document from another unit + +Under the Sort Work "All" filter a class sees every document it owns, including documents from units +it has already worked through — the class hash spans units. Two title-resolution problems follow, and +both are handled by treating a unit-declared title as belonging to its unit: + +- A kind declared by a unit that is not loaded has no registered title, and a class-wide document + stores no title of its own. `getDocumentDisplayTitle` names it from + `getDocumentKindLabel(kind)` plus the scope label — `"Driving Question Board (other)"`. +- Two units may declare the *same* kind with different wording. `IDocumentKindInfo.unit` records + which unit's config declared a title, and `getDocumentTitle` returns it only for that unit's + documents, so a foreign document falls through to the label above rather than borrowing wording + that may not be its own. + +The kind label recovers the kind's identity, not the author's wording: a slot titled "Our Big +Questions" in its own unit reads as "Driving Question Board" from elsewhere. Nothing loads another +unit's config, so its authored title is not available. + # View layer ## React Context diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 4a293682e2..41eacf6c96 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -254,6 +254,39 @@ kind-specific rule cuts against the project's requirement that adding another cl *configuration* change. This is the one intentional difference from #2890's UX and should be confirmed in the parity check. +### Titling a document from another unit + +The Sort Work base query constrains only `context_id`, and under the **All** filter no unit clause is added. +The class hash comes from the portal class, not the offering, so a class that has worked through more than +one unit sees documents from all of them — the pre-existing `Problem doc from sas-1.2` fallback exists for +exactly this reason. Class-wide documents reach that view too, and they raise two problems that titling by +kind alone cannot answer, because only the *current* unit's config is loaded: + +1. The document's kind was declared by a unit that is not loaded, so the registry holds no title — and a + class-wide document stores no title of its own. It would render blank. +2. Two units declare the *same* kind with different authored wording. The registry answers, but with the + current unit's title, stating a title for a document that unit does not govern. + +Both are handled by treating a unit-declared title as belonging to its unit. `IDocumentKindInfo` gains a +`unit` field, recorded at registration from the same unit code the document is stamped with, and +`getDocumentTitle` returns a registered title only for that unit's documents. Built-in kinds set no `unit` +and no `title`, so they are unaffected; the group-document label does not depend on unit config and keeps +working across units. + +A document that resolves no title and stores none is named by `getDocumentKindLabel(kind)` — a registry-free +reading of the camelCase kind — plus `getCurriculumScopeLabel(doc)`, giving `Driving Question Board (other)`. +The scope label is read from the stored fields rather than assumed to be a unit, because an unresolvable +kind says nothing about how the document is scoped; it produces `sas-1.2` for a problem-scoped document and +`sas` for a class-wide one, and `getDocumentTitleFromProblem` now shares it so the format lives in one place. +Provenance appears only in this fallback, matching the existing convention: a resolvable title is shown +plain, and coordinates stand in when there is none. + +Two limits are accepted. The label recovers the kind's identity, not the author's wording — a slot titled +"Our Big Questions" reads as "Driving Question Board" from another unit, and nothing loads that unit's +config to do better. And a document carrying a *renamed* unit code compares unequal to the declaring unit, +so it drops to the fallback; `getUnitCodeVariants` lives on `curriculumConfig`, which the registry (a leaf +module) does not import. + **No icon is authored.** Stage 2 removed the unused `icon` field from the `classWideDocuments` declaration and left the question to this stage; nothing here needs one, so no icon field is added to the unit config or to the kind registry. Adding one later is additive. diff --git a/src/lib/db.ts b/src/lib/db.ts index 35b62fe3dc..5813f550d4 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -817,7 +817,10 @@ export class DB { metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", - title: classWideDoc.title + title: classWideDoc.title, + // The same code stamped as the document's `unit` (see currentProblemInfo), so getDocumentTitle + // can tell this unit's documents from another unit's that declares the same kind. + unit: this.stores.unit.code }); } catch (err) { console.error("Ignoring class-wide document:", classWideDoc.kind, err); diff --git a/src/models/document/document-kinds.test.ts b/src/models/document/document-kinds.test.ts index 4f7a043413..0b376b747e 100644 --- a/src/models/document/document-kinds.test.ts +++ b/src/models/document/document-kinds.test.ts @@ -1,8 +1,8 @@ import { GroupDocument, PersonalDocument, ProblemDocument } from "./document-types"; import { - getDocumentKindInfo, getDocumentKindMetadataFields, getDocumentOwner, getDocumentOwnerType, - getDocumentScopeFields, getDocumentTitle, isValidDocumentKind, registerDocumentKind, - resetDocumentKindRegistryForTests + getDocumentKindInfo, getDocumentKindLabel, getDocumentKindMetadataFields, getDocumentOwner, + getDocumentOwnerType, getDocumentScopeFields, getDocumentTitle, isValidDocumentKind, + registerDocumentKind, resetDocumentKindRegistryForTests } from "./document-kinds"; describe("isValidDocumentKind", () => { @@ -148,6 +148,42 @@ describe("document kinds registry", () => { // through to the group-document label (which would read "Group undefined Document"). expect(getDocumentTitle({ kind: "unregisteredClassWideKind", type: GroupDocument })).toBeUndefined(); }); + + describe("a title declared by a unit config", () => { + beforeEach(() => { + resetDocumentKindRegistryForTests(); + registerDocumentKind("testUnitDeclaredKind", { + metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + title: "Driving Question Board", unit: "sas" + }); + }); + + it("names a document from the unit that declared it", () => { + expect(getDocumentTitle({ kind: "testUnitDeclaredKind", type: GroupDocument, unit: "sas" })) + .toBe("Driving Question Board"); + }); + + it("does not name a document from another unit that declares the same kind", () => { + // Only the current unit's config is loaded, and another unit may word the same kind + // differently, so its document falls through to the caller's fallback rather than borrowing + // this title. + expect(getDocumentTitle({ kind: "testUnitDeclaredKind", type: GroupDocument, unit: "msa" })) + .toBeUndefined(); + }); + }); + }); + + describe("getDocumentKindLabel", () => { + it("reads a camelCase kind as words", () => { + expect(getDocumentKindLabel("drivingQuestionBoard")).toBe("Driving Question Board"); + expect(getDocumentKindLabel("group")).toBe("Group"); + }); + + it("returns undefined when there is no kind", () => { + expect(getDocumentKindLabel(undefined)).toBeUndefined(); + expect(getDocumentKindLabel(null)).toBeUndefined(); + expect(getDocumentKindLabel("")).toBeUndefined(); + }); }); describe("getDocumentScopeFields for a classUnit kind", () => { diff --git a/src/models/document/document-kinds.ts b/src/models/document/document-kinds.ts index e1c1885161..542e84ddc7 100644 --- a/src/models/document/document-kinds.ts +++ b/src/models/document/document-kinds.ts @@ -1,3 +1,4 @@ +import { upperFirst } from "lodash"; import { IDocumentMetadata } from "../../../shared/shared"; import { GroupDocument, LearningLogDocument, LearningLogPublication, @@ -37,6 +38,15 @@ export interface IDocumentKindInfo { * group documents or in the future problem documents. */ title?: string; + /** + * The unit code whose config declared this kind. Set for kinds declared by a unit config; undefined + * for the built-in kinds, which are unit-independent. + * + * A `title` is authored in one unit's config, so it names only that unit's documents. Two units may + * declare the same kind with different wording, and only the current unit's config is loaded, so + * getDocumentTitle uses this to avoid lending one unit's title to another unit's document. + */ + unit?: string; } /** @@ -170,14 +180,18 @@ interface IDocumentTitleFields { kind?: string | null; type?: string; groupId?: string | null; + unit?: string | null; } /** * The display title for a document based on its kind */ export function getDocumentTitle(document: IDocumentTitleFields): string | undefined { - const registeredTitle = getDocumentKindInfo(document.kind)?.title; - if (registeredTitle != null) return registeredTitle; + const info = getDocumentKindInfo(document.kind); + // A unit-declared title names that unit's documents only (see IDocumentKindInfo.unit). Lending it to + // a document from another unit would state a title confidently that its own unit may word + // differently; such a document falls through to a caller's fallback instead. + if (info?.title != null && (info.unit == null || info.unit === document.unit)) return info.title; // Keyed on `type` plus `groupId`, not `kind`: a group document may have no stored `kind` yet (we backfill // the kind on open but need the title for the lists of documents before they are opened), so it cannot rely // on the lookup above. Requiring `groupId` (not just `type === GroupDocument`) matters because a class-wide @@ -189,6 +203,19 @@ export function getDocumentTitle(document: IDocumentTitleFields): string | undef return undefined; } +/** + * A readable label derived from the kind string alone: "drivingQuestionBoard" → "Driving Question + * Board". Registry-free by design, so it can name a document whose kind was declared by a unit config + * that is not loaded — the case getDocumentTitle cannot answer. + * + * It recovers the kind's identity, not the author's wording: a slot titled "Our Big Questions" in its + * own unit reads as "Driving Question Board" from elsewhere. + */ +export function getDocumentKindLabel(kind?: string | null): string | undefined { + if (!kind) return undefined; + return upperFirst(kind.replace(/([A-Z])/g, " $1")); +} + function registerBuiltInDocumentKinds() { registerDocumentKind(GroupDocument, { metadataFields: { concurrent: true }, diff --git a/src/models/document/document-scope.test.ts b/src/models/document/document-scope.test.ts index d716db0ef2..15ad12f93f 100644 --- a/src/models/document/document-scope.test.ts +++ b/src/models/document/document-scope.test.ts @@ -1,4 +1,4 @@ -import { hasClassUnitScope, hasGroupScope } from "./document-scope"; +import { getCurriculumScopeLabel, hasClassUnitScope, hasGroupScope } from "./document-scope"; describe("document scope guards", () => { // One case per document shape CLUE stores, so the guards are pinned against every shape they @@ -37,4 +37,27 @@ describe("document scope guards", () => { expect(hasClassUnitScope({ unit: "", investigation: null, groupId: null })).toBe(false); }); }); + + describe("getCurriculumScopeLabel", () => { + it("names the problem a document belongs to", () => { + expect(getCurriculumScopeLabel(problem)).toBe("sas-1.2"); + expect(getCurriculumScopeLabel(group)).toBe("sas-1.2"); + expect(getCurriculumScopeLabel(exemplar)).toBe("qa-1.1"); + }); + + it("names the unit alone when the document is scoped no narrower", () => { + expect(getCurriculumScopeLabel(classWide)).toBe("sas"); + expect(getCurriculumScopeLabel(legacyClassWide)).toBe("sas"); + }); + + it("returns undefined when the document has no unit", () => { + expect(getCurriculumScopeLabel(personal)).toBeUndefined(); + expect(getCurriculumScopeLabel({ unit: "" })).toBeUndefined(); + }); + + it("keeps the investigation when a document has one but no problem", () => { + // No registered scope type produces this shape; the label degrades rather than dropping it. + expect(getCurriculumScopeLabel({ unit: "sas", investigation: "1" })).toBe("sas-1.x"); + }); + }); }); diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts index a2dac390f8..8434e10351 100644 --- a/src/models/document/document-scope.ts +++ b/src/models/document/document-scope.ts @@ -17,6 +17,7 @@ export interface IDocumentScopeFields { unit?: string | null; investigation?: string | null; + problem?: string | null; groupId?: string | null; } @@ -42,3 +43,20 @@ export function hasGroupScope(doc: IDocumentScopeFields): doc is IDocumentScopeF export function hasClassUnitScope(doc: IDocumentScopeFields): boolean { return !!doc.unit && !doc.investigation && !doc.groupId; } + +/** + * A short label for the curriculum a document belongs to: "sas-1.2" when it is scoped to a problem, + * "sas" when it is scoped to a unit and nothing narrower, undefined when it has no unit at all. + * + * Callers use it as a stand-in when a document's real title cannot be resolved, so the coordinates + * name the document instead. It reads the stored fields alone, so it describes a document from any + * unit, including one whose config is not loaded. + * + * An investigation with no problem ("sas-1.x") is not a shape any registered scope type produces; it + * is handled so a partial scope still reads as a scope rather than losing the investigation. + */ +export function getCurriculumScopeLabel(doc: IDocumentScopeFields): string | undefined { + if (!doc.unit) return undefined; + if (!doc.investigation) return doc.unit; + return `${doc.unit}-${doc.investigation}.${doc.problem ?? "x"}`; +} diff --git a/src/models/document/document-utils.test.ts b/src/models/document/document-utils.test.ts index 633350968f..bb47a81b09 100644 --- a/src/models/document/document-utils.test.ts +++ b/src/models/document/document-utils.test.ts @@ -204,6 +204,44 @@ describe("document utils", () => { expect(getDocumentDisplayTitle(unit, metadata, appConfig)).toBe("Driving Question Board"); }); }); + + describe("class-wide documents from another unit", () => { + // Under the Sort Work "All" filter a class sees every document it owns, including class-wide + // documents from units it has already worked through. Those units' configs are not loaded, so + // their kinds' titles cannot be looked up and the documents store no title of their own. + const unit = UnitModel.create({ code: "test", title: "test" }); + const appConfig = AppConfigModel.create({ config: unitConfigDefaults }); + + test("names the document by its kind and the unit it came from", () => { + const metadata = DocumentMetadataModel.create({ + type: GroupDocument, kind: "drivingQuestionBoard", uid: "class_c1", key: "dqb-other", + unit: "other", investigation: null, problem: null + }); + expect(getDocumentDisplayTitle(unit, metadata, appConfig)).toBe("Driving Question Board (other)"); + }); + + test("does not borrow the current unit's title for another unit's document of the same kind", () => { + registerDocumentKind("testSharedKind", { + metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + title: "Our Big Questions", unit: "test" + }); + const ownUnitDoc = DocumentMetadataModel.create({ + type: GroupDocument, kind: "testSharedKind", uid: "class_c1", key: "dqb-own", unit: "test" + }); + const otherUnitDoc = DocumentMetadataModel.create({ + type: GroupDocument, kind: "testSharedKind", uid: "class_c1", key: "dqb-other", unit: "other" + }); + expect(getDocumentDisplayTitle(unit, ownUnitDoc, appConfig)).toBe("Our Big Questions"); + expect(getDocumentDisplayTitle(unit, otherUnitDoc, appConfig)).toBe("Test Shared Kind (other)"); + }); + + test("falls back to the kind alone when the document has no unit", () => { + const metadata = DocumentMetadataModel.create({ + type: GroupDocument, kind: "drivingQuestionBoard", uid: "class_c1", key: "dqb-no-unit" + }); + expect(getDocumentDisplayTitle(unit, metadata, appConfig)).toBe("Driving Question Board"); + }); + }); }); describe("canUserEditDocument", () => { diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index 7677396c45..b0d8d611a4 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -10,8 +10,8 @@ import { AppConfigModelType } from "../stores/app-config-model"; import { UserModelType } from "../stores/user"; import { DocumentModelType, IExemplarVisibilityProvider } from "./document"; import { DocumentContentModelType } from "./document-content"; -import { hasClassUnitScope } from "./document-scope"; -import { getDocumentTitle } from "./document-kinds"; +import { getCurriculumScopeLabel, hasClassUnitScope } from "./document-scope"; +import { getDocumentKindLabel, getDocumentTitle } from "./document-kinds"; import { GroupDocument, isExemplarType, isPlanningType, isProblemType, isPublishedType, isSupportType } from "./document-types"; @@ -25,20 +25,35 @@ function getProblemFromDoc(unit: UnitModelType, document: DocumentModelType | ID } function getDocumentTitleFromProblem(currentUnit: UnitModelType, document: DocumentModelType | IDocumentMetadataModel) { - const {type, unit, investigation, problem} = document; const problemModel = getProblemFromDoc(currentUnit, document); if (problemModel) { - if (isPlanningType(type)) { + if (isPlanningType(document.type)) { return `${problemModel.title}: Planning`; } return problemModel.title; } const upperType = upperFirst(document.type); - if (!unit) { + const scopeLabel = getCurriculumScopeLabel(document); + if (!scopeLabel) { return `${upperType} doc without ${translate("contentLevel.unit")}`; } - return `${upperType} doc from ${unit}-${investigation}.${problem}`; + return `${upperType} doc from ${scopeLabel}`; +} + +/** + * A stand-in title for a document that stores none and whose kind resolves no title — a class-wide + * document from a unit whose config is not loaded, or one whose kind another unit also declares. The + * kind names what the document is; the curriculum scope says where it came from. + * + * The scope is read from the stored fields rather than assumed, because an unresolvable kind gives no + * indication of how the document is scoped. + */ +function getUnresolvedDocumentTitle(document: DocumentModelType | IDocumentMetadataModel) { + const kindLabel = getDocumentKindLabel(document.kind); + if (!kindLabel) return undefined; + const scopeLabel = getCurriculumScopeLabel(document); + return scopeLabel ? `${kindLabel} (${scopeLabel})` : kindLabel; } export function getDocumentTitleWithTimestamp( @@ -71,7 +86,11 @@ export function getDocumentDisplayTitle( } else if (isProblemType(type) || isPlanningType(type)) { return getDocumentTitleFromProblem(unit, document); } else { - return getDocumentTitleWithTimestamp(document, appConfig); + const storedTitle = getDocumentTitleWithTimestamp(document, appConfig); + if (storedTitle) return storedTitle; + // Nothing stored and no kind title: name it by kind and scope if we can, otherwise return the + // stored value unchanged so callers see the same empty result as before. + return getUnresolvedDocumentTitle(document) ?? storedTitle; } } From bb38e10b56538164ba78606417b5401701254412 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 11:28:22 -0400 Subject: [PATCH 16/51] docs: record the constraint dynamic kinds place on scope [CLUE-610] A kind declared in configuration is only defined while that configuration is loaded, and kind names are not unique across configurations. So a document must stay interpretable without its kind's definition, and must carry the association naming the configuration that defined it. For unit-declared kinds that association is `unit`, which bounds them to unit-scoped documents or narrower. Making personal-like presets authorable needs a configuration source loaded independently of the current unit. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/README.md | 2 +- docs/document-axes/axes.md | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/document-axes/README.md b/docs/document-axes/README.md index 2f758a74e6..b9f4281b24 100644 --- a/docs/document-axes/README.md +++ b/docs/document-axes/README.md @@ -28,7 +28,7 @@ flips the rows it delivers **in the same PR**, and names the stage/ticket under |---|---|---|---| | `canonical` (single pointed-to doc for a scope slot) | scoped pointer slots, rule-enforced | done | CLUE-524; class+unit pointer scope added CLUE-550 Stage 2 | | `concurrent` (multi-writer vs single-writer) | stored per-doc; rule-readable; `DocumentModel` prop sourced from Firestore at open | done | CLUE-550 Stage 1 | -| `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired Stage 3 (workspace title bar reads the registry; no consumer branches on kind) | +| `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired Stage 3 (workspace title bar reads the registry; no consumer branches on kind); Stage 3 also scopes a unit-declared kind's definition to its unit — see "Static and dynamic kinds" in [axes.md](./axes.md) | | `owner` (authoring identity / provenance) | creation: kind-declared `ownerType` → owner `uid` (in the kind registry); read: getter over stored `uid` | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds via `getDocumentOwner`; read-side getter still to come) | | `scope` (org + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields through named guards (`hasGroupScope`, `hasClassUnitScope`) rather than branching on `type` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (read side: guards in `document-scope.ts`; the class+unit scope states its absent curriculum fields explicitly so it is queryable) | | `permissions` (composed grant set) | permission-policy grants (referenced policy) + stored per-doc grants | not started | — | diff --git a/docs/document-axes/axes.md b/docs/document-axes/axes.md index 1c7384c6c8..c25d8b41c5 100644 --- a/docs/document-axes/axes.md +++ b/docs/document-axes/axes.md @@ -137,6 +137,45 @@ needed a separate preset concept — `type` does both jobs at once. The part tha defaults, the permission baseline, and copy/publish templates have no separate existence today; `type` and the code around it supply them implicitly. +#### Static and dynamic kinds + +A preset does not have to be written in code. Kinds come from two sources: + +- **Static kinds** are registered by the application itself. They exist for the whole session, everywhere, + and are the same for every user. +- **Dynamic kinds** are declared in configuration that is loaded at runtime — today, a unit's + `classWideDocuments`, which registers a kind when that unit loads. This is what lets "add another + class-wide document" be an authoring change rather than a code change, and it is the direction the + roadmap wants: a preset is data. + +Dynamic kinds carry a constraint that static kinds do not, and it is a property of *where the definition +is loaded from*, not of the kind itself: **a dynamic kind's definition is only present when its +configuration is loaded.** Only the current unit's config is loaded, so for a unit-declared kind the +definition is absent for every document from any other unit — while those documents remain visible, because +Sort Work's unfiltered view spans every unit a class has worked through. + +Two rules follow, and both are really the same rule — *a document must remain interpretable without its +kind's definition*: + +1. **Anything a dynamic kind supplies must be stamped at creation or degrade gracefully.** Values the + definition contributes to a document's axes are written onto the document, so they survive the + definition's absence. Anything not stamped — presentation, above all — must have a fallback derived from + stored fields alone. This is the same reason consumers read scope through guards over stored fields + rather than through the registry. +2. **A dynamic kind's documents must carry the association that identifies the configuration that defined + them.** For unit-declared kinds that association is `unit`. Kind names are not globally unique across + configurations — two units may declare the same kind with different wording — so without it there is no + way to tell whether a definition found under that name is the one the document was made from, and the + wrong definition would be applied confidently. + +**This bounds which documents a dynamic kind can create.** A unit-declared kind can only produce documents +scoped to that unit or narrower, because rule 2 requires the `unit` association. It cannot produce +class-scoped documents — the ones with no unit at all, like personal documents and learning logs. Making +*those* presets authorable is a reasonable future goal, but it is not a matter of adding entries to a unit +config: it needs a configuration source loaded independently of the current unit (class- or site-level), so +that a definition is present wherever its documents are, along with an association on the document naming +that source. Until such a source exists, personal-like presets stay static. + ### `permissions` — who may do what **What it is.** The permission set: who may `read`, `write`, `publish`, `copy`, and whether the content From d14a5fbc638a64c53285d96fd5b3c9689cd0f8cd Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 11:37:04 -0400 Subject: [PATCH 17/51] docs: note that the group-title check is transitional [CLUE-610] getDocumentTitle selects the group-document title on `type` plus a groupId because a group document may carry no stored kind, and the lists showing these titles render before a document is opened to backfill one. Once the backfill has stamped kind on every group document the check becomes `kind == "group"`, and the groupId term goes with it: a class-wide document has its own kind and can no longer reach that branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-27-clue-550-stage-3-sort-work-ui-design.md | 9 +++++++++ scripts/backfill-group-document-axes.ts | 4 ++++ src/models/document/document-kinds.ts | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 41eacf6c96..92723b654e 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -388,6 +388,15 @@ should require `userIsRequestUser()`, or a class member can create a new documen `uid` and `concurrent: true`. That does not reopen the escalation closed above — a create cannot target an existing document — but it leaves that vector open once the update-path allowance above is removed. +The same backfill unblocks a smaller cleanup. `getDocumentTitle` selects the group-document title on +`type == "group"` **plus a `groupId`**, rather than on `kind`, because a group document may carry no stored +`kind` — the kind is backfilled on open, but the lists that show these titles render before the documents are +opened. The `groupId` term is what keeps a class-wide document (same `type`, no `groupId`) from being labelled +"Group undefined Document" when its own kind is unregistered. Once the backfill has stamped `kind` on every +group document, this becomes `kind == "group"` and the `groupId` term goes away with it: a class-wide document +has its own kind and can no longer reach the branch at all. Breadcrumbs are in `document-kinds.ts` and the +backfill script. + Re-running the full `documents-rules.test.ts` suite (120 tests, including every pre-existing history-entry and document-update case) and the full `firebase-test` suite (366 tests across all 8 rule files) both passed after the fix. The seven tests added across both fix rounds remain as the regression guard. diff --git a/scripts/backfill-group-document-axes.ts b/scripts/backfill-group-document-axes.ts index 83b0734e9e..deab4c6232 100644 --- a/scripts/backfill-group-document-axes.ts +++ b/scripts/backfill-group-document-axes.ts @@ -30,6 +30,10 @@ // `concurrent` nor `uid` today: a truthy `concurrent` at create should imply `type == "group"`, // and/or the create should require userIsRequestUser(), or a class member can create a new document // stamped with a classmate's `uid` and `concurrent: true`. +// +// The first pass also unblocks a second cleanup: getDocumentTitle (src/models/document/document-kinds.ts) +// selects the group-document title on `type == "group"` plus a groupId, because a group document may carry +// no `kind`. Once every group document has one, that check becomes `kind == "group"`. import type { Firestore } from "firebase-admin/firestore"; diff --git a/src/models/document/document-kinds.ts b/src/models/document/document-kinds.ts index 542e84ddc7..b54a973bd6 100644 --- a/src/models/document/document-kinds.ts +++ b/src/models/document/document-kinds.ts @@ -199,6 +199,11 @@ export function getDocumentTitle(document: IDocumentTitleFields): string | undef // session (e.g. it belongs to a unit that has not loaded), the lookup above misses and execution reaches // here; without the `groupId` check it would render as "Group undefined Document" instead of falling // through to `undefined`, which callers already handle. + // + // TRANSITIONAL: this reads `type` only because a group document may carry no `kind`. Once + // scripts/backfill-group-document-axes.ts has stamped `kind` on every group document in every + // environment, this becomes `document.kind === GroupDocument` and the `groupId` check goes away with it — + // a class-wide document has its own kind, so it can no longer reach this branch at all. if (document.type === GroupDocument && document.groupId) return `Group ${document.groupId} Document`; return undefined; } From 7e1e53c4f652a2dfa186fd90f853f3038704cbeb Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 12:36:37 -0400 Subject: [PATCH 18/51] refactor: name the scope guards for the dimension they read [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope is two linear nestings, not one ordered level: curriculum (unit -> investigation -> problem) and owner (class -> group -> user). Every document sits somewhere on both, which is why no scopeLevel enum fits. Name each guard for its dimension — hasGroupOwnerScope and hasUnitCurriculumScope — and have it read only that dimension's fields. The curriculum guard drops its !groupId term, since a group document is already excluded by being narrowed to an investigation, and gains !offeringId: an offering assigns one problem, so it narrows that same dimension. An offering belongs to neither hierarchy. It is the assignment of a problem to a class — a point in their product — so it fixes curriculum scope while leaving owner scope free, which is how one offering holds both user-owned and group-owned documents. The class association is likewise not an owner level: every document names a class, and naming one is not being owned by one. The model is written once, in docs/document-scope.md; the axes doc, the design spec, and the module header point there rather than restating it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/README.md | 2 +- docs/document-axes/axes.md | 12 +++ docs/document-scope.md | 74 +++++++++++++----- ...27-clue-550-stage-3-sort-work-ui-design.md | 76 +++++++++++-------- src/models/document/document-scope.test.ts | 57 +++++++++----- src/models/document/document-scope.ts | 44 +++++------ src/models/document/document-utils.ts | 11 ++- src/models/stores/document-group.ts | 19 +++-- 8 files changed, 188 insertions(+), 107 deletions(-) diff --git a/docs/document-axes/README.md b/docs/document-axes/README.md index b9f4281b24..b432c5a982 100644 --- a/docs/document-axes/README.md +++ b/docs/document-axes/README.md @@ -30,7 +30,7 @@ flips the rows it delivers **in the same PR**, and names the stage/ticket under | `concurrent` (multi-writer vs single-writer) | stored per-doc; rule-readable; `DocumentModel` prop sourced from Firestore at open | done | CLUE-550 Stage 1 | | `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired Stage 3 (workspace title bar reads the registry; no consumer branches on kind); Stage 3 also scopes a unit-declared kind's definition to its unit — see "Static and dynamic kinds" in [axes.md](./axes.md) | | `owner` (authoring identity / provenance) | creation: kind-declared `ownerType` → owner `uid` (in the kind registry); read: getter over stored `uid` | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds via `getDocumentOwner`; read-side getter still to come) | -| `scope` (org + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields through named guards (`hasGroupScope`, `hasClassUnitScope`) rather than branching on `type` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (read side: guards in `document-scope.ts`; the class+unit scope states its absent curriculum fields explicitly so it is queryable) | +| `scope` (owner + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields through named per-dimension guards (`hasGroupOwnerScope`, `hasUnitCurriculumScope`) rather than branching on `type` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (read side: guards in `document-scope.ts` split along the two dimensions — curriculum unit/investigation/problem and owner class/group/user; the unit curriculum scope states its absent curriculum fields explicitly so it is queryable). Still to come: guards for the class and user levels of owner scope, which live in `uid` | | `permissions` (composed grant set) | permission-policy grants (referenced policy) + stored per-doc grants | not started | — | | kind registry (by-kind view) | `register`/`get` map keyed on `kind`; `fn(doc)` API | done | CLUE-550 Stage 1 | | behavior modules (by-behavior view) | `fn(doc)` reading axis getters / registry; never branch on `kind` | in progress | CLUE-550 Stage 1 (history + write-sync on concurrent; read-access + rules-delete on group type, interim until the permissions axis); Stage 3 (edit gate `canUserEditDocument`, collaborative thumbnail treatment, and the collaborative title bar all read `concurrent`) | diff --git a/docs/document-axes/axes.md b/docs/document-axes/axes.md index c25d8b41c5..a60f495071 100644 --- a/docs/document-axes/axes.md +++ b/docs/document-axes/axes.md @@ -55,6 +55,18 @@ are set. - A **publication** broadens scope to the whole offering or class while its `owner` stays the publisher — `owner` and `scope` diverging is the signature of publishing. +**Scope is two dimensions, not one level** — curriculum (unit → investigation → problem) and owner +(class → group → user) — with the offering crossing both rather than sitting on either. A personal +document is user-owned with no curriculum scope; a class-wide document is class-owned with unit +curriculum scope. Neither is "more scoped" than the other, which is why scope cannot collapse to a +single ordered `scopeLevel`. The model, the guards that read it, and where each stored shape sits are +in [../document-scope.md](../document-scope.md). + +One consequence is worth noting here: the creation side names *combinations* rather than dimensions. +A kind's registered `scopeType` (`class`, `classUnit`, `offering`, `group`) is shorthand for a +pairing, and `offering` names the crossing point directly. That suits a creation preset, which must +fix both dimensions at once, but it is not the vocabulary read-side consumers should use. + **Scope also defines `canonical` slots.** A canonical slot *is* a scope that at most one document is expected to fill — "the problem doc for this user in this offering", "the group doc for this group" (see `canonical`). diff --git a/docs/document-scope.md b/docs/document-scope.md index 2f88b8c41a..6db6933540 100644 --- a/docs/document-scope.md +++ b/docs/document-scope.md @@ -53,32 +53,70 @@ However we are doing this when the appConfig is added to the environment object ## Reading a document's scope in code Consumers that need to know a document's scope read its stored association fields through the guards -in `src/models/document/document-scope.ts`, rather than branching on the document `type`: +in `src/models/document/document-scope.ts`, rather than branching on the document `type`. -- `hasGroupScope(doc)` — the document is scoped to a single group (`groupId` is set). -- `hasClassUnitScope(doc)` — the document is scoped to a class and a unit and nothing narrower: a - class-wide collaborative document. +### Two dimensions, and a reference that crosses them -No other stored shape satisfies `hasClassUnitScope`: +Scope is not one ordered level. It is two, each its own linear nesting, and every document sits +somewhere on both: -| document | `unit` | `investigation` | `groupId` | class+unit scoped? | -|---|---|---|---|---| -| personal, learning log | `null` | — | — | no | -| problem, planning, publications | set | set | — | no | -| group | set | set | set | no | -| exemplar (from curriculum) | set | set | — | no | -| class-wide slot | set | `null` | — | **yes** | +| dimension | levels (widest → narrowest) | read from | +|---|---|---| +| **curriculum scope** | unit → investigation → problem | `unit`, `investigation`, `problem` | +| **owner scope** | class → group → user | `groupId`; the class and user levels live in `uid` | -**No `scopeLevel` enum and no unified `scope` struct.** Scope is multi-dimensional — a personal -document is class+owner scoped while a class-wide document is class+unit scoped — so a single -ordered level would be ambiguous. Named guards are added as consumers need them. +This is why there is **no `scopeLevel` enum and no unified `scope` struct** — a single ordered level +cannot express a position on two axes at once. A personal document is user-owned with no curriculum +scope; a class-wide document is class-owned with unit curriculum scope. Neither is "more scoped". + +**`offeringId` is on neither dimension — it crosses both.** An offering is the assignment of one +problem to one class, so it is a point in the *product* of the two hierarchies rather than a level in +either. Carrying one pins curriculum scope at problem, but it does not determine owner scope: the +documents inside a single offering are variously user-owned (problem, planning), group-owned (group), +and could be class-owned. + +**`context_id` is not a level either** — every document names a class. Being *associated with* a +class is not being *owned by* one; class ownership is a synthetic `class_` uid. That is why +these guards name only the level they test and leave the class out of the name. + +**Each guard answers about one dimension and reads only that dimension's fields:** + +- `hasGroupOwnerScope(doc)` — owner scope: the document belongs to a single group (`groupId` is set). +- `hasUnitCurriculumScope(doc)` — curriculum scope: the document spans a whole unit, narrowed no + further. + +A consumer needing a position on both asks both. Keeping the guards single-dimension is what makes +each one's meaning independent of what the other dimension holds: `hasGroupOwnerScope` does not care +which problem a document belongs to, and `hasUnitCurriculumScope` does not care who owns it. + +Where each stored shape sits: + +| document | `unit` | `investigation` | `offeringId` | `groupId` | curriculum scope | owner scope | +|---|---|---|---|---|---|---| +| personal, learning log | `null` | — | — | — | none | user | +| problem, planning, publications | set | set | set | — | problem | user | +| group | set | set | set | set | problem | **group** | +| exemplar (from curriculum) | set | set | — | — | problem | user | +| class-wide slot | set | `null` | — | — | **unit** | class | A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents from other units, whose kinds are not registered in the current session. -The same module provides `getCurriculumScopeLabel(doc)`, which names a document's curriculum scope -from those fields — `"sas-1.2"` when it is scoped to a problem, `"sas"` when it is scoped to a unit -and nothing narrower. Titles use it as a stand-in when a document's real title cannot be resolved. +**Two gaps, recorded rather than closed:** + +- No guard reads the class or user levels of owner scope, because those live in `uid` — the class + owner is a synthetic `class_`. A consumer wanting "owned by the class" currently + approximates it with `hasUnitCurriculumScope`, which is correct only while the one class-owned kind + is also the one unit-scoped kind. `document-group.ts`'s `byName` is commented to that effect and + should switch when an owner-scope guard exists. +- `offeringId` is written to Firestore at creation but is not declared on `IDocumentMetadata` or + modelled on `DocumentMetadataModel`, so no read-side consumer can see it. Every document that + carries one also carries an `investigation`, so nothing is misclassified today, but the field is + effectively write-only until the `scope` axis's read side surfaces it. + +The same module provides `getCurriculumScopeLabel(doc)`, which names a document's position on the +curriculum dimension — `"sas-1.2"` for a problem, `"sas"` for a unit. Titles use it as a stand-in +when a document's real title cannot be resolved. ## Titling a document from another unit diff --git a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md index 92723b654e..3bd2d59293 100644 --- a/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md +++ b/docs/superpowers/specs/2026-07-27-clue-550-stage-3-sort-work-ui-design.md @@ -19,9 +19,11 @@ ## Summary — what this PR delivers -1. **Scope guards** — two named predicates over the stored association fields (`hasGroupScope`, - `hasClassUnitScope`) in a new leaf module. This resolves the scope-modeling checkpoint the project deferred to - its richest consumer: **narrow named guards, no `scopeLevel` enum and no unified `scope` struct.** +1. **Scope guards** — named predicates over the stored association fields, one per scope dimension + (`hasGroupOwnerScope`, `hasUnitCurriculumScope`), in a new leaf module. This resolves the scope-modeling + checkpoint the project deferred to its richest consumer: **scope is two independent dimensions — curriculum + (unit/investigation/problem) and owner (class/group/user) — read through per-dimension guards, with no + `scopeLevel` enum and no unified `scope` struct.** 2. **Explicit-null scope fields for the `classUnit` scope**, so "scoped to a unit but not to a problem" is directly queryable, following the convention that a scope field written as `null` means *absent scope* — with a backfill pass added to Stage 1's one-shot script for documents created before the change. @@ -57,35 +59,43 @@ already writes. No consumer needs a "this is a class-wide document" flag, and no consumer that asked that question would be branching on identity again, which is exactly what this project exists to remove. -**Decision: two narrow guards, no general scope model.** A new leaf module +**Decision: per-dimension guards, no general scope model.** A new leaf module `src/models/document/document-scope.ts` — structural parameter types only, no model imports, the same shape as -`document-kinds.ts`: +`document-kinds.ts`. + +Scope turns out to be **two dimensions** — curriculum (unit → investigation → problem) and owner (class → +group → user) — with the offering crossing both rather than sitting on either. That is why no `scopeLevel` +enum is introduced: a single ordered level cannot express a position on two axes at once. The model and the +field-by-shape table live in `docs/document-scope.md`, added by this PR; what matters here is the shape of the +API it produces: ```ts -hasGroupScope(doc) = !!doc.groupId -hasClassUnitScope(doc) = !!doc.unit && !doc.investigation && !doc.groupId +hasGroupOwnerScope(doc) = !!doc.groupId +hasUnitCurriculumScope(doc) = !!doc.unit && !doc.investigation && !doc.offeringId ``` -These read only stored association fields — no `type`, no `kind`, no `concurrent`. That matters for a real case: -under the "All" filter Sort Work lists documents from *other* units of the same class, and a class-wide document -from another unit has a `kind` that was never registered in this session (kinds are registered when the current -unit loads). A registry lookup would silently misfile it; a field read cannot. - -`hasClassUnitScope` is unambiguous across every document shape CLUE stores today, which is why it needs no -`concurrent` or `type` term to disambiguate: - -| document | `unit` | `investigation` | `groupId` | matches? | -|---|---|---|---|---| -| personal, learning log | `null` | — | — | no (`unit` is null) | -| problem, planning, publications | set | set | — | no (has `investigation`) | -| group | set | set | set | no | -| exemplar (from curriculum) | set | set | — | no (has `investigation`) | -| class-wide slot | set | `null` | — | **yes** | - -`docs/document-scope.md` gains a section recording these guards, the table above, and the decision not to -introduce a `scopeLevel` enum — with the reasoning that the existing scopes differ along more than one axis (a -personal document is class+owner scoped; a class-wide document is class+unit scoped), so a single ordered level -would be ambiguous. +Each guard answers about **one** dimension and reads only that dimension's fields, so its meaning does not +depend on what the other dimension holds. A consumer needing a position on both asks both — a class-wide +collaborative document is `hasUnitCurriculumScope` *and* not `hasGroupOwnerScope`. That is what drops the +cross-dimension `!doc.groupId` term the curriculum guard would otherwise carry (a group document is already +excluded by its `investigation`) and what makes `!doc.offeringId` belong: an offering assigns one problem, so +it narrows this same dimension. + +These read only stored association fields — no `type`, no `kind`, no `concurrent`. That matters for a real +case: under the "All" filter Sort Work lists documents from *other* units of the same class, and a class-wide +document from another unit has a `kind` that was never registered in this session (kinds are registered when +the current unit loads). A registry lookup would silently misfile it; a field read cannot. + +**Two gaps, recorded rather than closed.** No guard reads the class or user levels of owner scope, because +those live in `uid` (the class owner is a synthetic `class_`). `byName` needs "owned by the class" +and approximates it with `hasUnitCurriculumScope`, which is correct only while the one class-owned kind is +also the one unit-scoped kind; the call site is commented to that effect. Separately, `offeringId` is written +to Firestore at creation but is declared on neither `IDocumentMetadata` nor `DocumentMetadataModel`, so it +never reaches a read-side consumer — the `!doc.offeringId` term is inert at today's call sites, and every +document carrying an offering is excluded by its `investigation` instead. Both belong with the `owner` and +`scope` axes' read sides, which are not in this stage. + +`docs/document-scope.md` gains the section recording all of the above. ## Making the scope queryable: explicit-null fields @@ -110,7 +120,7 @@ scope self-describing rather than defined by which fields happen to be missing. ### Backfilling existing documents -A class-wide document created before this change has neither field, so `hasClassUnitScope` still accepts it +A class-wide document created before this change has neither field, so `hasUnitCurriculumScope` still accepts it client-side (a missing field is falsy just as `null` is) but the new Firestore query does not match it — it would silently disappear from Sort Work under the Investigation and Problem filters. Class-wide documents are unreleased, so only dev/QA partitions hold any, but the fix is cheap and the tooling already exists: Stage 1's @@ -136,7 +146,7 @@ Two details worth stating: in practice it is already filtered out — but if one ever lacked the field it would be mis-stamped with the *group* kind, silently breaking its title and its canonical-pointer slot. Selecting on `groupId` makes the two passes select disjoint sets by scope rather than relying on a value that a partial write could leave - missing. This is the same group-scope question `hasGroupScope` asks, expressed in the script. + missing. This is the same owner-scope question `hasGroupOwnerScope` asks, expressed in the script. - **The script is renamed to match what it now does.** `backfill-group-concurrent.ts` → `backfill-group-document-axes.ts`, with `backfillGroupConcurrent` → `backfillGroupDocumentAxes` and the test file renamed alongside it. It is no longer a one-field backfill: it normalizes the stored axes of every @@ -180,8 +190,8 @@ equality-only, so Firestore serves it from single-field indexes with no composit ### `byGroup` ```ts -if (hasGroupScope(doc)) return `${groupTerm} ${doc.groupId}`; -if (hasClassUnitScope(doc)) return kWholeClassSectionLabel; // "Whole Class" +if (hasGroupOwnerScope(doc)) return `${groupTerm} ${doc.groupId}`; +if (hasUnitCurriculumScope(doc)) return kWholeClassSectionLabel; // "Whole Class" const group = this.stores.groups.groupForUser(doc.uid); return group ? `${groupTerm} ${group.id}` : `No ${groupTerm}`; ``` @@ -450,8 +460,8 @@ declares a `drivingQuestionBoard` slot, so they are live now. - `kind` → **done** — presentation now reads the registry (title in Stage 2, title bar here) and no consumer branches on kind. -- `scope` → stays **in progress**, with the read side recorded: narrow named guards (`hasGroupScope`, - `hasClassUnitScope`) over stored association fields, and the checkpoint outcome that no `scopeLevel` enum or +- `scope` → stays **in progress**, with the read side recorded: per-dimension guards (`hasGroupOwnerScope`, + `hasUnitCurriculumScope`) over stored association fields, and the checkpoint outcome that no `scopeLevel` enum or unified `scope` struct is introduced. - behavior modules → the edit-gate predicate and the `concurrent`-driven presentation added to the list of behaviors reading axes rather than `type`; the history-write rule outcome recorded once known. diff --git a/src/models/document/document-scope.test.ts b/src/models/document/document-scope.test.ts index 15ad12f93f..97975b97ed 100644 --- a/src/models/document/document-scope.test.ts +++ b/src/models/document/document-scope.test.ts @@ -1,40 +1,57 @@ -import { getCurriculumScopeLabel, hasClassUnitScope, hasGroupScope } from "./document-scope"; +import { getCurriculumScopeLabel, hasGroupOwnerScope, hasUnitCurriculumScope } from "./document-scope"; describe("document scope guards", () => { // One case per document shape CLUE stores, so the guards are pinned against every shape they // must distinguish rather than only the two this feature introduces. const personal = { unit: null, investigation: null, groupId: null }; - const problem = { unit: "sas", investigation: "1", problem: "2", groupId: null }; - const group = { unit: "sas", investigation: "1", problem: "2", groupId: "3" }; - const exemplar = { unit: "qa", investigation: "1", problem: "1" }; + const problem = { unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: null }; + const group = { unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: "3" }; + const exemplar = { unit: "qa", investigation: "1", problem: "1" }; // curriculum-authored: no offering const classWide = { unit: "sas", investigation: null, groupId: null }; const legacyClassWide = { unit: "sas" }; // created before investigation/problem were stamped - describe("hasGroupScope", () => { + describe("hasGroupOwnerScope", () => { it("is true only when the document carries a group id", () => { - expect(hasGroupScope(group)).toBe(true); - expect(hasGroupScope(personal)).toBe(false); - expect(hasGroupScope(problem)).toBe(false); - expect(hasGroupScope(exemplar)).toBe(false); - expect(hasGroupScope(classWide)).toBe(false); + expect(hasGroupOwnerScope(group)).toBe(true); + expect(hasGroupOwnerScope(personal)).toBe(false); + expect(hasGroupOwnerScope(problem)).toBe(false); + expect(hasGroupOwnerScope(exemplar)).toBe(false); + expect(hasGroupOwnerScope(classWide)).toBe(false); + }); + + it("reads only the owner dimension, whatever the curriculum scope holds", () => { + // The two dimensions are independent: a group id decides this guard on its own. + expect(hasGroupOwnerScope({ groupId: "3" })).toBe(true); + expect(hasGroupOwnerScope({ unit: "sas", groupId: "3" })).toBe(true); }); }); - describe("hasClassUnitScope", () => { - it("is true only for a document scoped to a unit and nothing narrower", () => { - expect(hasClassUnitScope(classWide)).toBe(true); - expect(hasClassUnitScope(legacyClassWide)).toBe(true); + describe("hasUnitCurriculumScope", () => { + it("is true for a document scoped to a unit and nothing narrower", () => { + expect(hasUnitCurriculumScope(classWide)).toBe(true); + expect(hasUnitCurriculumScope(legacyClassWide)).toBe(true); + }); + + it("is false for every other curriculum position", () => { + expect(hasUnitCurriculumScope(personal)).toBe(false); // no unit + expect(hasUnitCurriculumScope(problem)).toBe(false); // narrowed to an investigation + expect(hasUnitCurriculumScope(group)).toBe(false); // narrowed to an investigation + expect(hasUnitCurriculumScope(exemplar)).toBe(false); // narrowed to an investigation + }); + + it("is false for a document in an offering, which assigns one problem", () => { + // An offering narrows the curriculum dimension on its own, whatever the other fields hold. + expect(hasUnitCurriculumScope({ unit: "sas", offeringId: "off-1" })).toBe(false); }); - it("is false for every other document shape", () => { - expect(hasClassUnitScope(personal)).toBe(false); // no unit - expect(hasClassUnitScope(problem)).toBe(false); // has an investigation - expect(hasClassUnitScope(group)).toBe(false); // has an investigation and a group - expect(hasClassUnitScope(exemplar)).toBe(false); // has an investigation + it("reads only the curriculum dimension, whatever the owner scope holds", () => { + // A group id does not narrow curriculum scope, so it cannot decide this guard. No kind creates + // this shape today; the guard answers about its own dimension regardless. + expect(hasUnitCurriculumScope({ unit: "sas", groupId: "3" })).toBe(true); }); it("treats an empty-string unit as no unit", () => { - expect(hasClassUnitScope({ unit: "", investigation: null, groupId: null })).toBe(false); + expect(hasUnitCurriculumScope({ unit: "", investigation: null, groupId: null })).toBe(false); }); }); diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts index 8434e10351..900eaed117 100644 --- a/src/models/document/document-scope.ts +++ b/src/models/document/document-scope.ts @@ -1,16 +1,14 @@ /** - * Guards over a document's stored scope association fields. - * - * A document's scope lives in its association fields (`context_id`, `unit`, `investigation`, + * Guards over a document's stored scope association fields (`context_id`, `unit`, `investigation`, * `problem`, `offeringId`, `groupId`), stamped at creation from the kind's registered `scopeType` - * (see document-kinds.ts). Consumers that need a document's scope read it through these guards - * rather than branching on `type` or looking `kind` up in the registry: a document listed in Sort - * Work may belong to another unit whose kind is not registered in the current session, but its - * stored fields are always present. + * (see document-kinds.ts). + * + * Scope has two dimensions — curriculum and owner — and each guard answers about one of them, reading + * only that dimension's fields. A consumer needing a position on both asks both. See + * docs/document-scope.md for the model, the field-by-shape table, and what is not covered yet. * - * These are narrow named predicates by design. Scope is multi-dimensional — a personal document is - * class+owner scoped while a class-wide document is class+unit scoped — so a single ordered - * "scope level" would be ambiguous. See docs/document-scope.md. + * A guard reads stored fields only, never the kind registry: Sort Work lists documents from other + * units, whose kinds are not registered in the current session. */ /** The scope fields the guards read. Structural, so this stays a leaf module. */ @@ -18,35 +16,33 @@ export interface IDocumentScopeFields { unit?: string | null; investigation?: string | null; problem?: string | null; + offeringId?: string | null; groupId?: string | null; } /** - * True when the document is scoped to a single group. + * Owner scope: the document belongs to a single group, whoever created it. * - * In Firestore metadata only group-scoped documents carry a `groupId`; other documents deliberately - * leave it unset so a stale group id can never be read back (see DocumentMetadataModel.groupId). + * Only group-scoped documents carry a `groupId`; others leave it unset so a stale group id can never + * be read back, since a user's group may change (see DocumentMetadataModel.groupId). */ -export function hasGroupScope(doc: IDocumentScopeFields): doc is IDocumentScopeFields & { groupId: string } { +export function hasGroupOwnerScope(doc: IDocumentScopeFields): doc is IDocumentScopeFields & { groupId: string } { return !!doc.groupId; } /** - * True when the document is scoped to a class and a unit and nothing narrower — a class-wide - * collaborative document. + * Curriculum scope: the document belongs to a unit and nothing narrower. * - * No other stored shape matches: class-scoped documents (personal, learning log) have `unit: null`; - * offering-scoped documents (problem, planning, publications) carry an `investigation`; group - * documents carry both an `investigation` and a `groupId`; curriculum exemplars carry a `unit` but - * also an `investigation`. + * Both negative terms narrow this same dimension — an `investigation` directly, an `offeringId` + * because an offering assigns one problem. It says nothing about who owns the document. */ -export function hasClassUnitScope(doc: IDocumentScopeFields): boolean { - return !!doc.unit && !doc.investigation && !doc.groupId; +export function hasUnitCurriculumScope(doc: IDocumentScopeFields): boolean { + return !!doc.unit && !doc.investigation && !doc.offeringId; } /** - * A short label for the curriculum a document belongs to: "sas-1.2" when it is scoped to a problem, - * "sas" when it is scoped to a unit and nothing narrower, undefined when it has no unit at all. + * A short label for a document's curriculum scope: "sas-1.2" when it is scoped to a problem, "sas" + * when it is scoped to a unit and nothing narrower, undefined when it has no unit at all. * * Callers use it as a stand-in when a document's real title cannot be resolved, so the coordinates * name the document instead. It reads the stored fields alone, so it describes a document from any diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index b0d8d611a4..18436c517b 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -10,7 +10,7 @@ import { AppConfigModelType } from "../stores/app-config-model"; import { UserModelType } from "../stores/user"; import { DocumentModelType, IExemplarVisibilityProvider } from "./document"; import { DocumentContentModelType } from "./document-content"; -import { getCurriculumScopeLabel, hasClassUnitScope } from "./document-scope"; +import { getCurriculumScopeLabel, hasGroupOwnerScope, hasUnitCurriculumScope } from "./document-scope"; import { getDocumentKindLabel, getDocumentTitle } from "./document-kinds"; import { GroupDocument, isExemplarType, isPlanningType, isProblemType, isPublishedType, isSupportType } from "./document-types"; @@ -192,8 +192,13 @@ export function canUserEditDocument({ if (!!uid && uid === user.id) return true; if (user.isResearcher) return false; if (!concurrent) return false; - if (hasClassUnitScope({ unit, investigation, groupId })) { + // Beyond this point the user must be inside the document's scope, asked at the narrowest level the + // document is scoped to. + if (hasGroupOwnerScope({ groupId })) { + return !!user.currentGroupId && groupId === user.currentGroupId; + } + if (hasUnitCurriculumScope({ unit, investigation })) { return !!contextId && contextId === user.classHash; } - return !!user.currentGroupId && groupId === user.currentGroupId; + return false; } diff --git a/src/models/stores/document-group.ts b/src/models/stores/document-group.ts index 79408466a5..bfd05b3d1c 100644 --- a/src/models/stores/document-group.ts +++ b/src/models/stores/document-group.ts @@ -8,7 +8,7 @@ import { } from "../../utilities/sort-document-utils"; import { upperWords } from "../../utilities/string-utils"; import { translate } from "../../utilities/translation/translate"; -import { hasClassUnitScope, hasGroupScope } from "../document/document-scope"; +import { hasGroupOwnerScope, hasUnitCurriculumScope } from "../document/document-scope"; import { IDocumentMetadataModel } from "../document/document-metadata-model"; import { getTileComponentInfo } from "../tiles/tile-component-info"; import { getTileContentInfo } from "../tiles/tile-content-info"; @@ -200,9 +200,9 @@ export class DocumentGroup { this.documents.forEach((doc) => { const { sectionLabel, sortKey } = (() => { // A document scoped to a group belongs to that group, whoever created it. - if (hasGroupScope(doc)) return groupSection(doc.groupId); - // A document scoped to the class and unit belongs to the class as a whole. - if (hasClassUnitScope(doc)) { + if (hasGroupOwnerScope(doc)) return groupSection(doc.groupId); + // A document that spans a whole unit is not one student's work; it belongs to the class. + if (hasUnitCurriculumScope(doc)) { return { sectionLabel: kWholeClassSectionLabel, sortKey: { scope: "class" } as GroupSectionSortKey }; } // Otherwise it belongs to its owner, and so to whichever group its owner is in now. @@ -237,15 +237,18 @@ export class DocumentGroup { }; this.documents.forEach((doc) => { - if (hasClassUnitScope(doc)) { - // A class-wide collaborative document belongs to the class, so it has no personal author. - addDocToSection(doc, kNoNameSectionLabel); - } else if (hasGroupScope(doc)) { + // Owner scope decides the section, so it is asked first, narrowest level outward. + if (hasGroupOwnerScope(doc)) { // A group document is listed under every member of the group that owns it. const group = this.stores.groups.getGroupById(doc.groupId); group?.users.forEach(user => { addDocForUser(doc, user.classUser); }); + } else if (hasUnitCurriculumScope(doc)) { + // A document spanning a whole unit is owned by the class, so it has no personal author. This + // reads curriculum scope to answer an owner-scope question: the class owner is a synthetic uid + // (`class_`), and no guard reads it yet. Once one does, ask that instead. + addDocToSection(doc, kNoNameSectionLabel); } else { addDocForUser(doc, this.stores.class.getUserById(doc.uid)); } From 63a16b606a9c7cd2fd1f51f9fa69bc0882e049b7 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Tue, 28 Jul 2026 22:37:27 -0400 Subject: [PATCH 19/51] docs: record what the owner axis has to support [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirements stated independently of how the owner is stored, so a change of representation can be checked against them. Each names where it is exercised. The Sort Work requirement is spelled out, since it constrains the representation most: both getters branch three ways on owner type, each branch resolves something different, and one of them emits a document once per group member. The type has to be readable from the document before any id is resolved. Two assumptions did not survive contact with the code: no Firestore query filters documents by owner type, and none filters by user owner. Sorting by owner is a client-side projection over documents already fetched by class, so the representation does not have to be queryable by type today. Also records a live limitation — a group owner resolves only against the current offering, so a group document from an earlier one is dropped from the by-name listing. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/axes.md | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/docs/document-axes/axes.md b/docs/document-axes/axes.md index a60f495071..3adb8847d1 100644 --- a/docs/document-axes/axes.md +++ b/docs/document-axes/axes.md @@ -39,6 +39,101 @@ Two tells that `owner` is its own thing: frozen copy the publisher can no longer edit, yet it still "belongs to" them for attribution and unpublish authority. Owner persists past write access. +#### What the owner axis has to support + +Stated independently of how the owner is stored, so a change of representation can be checked against +this list. Each requirement names where it is exercised today. + +**Assigning** + +1. **Resolve an owner at creation from the kind.** A kind declares whether its documents are owned by + the creating user, their group, or the class; creation turns that into a concrete owner + (`getDocumentOwner`, `document-kinds.ts`). + +**Authorizing** + +2. **Decide whether the authenticated user is the owner.** The rules compare a user-owned document's + owner against the JWT's `platform_user_id` — note, not `request.auth.uid` (`userOwnsDocument`, + `userIsRequestUser`, `userIsResourceUser` in `firestore.rules`). +3. **Distinguish "owned by no user" from "owned by a different user".** A group- or class-owned + document must never satisfy (2) for anybody. This is why concurrent documents need their own + history grant (`isConcurrentClassDocument`). It requires owners to be unique *across* types, not + only within one. + +**Addressing** + +4. **Locate a document's content by its owner.** RTDB stores content at + `classes//users//documents/`, so an owner must serialize to a single key-safe + path segment — RTDB keys exclude `.` `$` `#` `[` `]` `/` (`firebase.ts`). +5. **Rebuild the canonical-pointer path of a group-owned document.** Both the client and the rules + need the offering and the group as separate path segments + (`canonicalPointerPath` in `firestore.rules`, `getCanonicalPointerPath` in + `scoped-document-pointers.ts`). + +**Attributing and organizing** + +6. **Read a document's owner type directly off the document.** This is the requirement that most + constrains the representation, so it is worth stating concretely. + + Sort Work's `byGroup` and `byName` getters (`document-group.ts`) each walk the whole list of + documents the class has produced and must place *every* one of them into exactly one section. Both + branch three ways on the owner type, and each branch resolves a different thing: + + | owner type | `byGroup` section | `byName` section | + |---|---|---| + | group | `Group `, from the group the document belongs to | one entry under **each member** of that group | + | class | `Whole Class` — a fixed section | `No Name` — the class is not a person | + | user | the group its **owner currently belongs to**, or `No Group` | the owner's `Last, First` | + + Three things follow. The type selects *which lookup to perform* — a group registry lookup, no + lookup at all, or a class-user lookup — so it has to be known before any id is resolved. A + user-owned document is placed by an indirect route (its owner's current group, which can change), + so the owner type also decides whether the document's own association or its owner's present state + supplies the answer. And a group-owned document is emitted **several times**, once per member, + while the other two are emitted once — so the type changes the shape of the output, not just its + label. + + The view is iterating documents it did not choose and cannot anticipate, including documents from + other units and offerings. It needs the owner type as a value it can read from each document, the + same way it reads a title. +7. **Resolve a group owner to its group** — the members, and the "Group N" label. Needs the group + identifier on its own, and resolves against the **current offering's** group registry. +8. **Resolve a user owner to a class user** — for the "Last, First" section label. +9. **Decide whether the current user is inside a group owner** — the edit gate. Must be + offering-qualified: comparing bare group ids across offerings matches different cohorts + (`canUserEditDocument`). + +**Locating** + +10. **Find a group's pre-canonical document.** `findLegacyGroupDocument` filters on class, offering, + and group. This is the only Firestore query on an owner field, and it is transitional — it retires + with the canonical-pointer migration. + +#### Not required today + +**No Firestore query filters documents by owner type.** "Every document owned by a group in this +class" and "every document owned by the class" are *grouping* operations, not queries: Sort Work +fetches by `context_id` and projects client-side. Nor is there a query by user owner. (The one +`where("uid", …)` elsewhere in the tree, in `on-user-doc-written.ts`, filters the `users` collection — +those are user records, not document owners.) + +Worth stating explicitly, because it means the owner representation does **not** have to be queryable +by type today. If that changes, it becomes the strongest argument for a stored discriminant. + +#### Possible future requirements + +- **Query by owner type** — a view that fetches only class-owned documents, or trims payload by owner. + Client-side projection would not suffice. +- **More owner types** — a school, a network, a teacher cohort. +- **Group-level authorization in the rules.** A group document's history is currently authorized + class-wide because the auth token carries no group id (noted at `isConcurrentClassDocument`). If + tokens ever carried one, the rules would have to recover the group from the owner. +- **Owner transfer.** Because permissions are computed *from* the owner, reassigning it changes who + may act; it would need a privileged path rather than an ordinary update. +- **Resolving a group owner outside the current offering.** Requirement 7 resolves only against the + current offering, so a group-owned document from an earlier offering — visible under Sort Work's + "All" filter — cannot render its members. `byName` drops such a document from the listing entirely. + ### `scope` — where the document is attached **What it is.** The document's position in the org hierarchy (network / class / offering / group / user) From 07f1835956994a2a00c87f604f910f2f920eaef7 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 06:06:32 -0400 Subject: [PATCH 20/51] fix: decide group membership by owner, not by group id [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit gate read `documentMetadata?.groupId ?? document?.groupId`, but those two fields carry different meanings. On a document model, `groupId` is the author's current group, refreshed as groups change; only on the Firestore metadata is it the group that owns the document. The fallback could therefore answer an ownership question with an unrelated value. It also compared bare group ids. Groups live under an offering, so the same group number in another offering is a different set of students — and Sort Work's "All" filter lists documents from every offering the class has worked through, so those documents do reach the check. Compare owners instead. A group document's owner carries its offering, which makes the comparison exact and removes the ambiguous read entirely: the predicate no longer looks at `groupId` at all. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/document/document-utils.test.ts | 51 ++++++++++++++++++---- src/models/document/document-utils.ts | 25 ++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/models/document/document-utils.test.ts b/src/models/document/document-utils.test.ts index bb47a81b09..609cd34864 100644 --- a/src/models/document/document-utils.test.ts +++ b/src/models/document/document-utils.test.ts @@ -246,8 +246,12 @@ describe("document utils", () => { describe("canUserEditDocument", () => { const student = UserModel.create({ id: "me", type: "student", name: "Me", classHash: "class-1" }); + const kOffering = "off-1"; + // A group document's owner, the same synthetic id the app stamps at creation. + const groupOwner = (groupId: string, offeringId = kOffering) => `group_${offeringId}_${groupId}`; const groupedStudent = UserModel.create({ - id: "me", type: "student", name: "Me", classHash: "class-1", currentGroupId: "3" + id: "me", type: "student", name: "Me", classHash: "class-1", + currentGroupId: "3", offeringId: kOffering }); const teacher = UserModel.create({ id: "t1", type: "teacher", name: "Teacher", classHash: "class-1" }); const researcher = UserModel.create({ id: "r1", type: "researcher", name: "Researcher", classHash: "class-1" }); @@ -269,21 +273,39 @@ describe("document utils", () => { it("allows a member of the owning group to edit a group document", () => { expect(canUserEditDocument({ - documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + documentMetadata: metadata({ + uid: groupOwner("3"), concurrent: true, groupId: "3", unit: "sas", investigation: "1" + }), user: groupedStudent })).toBe(true); }); it("refuses another group's document", () => { expect(canUserEditDocument({ - documentMetadata: metadata({ concurrent: true, groupId: "7", unit: "sas", investigation: "1" }), + documentMetadata: metadata({ + uid: groupOwner("7"), concurrent: true, groupId: "7", unit: "sas", investigation: "1" + }), + user: groupedStudent + })).toBe(false); + }); + + it("refuses the same group number in a different offering — a different set of students", () => { + // Sort Work's "All" filter lists documents from every offering the class has worked through, + // so this document does reach the check. Group ids are unique only within an offering. + expect(canUserEditDocument({ + documentMetadata: metadata({ + uid: groupOwner("3", "other-offering"), concurrent: true, groupId: "3", + unit: "sas", investigation: "1" + }), user: groupedStudent })).toBe(false); }); it("refuses a group document when the user is not in a group", () => { expect(canUserEditDocument({ - documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + documentMetadata: metadata({ + uid: groupOwner("3"), concurrent: true, groupId: "3", unit: "sas", investigation: "1" + }), user: student })).toBe(false); }); @@ -355,20 +377,33 @@ describe("document utils", () => { })).toBe(false); }); - it("prefers the reactive metadata's group id over a still-loading document", () => { + it("prefers the reactive metadata's owner over a still-loading document", () => { // A groupmate's document syncs into the metadata before its content finishes loading; reading // the metadata per field is what makes the Edit button appear without a reload. const stillLoading = createDocumentModel({ - uid: "someone-else", type: GroupDocument, key: "k", concurrent: true + uid: "", type: GroupDocument, key: "k", concurrent: true }); - expect(stillLoading.groupId).toBeUndefined(); expect(canUserEditDocument({ document: stillLoading, - documentMetadata: metadata({ concurrent: true, groupId: "3", unit: "sas", investigation: "1" }), + documentMetadata: metadata({ + uid: groupOwner("3"), concurrent: true, groupId: "3", unit: "sas", investigation: "1" + }), user: groupedStudent })).toBe(true); }); + it("does not treat a document model's groupId as evidence of group ownership", () => { + // On a problem document, DocumentModel.groupId is the author's *current* group, refreshed as + // groups change (db-docs-content-listener) — not the group that owns the document. Only the + // owner decides ownership, so a matching groupId must not grant an edit on its own. + const authoredByAGroupmate = createDocumentModel({ + uid: "someone-else", type: ProblemDocument, key: "k", concurrent: true, + groupId: "3", unit: "sas", investigation: "1" + }); + expect(authoredByAGroupmate.groupId).toBe("3"); + expect(canUserEditDocument({ document: authoredByAGroupmate, user: groupedStudent })).toBe(false); + }); + it("allows a user to edit their own document via the document-only path (no metadata)", () => { const ownDocument = createDocumentModel({ uid: "me", type: ProblemDocument, key: "k" }); expect(canUserEditDocument({ document: ownDocument, user: student })).toBe(true); diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index 18436c517b..3441caece0 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -10,7 +10,7 @@ import { AppConfigModelType } from "../stores/app-config-model"; import { UserModelType } from "../stores/user"; import { DocumentModelType, IExemplarVisibilityProvider } from "./document"; import { DocumentContentModelType } from "./document-content"; -import { getCurriculumScopeLabel, hasGroupOwnerScope, hasUnitCurriculumScope } from "./document-scope"; +import { getCurriculumScopeLabel, hasUnitCurriculumScope } from "./document-scope"; import { getDocumentKindLabel, getDocumentTitle } from "./document-kinds"; import { GroupDocument, isExemplarType, isPlanningType, isProblemType, isPublishedType, isSupportType } from "./document-types"; @@ -183,7 +183,6 @@ export function canUserEditDocument({ const uid = documentMetadata?.uid ?? document?.uid; const type = documentMetadata?.type ?? document?.type; const concurrent = documentMetadata?.concurrent ?? document?.concurrent; - const groupId = documentMetadata?.groupId ?? document?.groupId; const unit = documentMetadata?.unit ?? document?.unit; const investigation = documentMetadata?.investigation ?? document?.investigation; const contextId = documentMetadata?.context_id ?? document?.contextId; @@ -194,11 +193,27 @@ export function canUserEditDocument({ if (!concurrent) return false; // Beyond this point the user must be inside the document's scope, asked at the narrowest level the // document is scoped to. - if (hasGroupOwnerScope({ groupId })) { - return !!user.currentGroupId && groupId === user.currentGroupId; - } + if (isUserInDocumentsGroup(uid, user)) return true; if (hasUnitCurriculumScope({ unit, investigation })) { return !!contextId && contextId === user.classHash; } return false; } + +/** + * Whether the user belongs to the group that owns this document. + * + * Compares owners rather than group ids. A group id is unique only within an offering — groups live + * at `offerings//groups` — so the same group number in another offering is a different + * set of students. The owner (`group__`) carries the offering, which makes the + * comparison exact; Sort Work's "All" filter lists documents from every offering the class has + * worked through, so documents from another offering do reach this check. + * + * It also avoids reading `groupId` off a document model, where the field carries a different meaning: + * for a problem document it is the author's *current* group, refreshed as groups change, rather than + * the group that owns the document. + */ +function isUserInDocumentsGroup(uid: string | null | undefined, user: UserModelType): boolean { + if (!uid || !user.currentGroupId || !user.offeringId) return false; + return uid === user.userIdForGroupDocuments; +} From c04b36a211bfd0a16a99f566d02bc3a2759ce81e Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 12:59:48 -0400 Subject: [PATCH 21/51] docs: split the scope axis into container and curriculum [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope was carrying two independent facts: where a document is kept, and what content it is about. Bundling them is the same defect the axes work exists to undo, so they become separate axes. `container` is a strict nesting — class, classUnit, offering — and a document sits at exactly one node. Neither the user nor the group is a level: a container has to outlast what it holds, and group membership changes within an assignment and differs between them. Whose a document is belongs to `owner`. `curriculum` is what the document is about: nothing, unit, investigation, problem. The two usually agree, and exemplars are where they come apart — an exemplar is about one problem but belongs to no assignment, since it exists whether or not the class was ever assigned that problem. Also records that a canonical slot is a container plus an owner plus a label, and that class audiences are named by the container while group audiences come from the owner. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/axes.md | 152 +++++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 64 deletions(-) diff --git a/docs/document-axes/axes.md b/docs/document-axes/axes.md index 3adb8847d1..a66acb4d1b 100644 --- a/docs/document-axes/axes.md +++ b/docs/document-axes/axes.md @@ -7,9 +7,9 @@ readable by their teacher, shareable to their group*. Every one of those clauses decision, and different types make them differently. This document names those decisions as **axes**. Instead of asking "what type is this document?", we -describe a document by **where it sits on each axis** — who owns it, where it is scoped, whether it is -the canonical doc for its slot, whether it is multi-writer, who may do what to it. The type becomes -just one of those axes (`kind`), not the thing everything hangs off. +describe a document by **where it sits on each axis** — who owns it, where it is kept, what content it +is about, whether it is the canonical doc for its slot, whether it is multi-writer, who may do what to +it. The type becomes just one of those axes (`kind`), not the thing everything hangs off. **These axes are "virtual" today.** The current code does not store most of them as fields. But its *behavior* already fixes a value for every axis on every document — the four-up share toggle, the @@ -22,7 +22,7 @@ refactoring tracked in this folder then makes the axes explicit; see > evidence (per code site) that backs it lives in the findings doc on the `document-type-decomposition` > branch. -## The six axes +## The seven axes ### `owner` — authoring identity and provenance @@ -134,48 +134,68 @@ by type today. If that changes, it becomes the strongest argument for a stored d current offering, so a group-owned document from an earlier offering — visible under Sort Work's "All" filter — cannot render its members. `byName` drops such a document from the listing entirely. -### `scope` — where the document is attached - -**What it is.** The document's position in the org hierarchy (network / class / offering / group / user) -and the curriculum hierarchy (unit / problem / section). A document attaches to whichever of these -apply; the familiar labels ("this user's doc in this offering") are just names for which associations -are set. - -**In today's behavior.** Scope is visible in *where CLUE looks for a document* and *when it applies*: -- A **problem** or **planning** document is scoped to *one user in one offering* — you get a fresh one - per assignment, and it does not follow you to a different problem. -- A **personal** document or **learning log** is scoped to *the user in the class*, with no offering — - which is why it is available across problems and used to carry notes between them. -- A **group** document is scoped to *the group in the offering* (no single user). -- A **publication** broadens scope to the whole offering or class while its `owner` stays the - publisher — `owner` and `scope` diverging is the signature of publishing. - -**Scope is two dimensions, not one level** — curriculum (unit → investigation → problem) and owner -(class → group → user) — with the offering crossing both rather than sitting on either. A personal -document is user-owned with no curriculum scope; a class-wide document is class-owned with unit -curriculum scope. Neither is "more scoped" than the other, which is why scope cannot collapse to a -single ordered `scopeLevel`. The model, the guards that read it, and where each stored shape sits are -in [../document-scope.md](../document-scope.md). - -One consequence is worth noting here: the creation side names *combinations* rather than dimensions. -A kind's registered `scopeType` (`class`, `classUnit`, `offering`, `group`) is shorthand for a -pairing, and `offering` names the crossing point directly. That suits a creation preset, which must -fix both dimensions at once, but it is not the vocabulary read-side consumers should use. - -**Scope also defines `canonical` slots.** A canonical slot *is* a scope that at most one document is -expected to fill — "the problem doc for this user in this offering", "the group doc for this group" -(see `canonical`). - -**Scope also feeds `permissions`.** Scope is not purely positional. Its **class** and **group** associations -double as *permission principals*: "readable by the class" means readable by whoever shares the document's -class scope, and "readable by the group" means the members of its group scope. So when a publication -opens to the class, or a group document is read and written by its members, the audience is being named -*by scope*. +### `container` — where the document is kept + +**What it is.** The place a document belongs to, in a strict nesting: **class → classUnit → +offering**. A classUnit is one class working through one unit. An offering is one assignment of one +problem to that class, and every offering falls inside exactly one classUnit. Each document sits at +exactly one of these, and never moves. + +**In today's behavior.** The container is visible in *how long a document stays with you*: +- A **personal** document or **learning log** is kept by the class. It is available no matter which + unit or assignment you are working on, which is why it is used to carry notes between problems. +- A **class-wide collaborative** document (the driving question board) is kept by the class *and* the + unit — one per class per unit. Move to another unit and you get a different one. +- A **problem** or **planning** document is kept by the assignment. You get a fresh one per + assignment, and it does not follow you to a different problem. +- A **group** document is kept by the assignment too — its group-ness is *whose* it is, not where it + is kept. + +**Neither the user nor the group is a level here.** It is tempting to continue the nesting downward, +since students are in groups and groups are in assignments. But a container has to be stable for as +long as the things in it: group membership changes during an assignment and differs between +assignments, and a single student's documents are kept at several different levels rather than under +one place of their own. *Whose* a document is belongs to `owner`. + +**The container defines `canonical` slots.** A canonical slot is a container plus an owner plus a +label — "the group document for this group in this assignment", "the driving question board for this +class in this unit" (see `canonical`). + +**The container names permission principals.** "Readable by the class" means readable by whoever +shares the document's class. So when a publication opens to the class, the audience is being named by +where the document is kept. (Group-based audiences work the same way, but the group comes from +`owner` rather than from the container.) + +### `curriculum` — what content the document is about + +**What it is.** Where the document sits in the curriculum: **nothing → unit → investigation → +problem**. This is what the document is *about*, not where it is kept. + +**In today's behavior.** The curriculum position is visible in *when a document is offered to you*: +- A **personal** document or **learning log** has no curriculum position at all — it is not about any + particular content. +- A **class-wide collaborative** document is about a whole unit. +- A **problem**, **planning**, or **group** document is about one problem. +- An **exemplar** is about one problem — it is authored into the curriculum alongside that problem. + +It is also what the Sort Work filters match on: choosing an investigation or a problem narrows the +list to documents about that part of the curriculum. + +**Why this is separate from `container`.** For most documents the two line up — kept by the class and +about nothing, kept by a classUnit and about that unit, kept by an assignment and about that problem. +Exemplars are the case that comes apart: an exemplar is about a specific problem, but it is not part +of any assignment. It exists whether or not the class has ever been assigned that problem, so there is +no assignment to keep it in. A document can be about a problem without being kept in that problem's +assignment. + +A **publication** shows the same independence from the other direction: publishing broadens who can +see a document without changing what it is about, while its `owner` stays the publisher — `owner`, +`container`, and `curriculum` moving separately is the signature of publishing. ### `canonical` — the single doc for a slot -**What it is.** Whether this is *the* one document expected to fill a given **scope** slot, as opposed -to one of a growing collection. +**What it is.** Whether this is *the* one document expected to fill a given **container** slot, as +opposed to one of a growing collection. **In today's behavior.** Some documents are singletons, some are collections: - A user is meant to have **exactly one** problem document per offering (and a teacher one planning @@ -215,7 +235,8 @@ type and to reason about its axes instead — so it is fair to ask why a type-sh all. It survives because a few things are genuinely *per-preset* and cannot be read off how a document behaves: - **Creation** — when a new document is made, something has to choose its starting axis values. "A new - problem is owned by its creator, scoped to this offering, canonical, single-writer, teacher-readable" + problem is owned by its creator, kept by this assignment, about that problem, canonical, + single-writer, teacher-readable" is a recipe belonging to a preset; the axes describe the result but cannot supply it. - **Presentation** — the label a document is shown under, its title bar, its icons and styling are chosen per preset, not consequences of its axis values. @@ -267,8 +288,8 @@ kind's definition*: 1. **Anything a dynamic kind supplies must be stamped at creation or degrade gracefully.** Values the definition contributes to a document's axes are written onto the document, so they survive the definition's absence. Anything not stamped — presentation, above all — must have a fallback derived from - stored fields alone. This is the same reason consumers read scope through guards over stored fields - rather than through the registry. + stored fields alone. This is the same reason consumers read a document's container and curriculum + from its stored associations rather than through the registry. 2. **A dynamic kind's documents must carry the association that identifies the configuration that defined them.** For unit-declared kinds that association is `unit`. Kind names are not globally unique across configurations — two units may declare the same kind with different wording — so without it there is no @@ -276,8 +297,8 @@ kind's definition*: wrong definition would be applied confidently. **This bounds which documents a dynamic kind can create.** A unit-declared kind can only produce documents -scoped to that unit or narrower, because rule 2 requires the `unit` association. It cannot produce -class-scoped documents — the ones with no unit at all, like personal documents and learning logs. Making +kept by that unit or narrower, because rule 2 requires the `unit` association. It cannot produce +documents kept by the class — the ones with no unit at all, like personal documents and learning logs. Making *those* presets authorable is a reasonable future goal, but it is not a matter of adding entries to a unit config: it needs a configuration source loaded independently of the current unit (class- or site-level), so that a definition is present wherever its documents are, along with an association on the document naming @@ -300,9 +321,9 @@ features: - A **multi-class support** grants read to a structured target audience across classes. - An **exemplar** grants read per student. -Several of those audiences are named *by `scope`*: "the class can read" and "group members can -read/write" resolve through the document's class and group associations. `permissions` supplies the *verbs* -(read / write / publish / copy) and the per-document toggles; `scope` supplies *which* class or group +Several of those audiences are named elsewhere: "the class can read" resolves through the document's +`container`, and "group members can read/write" through its `owner`. `permissions` supplies the *verbs* +(read / write / publish / copy) and the per-document toggles; the other axes supply *which* class or group those grants point at. Because `permissions` blends kind-defaults with a few stored per-document grants, it is the axis that @@ -325,21 +346,24 @@ axes are already present, but *not* the definition. The point of this doc is the just where today's behavior happens to have placed things. `permissions` is collapsed to a short label because its real value (a composed grant set) does not fit a cell. -| kind (`type`) | owner | scope | canonical | concurrent | permissions (shorthand) | -|---|---|---|---|---|---| -| `problem` | student/teacher | user-in-offering | yes (by convention) | no | owner + teacher read; group-read when shared | -| `planning` | teacher | user-in-offering | yes (by convention) | no | owner + teacher read | -| `personal` | student/teacher | user-in-class | no (collection) | no | owner + teacher read; class-read when public | -| `learningLog` | student/teacher | user-in-class | no (collection) | no | owner + teacher read; class-read when public | -| `group` | none (group user) | group-in-offering | yes (pointer) | **yes** | all group members read/write | -| `problem` publication | publisher (retained) | offering | no, versioned | no | class read; frozen | -| `personal`/`learningLog` publication | publisher (retained) | class | no, versioned | no | class read; frozen | -| `support` (multi-class) | teacher (retained) | multi-class / offering | no | no | target audience read; frozen | -| `exemplar` | synthetic author | class-less, curriculum-rooted | no | no | per-student read | +| kind (`type`) | owner | container | curriculum | canonical | concurrent | permissions (shorthand) | +|---|---|---|---|---|---|---| +| `problem` | student/teacher | offering | problem | yes (by convention) | no | owner + teacher read; group-read when shared | +| `planning` | teacher | offering | problem | yes (by convention) | no | owner + teacher read | +| `personal` | student/teacher | class | none | no (collection) | no | owner + teacher read; class-read when public | +| `learningLog` | student/teacher | class | none | no (collection) | no | owner + teacher read; class-read when public | +| `group` | the group | offering | problem | yes (pointer) | **yes** | all group members read/write | +| class-wide collaborative | the class | classUnit | unit | yes (pointer) | **yes** | all class members read/write | +| `problem` publication | publisher (retained) | offering | problem | no, versioned | no | class read; frozen | +| `personal`/`learningLog` publication | publisher (retained) | class | none | no, versioned | no | class read; frozen | +| `support` (multi-class) | teacher (retained) | multi-class / offering | problem | no | no | target audience read; frozen | +| `exemplar` | synthetic author | none until commented on | problem | no | no | per-student read | Reading the table the new way: a "group document" is not a special *kind of thing* — it is simply the -document that happens to be *ownerless, group-scoped, canonical, concurrent, and group-read/write*. Any -other document that took those same axis values would behave the same way. That is the shift this +document that happens to be *group-owned, kept by an assignment, about that problem, canonical, +concurrent, and group-read/write*. Any other document that took those same axis values would behave +the same way. The class-wide collaborative document is the demonstration: it differs from a group +document on `owner`, `container`, and `curriculum` alone, and behaves accordingly. That is the shift this folder is built around. ## Relationship to the other docs here From 98f065e4b6dfdb859e53459ef8e9554bef061f1c Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 13:14:33 -0400 Subject: [PATCH 22/51] docs: restate the owner requirements as behavior, not implementation [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list read as a tour of the code. Rewrite it so each entry says what CLUE has to be able to do, and mark the exceptions rather than blurring them: a few entries record a constraint the current implementation imposes, and each says what it would take to lift. Moves the edit gate into Authorizing, where it belongs, and states the requirement it was missing — the check has to be specific to one offering, because group numbers repeat across them. Adds a Broken behavior section: a group owner resolves only against the current offering, so a group document from an earlier one is dropped from the by-name listing. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/axes.md | 117 +++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/docs/document-axes/axes.md b/docs/document-axes/axes.md index a66acb4d1b..f3ee593a6e 100644 --- a/docs/document-axes/axes.md +++ b/docs/document-axes/axes.md @@ -42,97 +42,102 @@ Two tells that `owner` is its own thing: #### What the owner axis has to support Stated independently of how the owner is stored, so a change of representation can be checked against -this list. Each requirement names where it is exercised today. +this list. Most entries are behavior CLUE has to support however it is built. A few instead record a +constraint the current implementation imposes; those are marked *Note*, and each says what it would +take to lift it — they bound today's choices without being requirements in their own right. **Assigning** 1. **Resolve an owner at creation from the kind.** A kind declares whether its documents are owned by - the creating user, their group, or the class; creation turns that into a concrete owner - (`getDocumentOwner`, `document-kinds.ts`). + the creating user, their group, or the class; creation turns that declaration into a concrete owner. **Authorizing** -2. **Decide whether the authenticated user is the owner.** The rules compare a user-owned document's - owner against the JWT's `platform_user_id` — note, not `request.auth.uid` (`userOwnsDocument`, - `userIsRequestUser`, `userIsResourceUser` in `firestore.rules`). -3. **Distinguish "owned by no user" from "owned by a different user".** A group- or class-owned - document must never satisfy (2) for anybody. This is why concurrent documents need their own - history grant (`isConcurrentClassDocument`). It requires owners to be unique *across* types, not - only within one. +2. **Decide whether the authenticated user is the owner.** The Firestore rules compare a user-owned + document's owner against the JWT's `platform_user_id`. +3. **Distinguish "owned by no user" from "owned by a different user".** A group or class owned + document must never satisfy (2) for anybody. In other words the `platform_user_id` check can't match a + group or class owned document. Put another way, this is one of the reasons the combination of owner type and owner id + needs to be unique within the authentication domain. *A side note*: Because we can't tell directly if a user + can edit a group or class owned document, in the Firestore rules concurrent documents need their own + check to see if a user can add a history entry. +4. **Decide whether the current user belongs to the group that owns a document** — the gate on editing + a group document. Group ids repeat across offerings, so the same number names a different set of + students in each; this check has to be specific to one offering, which means the owner must carry + enough to identify the offering. Documents from other offerings do reach this check, because Sort + Work's "All" filter lists everything the class has produced. **Addressing** -4. **Locate a document's content by its owner.** RTDB stores content at +5. **Locate a document's content by its owner.** RTDB stores content at `classes//users//documents/`, so an owner must serialize to a single key-safe - path segment — RTDB keys exclude `.` `$` `#` `[` `]` `/` (`firebase.ts`). -5. **Rebuild the canonical-pointer path of a group-owned document.** Both the client and the rules - need the offering and the group as separate path segments - (`canonicalPointerPath` in `firestore.rules`, `getCanonicalPointerPath` in - `scoped-document-pointers.ts`). + path segment — RTDB keys exclude `.` `$` `#` `[` `]` `/`. *Note*: this is an implementation detail, + so we could decide to change it, but that would require a large migration. +6. **Rebuild the canonical-pointer path of a group-owned document.** Both the client and the rules + need the offering and the group as separate path segments. *Note*: this is also an implementation + detail. It is a new feature (2026-07), so could be revised. **Attributing and organizing** -6. **Read a document's owner type directly off the document.** This is the requirement that most +7. **Read a document's owner type directly off the document.** This is the requirement that most constrains the representation, so it is worth stating concretely. - Sort Work's `byGroup` and `byName` getters (`document-group.ts`) each walk the whole list of - documents the class has produced and must place *every* one of them into exactly one section. Both - branch three ways on the owner type, and each branch resolves a different thing: + The Sort Work tab can section documents by group or by name. Each view walks the whole list of + documents the class has produced and must place *every* one of them. Both branch three ways on the + owner type, and each branch resolves something different: - | owner type | `byGroup` section | `byName` section | + | owner type | sectioned by group | sectioned by name | |---|---|---| - | group | `Group `, from the group the document belongs to | one entry under **each member** of that group | + | group | `Group ` — the document's group | one entry under **each member** of that group | | class | `Whole Class` — a fixed section | `No Name` — the class is not a person | - | user | the group its **owner currently belongs to**, or `No Group` | the owner's `Last, First` | - - Three things follow. The type selects *which lookup to perform* — a group registry lookup, no - lookup at all, or a class-user lookup — so it has to be known before any id is resolved. A - user-owned document is placed by an indirect route (its owner's current group, which can change), - so the owner type also decides whether the document's own association or its owner's present state - supplies the answer. And a group-owned document is emitted **several times**, once per member, - while the other two are emitted once — so the type changes the shape of the output, not just its - label. - - The view is iterating documents it did not choose and cannot anticipate, including documents from - other units and offerings. It needs the owner type as a value it can read from each document, the - same way it reads a title. -7. **Resolve a group owner to its group** — the members, and the "Group N" label. Needs the group - identifier on its own, and resolves against the **current offering's** group registry. -8. **Resolve a user owner to a class user** — for the "Last, First" section label. -9. **Decide whether the current user is inside a group owner** — the edit gate. Must be - offering-qualified: comparing bare group ids across offerings matches different cohorts - (`canUserEditDocument`). + | user | `Group ` — whichever group its owner belongs to *now* — or `No Group` | the owner's `Last, First` | + + Three things follow. The owner type selects *which lookup to perform* — a group registry lookup, a + name lookup in the class, or no lookup at all — so it has to be known before any id is resolved. A + user-owned document is placed indirectly, by its owner's current group rather than by anything the + document itself records, so the owner type also decides whether the document or its owner's present + state supplies the answer. And when sectioning by name, a group-owned document is emitted **once per + member** while the other two are emitted once — so the owner type changes the shape of the output, + not just the label. + + The view iterates documents it did not choose and cannot anticipate, including documents from other + units and offerings. It needs the owner type as a value it can read from each document, the same way + it reads a title. +8. **Resolve a group owner to its group** — the members, and the "Group N" label. The members come from + a group registry maintained for the **current offering**. +9. **Resolve a user owner to their name in the class** — for the "Last, First" section label. **Locating** -10. **Find a group's pre-canonical document.** `findLegacyGroupDocument` filters on class, offering, - and group. This is the only Firestore query on an owner field, and it is transitional — it retires - with the canonical-pointer migration. +10. **Find a group's document from before canonical pointers existed**, by class, offering, and group. + This is the only query anywhere that filters on an owner, and it is transitional — it retires with + the canonical-pointer migration. #### Not required today -**No Firestore query filters documents by owner type.** "Every document owned by a group in this -class" and "every document owned by the class" are *grouping* operations, not queries: Sort Work -fetches by `context_id` and projects client-side. Nor is there a query by user owner. (The one -`where("uid", …)` elsewhere in the tree, in `on-user-doc-written.ts`, filters the `users` collection — -those are user records, not document owners.) +**No query filters documents by owner.** Sort Work fetches by class and by curriculum position (unit, +investigation, problem), then sections what it receives. Neither the owner type nor the owner id is +ever a query term, apart from the transitional case above. Worth stating explicitly, because it means the owner representation does **not** have to be queryable -by type today. If that changes, it becomes the strongest argument for a stored discriminant. +by type today. If that changes, it becomes the strongest argument for a stored owner type. + +#### Broken behavior + +- **Resolving a group owner outside the current offering.** Requirement 8 resolves only against the + current offering, so a group-owned document from an earlier offering — visible under Sort Work's + "All" filter — does not know its members. Sectioning by name drops such a document entirely. #### Possible future requirements - **Query by owner type** — a view that fetches only class-owned documents, or trims payload by owner. - Client-side projection would not suffice. + This would be needed if the current client-side approach isn't sufficient. - **More owner types** — a school, a network, a teacher cohort. - **Group-level authorization in the rules.** A group document's history is currently authorized - class-wide because the auth token carries no group id (noted at `isConcurrentClassDocument`). If - tokens ever carried one, the rules would have to recover the group from the owner. + class-wide because the auth token carries no group id. If tokens ever carried one, the rules would + have to recover the group from the owner. - **Owner transfer.** Because permissions are computed *from* the owner, reassigning it changes who may act; it would need a privileged path rather than an ordinary update. -- **Resolving a group owner outside the current offering.** Requirement 7 resolves only against the - current offering, so a group-owned document from an earlier offering — visible under Sort Work's - "All" filter — cannot render its members. `byName` drops such a document from the listing entirely. ### `container` — where the document is kept From e61a62752e94ab7f0019c4434a71cee2c7017276 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 13:28:40 -0400 Subject: [PATCH 23/51] docs: move the axes reading notes out of document-scope.md [CLUE-610] document-scope.md is about a different meaning of scope: how code at the tile level reaches things at the document level, surveyed as tree traversal, MST environment, React context, tile props, and the tile API. Sections about where a document sits on the axes were filed there because the word matched, not the meaning, leaving a reader looking for either topic to find the other. Restores that file to what it was, and moves the axes material to a new current-state doc alongside axes.md and target-architecture.md: the guards that exist, the fields behind them, what each stored shape looks like, and what has no guard yet. Records that the helper names still say "scope" from before that axis was split. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/README.md | 4 +- docs/document-axes/reading-axes-in-code.md | 79 ++++++++++++++++++++ docs/document-scope.md | 86 ---------------------- src/models/document/document-scope.ts | 6 +- 4 files changed, 84 insertions(+), 91 deletions(-) create mode 100644 docs/document-axes/reading-axes-in-code.md diff --git a/docs/document-axes/README.md b/docs/document-axes/README.md index b432c5a982..8dec55f1ac 100644 --- a/docs/document-axes/README.md +++ b/docs/document-axes/README.md @@ -8,13 +8,13 @@ branch. - **Concepts — what the axes are, read out of current CLUE behavior:** [axes.md](./axes.md) - **Target — how the axes live in code (layers and boundaries):** [target-architecture.md](./target-architecture.md) +- **Current state — what a consumer can read off a document today:** [reading-axes-in-code.md](./reading-axes-in-code.md) - **Research background (current-state evidence):** the findings doc, on the `document-type-decomposition` branch (~49KB; left there rather than imported). ### Related existing docs this roadmap evolves toward - [../document-types.md](../document-types.md) — the current `type` catalog these axes decompose. -- [../document-scope.md](../document-scope.md) — the current scoping model the `scope` axis formalizes. - [../group-docs/README.md](../group-docs/README.md) — the group-document feature; its concurrency behavior is the first thing rebased onto the `concurrent` axis. @@ -60,4 +60,4 @@ type, a unit-scoped listener keeps them visible under the investigation and prob presentation reads `concurrent` and the kind registry, and one predicate (`canUserEditDocument`) gates every Edit button. It also settles the deferred scope-modeling question: consumers read narrow named guards over the stored association fields, with no `scopeLevel` enum and no unified `scope` -struct (see docs/document-scope.md). +struct (see [reading-axes-in-code.md](./reading-axes-in-code.md)). diff --git a/docs/document-axes/reading-axes-in-code.md b/docs/document-axes/reading-axes-in-code.md new file mode 100644 index 0000000000..df8cf927ac --- /dev/null +++ b/docs/document-axes/reading-axes-in-code.md @@ -0,0 +1,79 @@ +# Reading the axes in code, today + +> **Purpose:** what a consumer can actually read off a document right now, and how. [axes.md](./axes.md) +> defines the axes in terms of behavior and deliberately avoids naming code; +> [target-architecture.md](./target-architecture.md) describes where the code is heading. This doc is the +> current state in between — the helpers that exist, the stored fields behind them, and what is not +> covered yet. + +## The guards + +Consumers that need a document's position on an axis read its stored association fields through the +guards in `src/models/document/document-scope.ts`, rather than branching on the document `type`: + +- `hasGroupOwnerScope(doc)` — an **owner** question: the document belongs to a single group. +- `hasUnitCurriculumScope(doc)` — a **curriculum** question: the document is about a whole unit and + nothing narrower. + +Each guard answers about one axis and reads only that axis's fields. A consumer needing a position on +both asks both. That is what keeps each guard's meaning independent of what the other axis holds: +`hasGroupOwnerScope` does not care which problem a document is about, and `hasUnitCurriculumScope` +does not care who owns it. + +A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents +from other units, whose kinds are not registered in the current session. + +The module also provides `getCurriculumScopeLabel(doc)`, which names a document's curriculum position +— `"sas-1.2"` for a problem, `"sas"` for a unit. Titles use it as a stand-in when a document's real +title cannot be resolved. + +> **Naming lag.** These helpers were written while `scope` was still a single axis, so they carry +> "Scope" in their names and live in a module called `document-scope`. `hasGroupOwnerScope` belongs to +> `owner`; `hasUnitCurriculumScope` and `getCurriculumScopeLabel` belong to `curriculum`. Renaming them +> has not been done. + +## What each stored shape looks like + +| document | `unit` | `investigation` | `offeringId` | `groupId` | container | curriculum | owner | +|---|---|---|---|---|---|---|---| +| personal, learning log | `null` | — | — | — | class | none | user | +| problem, planning, publications | set | set | set | — | offering | problem | user | +| group | set | set | set | set | offering | problem | **group** | +| exemplar (from curriculum) | set | set | — | — | classUnit | problem | synthetic user | +| class-wide slot | set | `null` | — | — | classUnit | **unit** | class | + +The `null`s are load-bearing. A class-wide document writes `investigation: null` and `problem: null` +explicitly rather than omitting them, because Firestore cannot match a field that is missing — that is +what makes "about a unit but not a problem" a queryable condition. + +## Not covered yet + +- **No guard reads the class or user levels of owner.** Those live in `uid`: the class owner is a + synthetic `class_`, the group owner a synthetic `group__`. A consumer + wanting "owned by the class" currently approximates it with `hasUnitCurriculumScope`, which is + correct only while the one class-owned kind is also the one unit-scoped kind. `document-group.ts`'s + `byName` is commented to that effect and should switch when an owner guard exists. +- **No guard reads the container at all.** Its levels are derivable from the same stored fields — + `offeringId` for offering, `unit` for classUnit, `context_id` for class — but nothing exposes them, + and the canonical-pointer path is built from the individual fields instead. +- **`offeringId` is effectively write-only.** It is written to Firestore at creation but is declared on + neither `IDocumentMetadata` nor `DocumentMetadataModel`, so no read-side consumer can see it. Every + document that carries one also carries an `investigation`, so nothing is misclassified today. + +## Titling a document from another unit + +Under Sort Work's "All" filter a class sees every document it owns, including documents from units it +has already worked through — the class hash spans units. Two title-resolution problems follow, and both +are handled by treating a unit-declared title as belonging to its unit: + +- A kind declared by a unit that is not loaded has no registered title, and a class-wide document + stores no title of its own. `getDocumentDisplayTitle` names it from `getDocumentKindLabel(kind)` plus + the curriculum label — `"Driving Question Board (other)"`. +- Two units may declare the *same* kind with different wording. `IDocumentKindInfo.unit` records which + unit's config declared a title, and `getDocumentTitle` returns it only for that unit's documents, so + a foreign document falls through to the label above rather than borrowing wording that may not be its + own. + +The kind label recovers the kind's identity, not the author's wording: a slot titled "Our Big Questions" +in its own unit reads as "Driving Question Board" from elsewhere. Nothing loads another unit's config, +so its authored title is not available. diff --git a/docs/document-scope.md b/docs/document-scope.md index 6db6933540..afb57d16e7 100644 --- a/docs/document-scope.md +++ b/docs/document-scope.md @@ -50,92 +50,6 @@ So the environment service has to be created, then the root node created with th The top level properties of the environment object are not supposed to be modified after it is created, based on this "shallowly immutable" note here: https://mobx-state-tree.js.org/concepts/dependency-injection However we are doing this when the appConfig is added to the environment object in `Documents#add` -## Reading a document's scope in code - -Consumers that need to know a document's scope read its stored association fields through the guards -in `src/models/document/document-scope.ts`, rather than branching on the document `type`. - -### Two dimensions, and a reference that crosses them - -Scope is not one ordered level. It is two, each its own linear nesting, and every document sits -somewhere on both: - -| dimension | levels (widest → narrowest) | read from | -|---|---|---| -| **curriculum scope** | unit → investigation → problem | `unit`, `investigation`, `problem` | -| **owner scope** | class → group → user | `groupId`; the class and user levels live in `uid` | - -This is why there is **no `scopeLevel` enum and no unified `scope` struct** — a single ordered level -cannot express a position on two axes at once. A personal document is user-owned with no curriculum -scope; a class-wide document is class-owned with unit curriculum scope. Neither is "more scoped". - -**`offeringId` is on neither dimension — it crosses both.** An offering is the assignment of one -problem to one class, so it is a point in the *product* of the two hierarchies rather than a level in -either. Carrying one pins curriculum scope at problem, but it does not determine owner scope: the -documents inside a single offering are variously user-owned (problem, planning), group-owned (group), -and could be class-owned. - -**`context_id` is not a level either** — every document names a class. Being *associated with* a -class is not being *owned by* one; class ownership is a synthetic `class_` uid. That is why -these guards name only the level they test and leave the class out of the name. - -**Each guard answers about one dimension and reads only that dimension's fields:** - -- `hasGroupOwnerScope(doc)` — owner scope: the document belongs to a single group (`groupId` is set). -- `hasUnitCurriculumScope(doc)` — curriculum scope: the document spans a whole unit, narrowed no - further. - -A consumer needing a position on both asks both. Keeping the guards single-dimension is what makes -each one's meaning independent of what the other dimension holds: `hasGroupOwnerScope` does not care -which problem a document belongs to, and `hasUnitCurriculumScope` does not care who owns it. - -Where each stored shape sits: - -| document | `unit` | `investigation` | `offeringId` | `groupId` | curriculum scope | owner scope | -|---|---|---|---|---|---|---| -| personal, learning log | `null` | — | — | — | none | user | -| problem, planning, publications | set | set | set | — | problem | user | -| group | set | set | set | set | problem | **group** | -| exemplar (from curriculum) | set | set | — | — | problem | user | -| class-wide slot | set | `null` | — | — | **unit** | class | - -A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents -from other units, whose kinds are not registered in the current session. - -**Two gaps, recorded rather than closed:** - -- No guard reads the class or user levels of owner scope, because those live in `uid` — the class - owner is a synthetic `class_`. A consumer wanting "owned by the class" currently - approximates it with `hasUnitCurriculumScope`, which is correct only while the one class-owned kind - is also the one unit-scoped kind. `document-group.ts`'s `byName` is commented to that effect and - should switch when an owner-scope guard exists. -- `offeringId` is written to Firestore at creation but is not declared on `IDocumentMetadata` or - modelled on `DocumentMetadataModel`, so no read-side consumer can see it. Every document that - carries one also carries an `investigation`, so nothing is misclassified today, but the field is - effectively write-only until the `scope` axis's read side surfaces it. - -The same module provides `getCurriculumScopeLabel(doc)`, which names a document's position on the -curriculum dimension — `"sas-1.2"` for a problem, `"sas"` for a unit. Titles use it as a stand-in -when a document's real title cannot be resolved. - -## Titling a document from another unit - -Under the Sort Work "All" filter a class sees every document it owns, including documents from units -it has already worked through — the class hash spans units. Two title-resolution problems follow, and -both are handled by treating a unit-declared title as belonging to its unit: - -- A kind declared by a unit that is not loaded has no registered title, and a class-wide document - stores no title of its own. `getDocumentDisplayTitle` names it from - `getDocumentKindLabel(kind)` plus the scope label — `"Driving Question Board (other)"`. -- Two units may declare the *same* kind with different wording. `IDocumentKindInfo.unit` records - which unit's config declared a title, and `getDocumentTitle` returns it only for that unit's - documents, so a foreign document falls through to the label above rather than borrowing wording - that may not be its own. - -The kind label recovers the kind's identity, not the author's wording: a slot titled "Our Big -Questions" in its own unit reads as "Driving Question Board" from elsewhere. Nothing loads another -unit's config, so its authored title is not available. - # View layer ## React Context diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts index 900eaed117..bbdf7a7529 100644 --- a/src/models/document/document-scope.ts +++ b/src/models/document/document-scope.ts @@ -3,9 +3,9 @@ * `problem`, `offeringId`, `groupId`), stamped at creation from the kind's registered `scopeType` * (see document-kinds.ts). * - * Scope has two dimensions — curriculum and owner — and each guard answers about one of them, reading - * only that dimension's fields. A consumer needing a position on both asks both. See - * docs/document-scope.md for the model, the field-by-shape table, and what is not covered yet. + * Each guard answers about one axis, reading only that axis's fields; a consumer needing a position on + * more than one asks each. See docs/document-axes/reading-axes-in-code.md for the field-by-shape table + * and what is not covered yet, and docs/document-axes/axes.md for what the axes mean. * * A guard reads stored fields only, never the kind registry: Sort Work lists documents from other * units, whose kinds are not registered in the current session. From f0ee513bbb49d28ab1a63aab9b76f9933e9bbdc0 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 15:28:40 -0400 Subject: [PATCH 24/51] refactor: split the scope axis in code into owner, container, and curriculum [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axes docs describe container (where a document is kept: class → classUnit → offering) and curriculum (what it is about) as separate axes, while the code still called both "scope" and asked one about the other. Creation side: - A kind now declares `containerType` ("class" | "classUnit" | "offering") beside its existing `ownerType`. There is no group container level: a group document is kept in the offering alongside the problem documents its members write, and what makes it the group's is its owner. - `getDocumentOwnerFields` stamps a group owner's `groupId`, keyed on `ownerType`. It was previously stamped by a `group` scope type, which stated owner data on the wrong axis. - `getDocumentScopeFields` becomes `getDocumentLocationFields`, named for the pair of axes whose fields it returns rather than for the container alone. Read side, in the renamed `document-axes.ts`: - `hasGroupOwner` and the new `hasClassOwner`, which hides the fact that a class owner is a synthetic `class_` uid with no field of its own. Sort Work sections a class-wide document under the class because of who owns it, so it now asks this rather than testing the curriculum. - `isInClassUnitContainer` replaces the curriculum test in `canUserEditDocument`. A classmate may edit a class-wide document because of where it is kept, which is a container question. - `hasUnitCurriculum` is gone; nothing asks a yes/no curriculum question now, and leaving it invited the same axis confusion back. `isInClassUnitContainer` needs `offeringId`, the only positive marker of the offering container — an exemplar carries the same unit/investigation/problem as a problem document. It is written to Firestore but was declared on no type, so nothing could read it; it is now on `IDocumentMetadataBase`, `DocumentMetadataModel`, and `DocumentModel`. Without it a group document would read as class-wide and any classmate could edit it, so the edit-gate fixtures now carry the offering and class a real group document has. An exemplar belongs to no offering either, so it shares that container and the gate's `concurrent` check is what keeps it read-only. That holds for both of its shapes, and each is now pinned: the curriculum document, and the metadata record a teacher's comment creates — the latter stamped with the commenting class's `context_id`, so nothing else would stop it. Also renames `GroupSectionSortKey.scope` to `.section`; it categorizes Sort Work sections and never meant the axis. The stored metadata is unchanged: a group document still gets the same `groupId`, now from the owner axis rather than the scope axis. Co-Authored-By: Claude Opus 5 (1M context) --- docs/document-axes/README.md | 30 +++--- docs/document-axes/reading-axes-in-code.md | 74 +++++++++----- docs/document-metadata/metadata-fields.md | 13 +-- src/lib/db.test.ts | 4 +- src/lib/db.ts | 48 +++++---- src/models/document/document-axes.test.ts | 107 +++++++++++++++++++++ src/models/document/document-axes.ts | 79 +++++++++++++++ src/models/document/document-kinds.test.ts | 96 ++++++++++-------- src/models/document/document-kinds.ts | 77 +++++++++------ src/models/document/document-scope.test.ts | 80 --------------- src/models/document/document-scope.ts | 58 ----------- src/models/document/document-utils.test.ts | 68 ++++++++----- src/models/document/document-utils.ts | 32 +++--- src/models/stores/document-group.ts | 26 +++-- src/utilities/sort-document-utils.test.ts | 20 ++-- src/utilities/sort-document-utils.ts | 16 +-- 16 files changed, 482 insertions(+), 346 deletions(-) create mode 100644 src/models/document/document-axes.test.ts create mode 100644 src/models/document/document-axes.ts delete mode 100644 src/models/document/document-scope.test.ts delete mode 100644 src/models/document/document-scope.ts diff --git a/docs/document-axes/README.md b/docs/document-axes/README.md index 8dec55f1ac..62fc3d1f4c 100644 --- a/docs/document-axes/README.md +++ b/docs/document-axes/README.md @@ -29,12 +29,13 @@ flips the rows it delivers **in the same PR**, and names the stage/ticket under | `canonical` (single pointed-to doc for a scope slot) | scoped pointer slots, rule-enforced | done | CLUE-524; class+unit pointer scope added CLUE-550 Stage 2 | | `concurrent` (multi-writer vs single-writer) | stored per-doc; rule-readable; `DocumentModel` prop sourced from Firestore at open | done | CLUE-550 Stage 1 | | `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired Stage 3 (workspace title bar reads the registry; no consumer branches on kind); Stage 3 also scopes a unit-declared kind's definition to its unit — see "Static and dynamic kinds" in [axes.md](./axes.md) | -| `owner` (authoring identity / provenance) | creation: kind-declared `ownerType` → owner `uid` (in the kind registry); read: getter over stored `uid` | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds via `getDocumentOwner`; read-side getter still to come) | -| `scope` (owner + curriculum association refs) | creation: `getDocumentScopeFields(kind, ctx)` stamps a kind's association fields, keyed on a registered `scopeType`; read: consumers read the individual scope fields through named per-dimension guards (`hasGroupOwnerScope`, `hasUnitCurriculumScope`) rather than branching on `type` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (read side: guards in `document-scope.ts` split along the two dimensions — curriculum unit/investigation/problem and owner class/group/user; the unit curriculum scope states its absent curriculum fields explicitly so it is queryable). Still to come: guards for the class and user levels of owner scope, which live in `uid` | +| `owner` (who the document belongs to) | creation: kind-declared `ownerType` → owner `uid` (`getDocumentOwner`) plus a group owner's stored `groupId` (`getDocumentOwnerFields`); read: `hasGroupOwner(doc)` over the stored `groupId`, `hasClassOwner(doc)` over the uid's `class_` prefix | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds); Stage 3 (both guards; `groupId` moved onto this axis, off the container). Still to come: the user level, and a getter that returns which owner a document has rather than testing for one | +| `container` (where the document is kept: class → classUnit → offering) | creation: kind-declared `containerType`, stamped by `getDocumentLocationFields(kind, ctx)`; read: `isInClassUnitContainer(doc)`, over an `offeringId` now surfaced on both metadata types | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (`containerType` replaces `scopeType`, with no group level — a group document is kept in the offering and owned by the group; the edit gate switched from a curriculum test to this one). Still to come: a guard for the class level, and a getter returning the container | +| `curriculum` (what the document is about: nothing → unit → investigation → problem) | creation: fixed by the kind's `containerType`, since every container above the class is identified by a curriculum coordinate; read: `getCurriculumLabel(doc)` | in progress | CLUE-550 Stage 2 (creation side, every kind); Stage 3 (the label, and the unit level states its absent fields explicitly so it is queryable). No consumer asks a yes/no curriculum question, so no guard exists | | `permissions` (composed grant set) | permission-policy grants (referenced policy) + stored per-doc grants | not started | — | | kind registry (by-kind view) | `register`/`get` map keyed on `kind`; `fn(doc)` API | done | CLUE-550 Stage 1 | | behavior modules (by-behavior view) | `fn(doc)` reading axis getters / registry; never branch on `kind` | in progress | CLUE-550 Stage 1 (history + write-sync on concurrent; read-access + rules-delete on group type, interim until the permissions axis); Stage 3 (edit gate `canUserEditDocument`, collaborative thumbnail treatment, and the collaborative title bar all read `concurrent`) | -| creation factory (the one `kind → axis` bridge) | reads registry defaults, stamps axis values on a new doc | in progress | CLUE-550 Stage 2 (per-slot class-wide canonical creation; owner `uid` and scope fields stamped from the kind's `ownerType`/`scopeType`) | +| creation factory (the one `kind → axis` bridge) | reads registry defaults, stamps axis values on a new doc | in progress | CLUE-550 Stage 2 (per-slot class-wide canonical creation; owner and location fields stamped from the kind's `ownerType`/`containerType`) | Status values: `not started` / `in progress` / `done`. @@ -46,18 +47,19 @@ history, non-owner write-sync, class-wide read access, the rules delete clause) the stored `concurrent`. Stage 2 auto-creates class-wide documents (e.g. the driving-question board) via the canonical-pointer engine: a class+unit pointer scope alongside the existing offering+group scope, with get-or-create convergence guaranteeing exactly one document per slot per class. Stage 2 also begins the -`owner` and `scope` axes on the creation side, now for **every** kind: a document's owner `uid` is derived from -the kind's registered `ownerType` (`user` / `group` / `class`) — class-wide documents owned by a class-scoped -synthetic uid (`class_`) — and its scope association fields from the kind's registered `scopeType` -via `getDocumentScopeFields(kind, ctx)`, both resolved in the kind registry rather than a `type` switch. Because -all kinds are registered, `createFirestoreMetadataDocument` derives owner and scope through these registry calls +`owner`, `container`, and `curriculum` axes on the creation side, now for **every** kind: a document's owner +`uid` is derived from the kind's registered `ownerType` (`user` / `group` / `class`) — class-wide documents +owned by a synthetic class uid (`class_`) — and the fields saying where it is kept and what it is +about from the kind's registered container, both resolved in the kind registry rather than a `type` switch. +Because all kinds are registered, `createFirestoreMetadataDocument` derives all of these through registry calls for all document types. The kind axis fields (`kind`/`concurrent`) are stamped only on `type:"group"` documents — avoiding a stamp we would have to migrate if the publication kinds are later folded into the kinds they publish. -Stage 3 surfaces those documents: Sort Work sections them under "Whole Class" by scope rather than by -type, a unit-scoped listener keeps them visible under the investigation and problem filters, -presentation reads `concurrent` and the kind registry, and one predicate (`canUserEditDocument`) -gates every Edit button. It also settles the deferred scope-modeling question: consumers read narrow -named guards over the stored association fields, with no `scopeLevel` enum and no unified `scope` -struct (see [reading-axes-in-code.md](./reading-axes-in-code.md)). +Stage 3 surfaces those documents: Sort Work sections them under "Whole Class" by owner and curriculum +rather than by type, a unit-scoped listener keeps them visible under the investigation and problem +filters, presentation reads `concurrent` and the kind registry, and one predicate +(`canUserEditDocument`) gates every Edit button. It also settles how these axes are modeled in code: +consumers read narrow named guards over the stored fields, with no level enum and no unified struct; +a kind declares `ownerType` and `containerType`; and `groupId` sits on the owner axis, so there is no +group container level (see [reading-axes-in-code.md](./reading-axes-in-code.md)). diff --git a/docs/document-axes/reading-axes-in-code.md b/docs/document-axes/reading-axes-in-code.md index df8cf927ac..ec031dde02 100644 --- a/docs/document-axes/reading-axes-in-code.md +++ b/docs/document-axes/reading-axes-in-code.md @@ -8,29 +8,53 @@ ## The guards -Consumers that need a document's position on an axis read its stored association fields through the -guards in `src/models/document/document-scope.ts`, rather than branching on the document `type`: +Consumers that need a document's position on an axis read its stored fields through the guards in +`src/models/document/document-axes.ts`, rather than branching on the document `type`: -- `hasGroupOwnerScope(doc)` — an **owner** question: the document belongs to a single group. -- `hasUnitCurriculumScope(doc)` — a **curriculum** question: the document is about a whole unit and - nothing narrower. +- `hasGroupOwner(doc)` — an **owner** question: the document belongs to a single group. +- `hasClassOwner(doc)` — an **owner** question: the document belongs to the class as a whole, with no + personal author. +- `isInClassUnitContainer(doc)` — a **container** question: the document is kept in the class's copy of + one unit, rather than in a single offering of one problem. Each guard answers about one axis and reads only that axis's fields. A consumer needing a position on -both asks both. That is what keeps each guard's meaning independent of what the other axis holds: -`hasGroupOwnerScope` does not care which problem a document is about, and `hasUnitCurriculumScope` +more than one asks each. That is what keeps each guard's meaning independent of what the other axes +hold: `hasGroupOwner` does not care which problem a document is about, and `isInClassUnitContainer` does not care who owns it. +Asking the *right* axis matters as much as reading the right fields. Sort Work sections a class-wide +document under the class because of who owns it, so it asks `hasClassOwner`. The Edit button lets a +classmate into a class-wide document because of where it is kept, so it asks +`isInClassUnitContainer`. + A guard reads *stored fields only*. It must not consult the kind registry: Sort Work lists documents from other units, whose kinds are not registered in the current session. -The module also provides `getCurriculumScopeLabel(doc)`, which names a document's curriculum position -— `"sas-1.2"` for a problem, `"sas"` for a unit. Titles use it as a stand-in when a document's real -title cannot be resolved. +The module also provides `getCurriculumLabel(doc)`, which names a document's curriculum position — +`"sas-1.2"` for a problem, `"sas"` for a unit. Titles use it as a stand-in when a document's real +title cannot be resolved. Nothing currently asks a yes/no question about the curriculum axis, so no +guard for it exists. + +## How a kind declares its axes + +Those fields are stamped at creation from what the kind registered in +`src/models/document/document-kinds.ts`. A kind declares two things: + +- `ownerType` — `"user"`, `"group"`, or `"class"`. It picks the owner `uid` (`getDocumentOwner`) and, + for a group owner, the stored `groupId` (`getDocumentOwnerFields`). +- `containerType` — `"class"`, `"classUnit"`, or `"offering"`. It picks the fields that say where the + document is kept and what it is about (`getDocumentLocationFields`). + +One knob covers both container and curriculum because every container above the class is *identified +by* a curriculum coordinate — a classUnit by its unit, an offering by its problem — so a kind has no +curriculum position left to choose separately. `getDocumentLocationFields` is named for the pair rather +than for the container alone, because the fields it returns span both axes. -> **Naming lag.** These helpers were written while `scope` was still a single axis, so they carry -> "Scope" in their names and live in a module called `document-scope`. `hasGroupOwnerScope` belongs to -> `owner`; `hasUnitCurriculumScope` and `getCurriculumScopeLabel` belong to `curriculum`. Renaming them -> has not been done. +There is no container level for a group. A group document is kept in the offering, alongside the +problem documents its members write; what makes it the group's is its owner. Its `groupId` therefore +follows `ownerType: "group"`, and is a denormalization of an owner uid that already encodes it +(`group__`) — stored so Firestore rules and group-member lookups need not parse +the uid. ## What each stored shape looks like @@ -48,17 +72,17 @@ what makes "about a unit but not a problem" a queryable condition. ## Not covered yet -- **No guard reads the class or user levels of owner.** Those live in `uid`: the class owner is a - synthetic `class_`, the group owner a synthetic `group__`. A consumer - wanting "owned by the class" currently approximates it with `hasUnitCurriculumScope`, which is - correct only while the one class-owned kind is also the one unit-scoped kind. `document-group.ts`'s - `byName` is commented to that effect and should switch when an owner guard exists. -- **No guard reads the container at all.** Its levels are derivable from the same stored fields — - `offeringId` for offering, `unit` for classUnit, `context_id` for class — but nothing exposes them, - and the canonical-pointer path is built from the individual fields instead. -- **`offeringId` is effectively write-only.** It is written to Firestore at creation but is declared on - neither `IDocumentMetadata` nor `DocumentMetadataModel`, so no read-side consumer can see it. Every - document that carries one also carries an `investigation`, so nothing is misclassified today. +- **No guard reads the user level of owner.** A user-owned document is simply one whose `uid` is + neither synthetic prefix, and no guard says so; consumers compare `uid` to a user id directly. + There is also no getter that returns *which* owner a document has — only the two "is it this one" + guards. +- **`hasClassOwner` reads the uid's grammar.** The class owner has no field of its own, so the guard + matches the `class_` prefix that `DB.userIdForClassWideDocuments` mints. The two share + `kClassOwnerPrefix`, which is what keeps them from drifting, but the grammar is still a convention + rather than something stored. The same is true of the group owner uid. +- **Only one container level has a guard.** `isInClassUnitContainer` distinguishes classUnit from + offering; nothing names the class level, and no getter returns a document's container. The + canonical-pointer path is still built from the individual fields rather than from a container. ## Titling a document from another unit diff --git a/docs/document-metadata/metadata-fields.md b/docs/document-metadata/metadata-fields.md index 9042b2215f..de33f693c1 100644 --- a/docs/document-metadata/metadata-fields.md +++ b/docs/document-metadata/metadata-fields.md @@ -128,7 +128,7 @@ each other's documents, so teacher documents must keep writing it or that cross- breaks. Student and group documents have no network, so the field is null for them. It is declared on `IDocumentMetadata` and loaded into `DocumentMetadataModel.network`; no consumer reads it off the model yet. -Fields such as `offeringId` and `canonical` are written to the Firestore doc but have no +Fields such as `canonical` are written to the Firestore doc but have no `DocumentMetadataModel` prop; they reach `DocumentMetadataStore`'s `typecheck(DocumentMetadataModel, data)` unfiltered and validate only because MST's `typecheck` ignores properties the model does not declare — pinned by the `typecheck` tests in [mst.test.ts](../../src/models/mst.test.ts). @@ -199,13 +199,14 @@ forbid setting it on create and permit a single one-time set on update. Not pres - **Location:** Firestore `documents/{key}.offeringId`; RTDB `/{classPath}/users/{uid}/documentMetadata/{key}/offeringId` - **Applies to:** the problem family — problem, planning, publication, supportPublication, group -- **Runtime:** not surfaced on any document model +- **Runtime:** `DocumentModel.offeringId`, `DocumentMetadataModel.offeringId` - **Updated by:** nothing — creation only -- **Reactive:** No +- **Reactive:** No — immutable -Listed here rather than under dual-stored because it is not in `IDocumentMetadata` and has no runtime -representation; it exists to scope documents to an offering. It reaches Firestore only because -`createFirestoreMetadataDocument` spreads the RTDB metadata object. +Names the offering a document is kept in, and is the only positive marker of that container: an +exemplar carries the same unit/investigation/problem as a problem document and is distinguished from +it by nothing else. `canUserEditDocument` therefore depends on it — without it a group document reads +as class-wide, and the class check would let any classmate edit another group's work. --- diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts index 4639c31ff8..8bd42239c7 100644 --- a/src/lib/db.test.ts +++ b/src/lib/db.test.ts @@ -436,10 +436,10 @@ describe("db", () => { describe("class-wide document creation", () => { it("createFirestoreMetadataDocument stamps class+unit scope, kind, and concurrent (but not title)", async () => { // The kind must be registered as class-scoped so getDocumentKindMetadataFields returns its axis fields and - // getDocumentScopeFields returns the class `unit` (read from the stores' current unit). The authored title + // getDocumentLocationFields returns the class `unit` (read from the stores' current unit). The authored title // is registered too, to prove it is resolved by kind and NOT persisted into the Firestore metadata. registerDocumentKind("drivingQuestionBoard", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit", title: "Driving Question Board" }); // Rebuild stores with the classHash (→ context_id) and the current unit code the class-wide scope uses. diff --git a/src/lib/db.ts b/src/lib/db.ts index 5813f550d4..fc8639a481 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -33,9 +33,10 @@ import { LogEventName } from "./logger-types"; import { getSimpleDocumentPath, IDocumentMetadata, IGetImageDataParams, IPublishSupportParams } from "../../shared/shared"; import { - getDocumentKindMetadataFields, getDocumentOwner, getDocumentOwnerType, getDocumentScopeFields, - registerDocumentKind + getDocumentKindMetadataFields, getDocumentLocationFields, getDocumentOwner, getDocumentOwnerFields, + getDocumentOwnerType, registerDocumentKind } from "../models/document/document-kinds"; +import { kClassOwnerPrefix } from "../models/document/document-axes"; import { getFirebaseFunction } from "../hooks/use-firebase-function"; import { IStores } from "../models/stores/stores"; import { TeacherSupportModelType, SectionTarget, AudienceModelType } from "../models/stores/supports"; @@ -593,18 +594,20 @@ export class DB { return docSnapshot.data() as IDocumentMetadata; } - // Resolve every scope field (context_id, unit/investigation/problem, offering/group association) from the - // kind's registered scope — all kinds are registered, so getDocumentScopeFields handles each type. The - // runtime values come from the stores; they are valid here because createDocument validated them via - // validateDocumentKindCreation before writing (a group kind requires the user to be in a group, so - // currentGroupId is present). - const scopeFields = getDocumentScopeFields(kind, { + // Resolve where the document is kept and what it is about (context_id, unit/investigation/problem, + // offeringId) from the kind's registered container — all kinds are registered, so + // getDocumentLocationFields handles each type. + const locationFields = getDocumentLocationFields(kind, { ...this.currentProblemInfo, context_id: user.classHash, offeringId: user.offeringId, - groupId: user.currentGroupId, }); + // The owner's stored fields beyond `uid`: a group owner's `groupId`. The runtime value comes from the + // stores; it is valid here because createDocument validated it via validateDocumentKindCreation before + // writing (a group kind requires the user to be in a group, so currentGroupId is present). + const ownerFields = getDocumentOwnerFields(kind, { groupId: user.currentGroupId }); + // `title` is stamped only when present so Firestore never sees `title: undefined`. const titleInfo: { title?: string } = {}; if (title != null) { @@ -612,7 +615,7 @@ export class DB { } // Stamp the kind's axis fields (kind + concurrent), but only on type:"group" documents (group + class-wide). - // Every kind is registered now for scope/owner resolution, yet we deliberately do NOT persist `kind` on + // Every kind is registered now for location/owner resolution, yet we deliberately do NOT persist `kind` on // other docs' Firestore metadata yet. We might change the list of kinds when we add full support for the // other document types, so we don't want to stamp a kind we'd then have to migrate. const kindFields = type === GroupDocument ? getDocumentKindMetadataFields(kind) : {}; @@ -627,7 +630,8 @@ export class DB { properties: {}, uid: owner, ...titleInfo, - ...scopeFields, + ...ownerFields, + ...locationFields, ...kindFields }; await documentRef.set(firestoreMetadata); @@ -643,8 +647,8 @@ export class DB { }; } - // Verify the stores hold the runtime context a document of this kind needs to construct its owner and scope - // fields. Throws when the context is missing. Currently only group-owned kinds have a requirement: their owner + // Verify the stores hold the runtime context a document of this kind needs to construct its owner and + // location fields. Throws when the context is missing. Only group-owned kinds have a requirement: their owner // id is `group__`, so both are required. The check lives here instead of the kind registry // because it is easier for now. private validateDocumentKindCreation(kind: string) { @@ -780,9 +784,10 @@ export class DB { }); } - // Class-scoped synthetic owner for this class's class-wide documents + // Synthetic owner uid for this class's class-wide documents. hasClassOwner reads the prefix back off + // a stored uid, so both sides share the constant. private get userIdForClassWideDocuments() { - return `class_${this.stores.user.classHash}`; + return `${kClassOwnerPrefix}${this.stores.user.classHash}`; } public async getOrCreateClassWideDocument(classWideDoc: { kind: string; title: string }) { @@ -807,16 +812,17 @@ export class DB { if (!classWideDocs?.length) return; for (const classWideDoc of classWideDocs) { // Register each declared document's kind so createFirestoreMetadataDocument stamps its axis fields via the - // registry and createDocument derives its owner and scope. Class-wide collaborative documents are always - // concurrent, class-owned (class_), and class+unit scoped. The authored title is registered - // here (not stored per document) so it is resolved live by kind — an author changing it applies to every - // document of that kind (see getDocumentTitle). registerDocumentKind validates the kind and rejects a - // duplicate (both throw); skip a bad entry rather than crash startup. + // registry and createDocument derives its owner and location. Class-wide collaborative documents are + // always concurrent, class-owned (class_), and kept in the class's copy of the unit, about + // that unit and nothing narrower. The authored title is registered here (not stored per document) so it + // is resolved live by kind — an author changing it applies to every document of that kind (see + // getDocumentTitle). registerDocumentKind validates the kind and rejects a duplicate (both throw); skip + // a bad entry rather than crash startup. try { registerDocumentKind(classWideDoc.kind, { metadataFields: { concurrent: true }, ownerType: "class", - scopeType: "classUnit", + containerType: "classUnit", title: classWideDoc.title, // The same code stamped as the document's `unit` (see currentProblemInfo), so getDocumentTitle // can tell this unit's documents from another unit's that declares the same kind. diff --git a/src/models/document/document-axes.test.ts b/src/models/document/document-axes.test.ts new file mode 100644 index 0000000000..43d7d86f3e --- /dev/null +++ b/src/models/document/document-axes.test.ts @@ -0,0 +1,107 @@ +import { getCurriculumLabel, hasClassOwner, hasGroupOwner, isInClassUnitContainer } from "./document-axes"; + +describe("document axis guards", () => { + // One case per document shape CLUE stores, so the guards are pinned against every shape they + // must distinguish rather than only the two this feature introduces. Each carries the uid its + // owner would mint: a real user id, or one of the synthetic ones. + const personal = { uid: "u-1", unit: null, investigation: null, groupId: null }; + const problem = + { uid: "u-1", unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: null }; + const group = + { uid: "group_off-1_3", unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: "3" }; + // Curriculum-authored, so it belongs to no offering and is owned by an authoring persona. + const exemplar = { uid: "ivan_idea_1", unit: "qa", investigation: "1", problem: "1" }; + const classWide = { uid: "class_h1", unit: "sas", investigation: null, groupId: null }; + // Created before investigation/problem were stamped. + const legacyClassWide = { uid: "class_h1", unit: "sas" }; + + describe("hasGroupOwner", () => { + it("is true only when the document carries a group id", () => { + expect(hasGroupOwner(group)).toBe(true); + expect(hasGroupOwner(personal)).toBe(false); + expect(hasGroupOwner(problem)).toBe(false); + expect(hasGroupOwner(exemplar)).toBe(false); + expect(hasGroupOwner(classWide)).toBe(false); + }); + + it("reads only the owner dimension, whatever the curriculum scope holds", () => { + // The two dimensions are independent: a group id decides this guard on its own. + expect(hasGroupOwner({ groupId: "3" })).toBe(true); + expect(hasGroupOwner({ unit: "sas", groupId: "3" })).toBe(true); + }); + }); + + describe("hasClassOwner", () => { + it("is true only for a document owned by the synthetic class uid", () => { + expect(hasClassOwner(classWide)).toBe(true); + expect(hasClassOwner(legacyClassWide)).toBe(true); + }); + + it("is false for every other owner", () => { + expect(hasClassOwner(personal)).toBe(false); // a real user + expect(hasClassOwner(problem)).toBe(false); // a real user + expect(hasClassOwner(group)).toBe(false); // the synthetic group uid + expect(hasClassOwner(exemplar)).toBe(false); // a synthetic authoring persona + }); + + it("reads only the owner, whatever the document is about or where it is kept", () => { + expect(hasClassOwner({ uid: "class_h1" })).toBe(true); + expect(hasClassOwner({ uid: "class_h1", unit: "sas", investigation: "1", offeringId: "off-1" })) + .toBe(true); + }); + + it("is false when the document has no uid at all", () => { + expect(hasClassOwner({ unit: "sas" })).toBe(false); + }); + }); + + describe("isInClassUnitContainer", () => { + it("is true for a document kept in the class's copy of a unit", () => { + expect(isInClassUnitContainer(classWide)).toBe(true); + expect(isInClassUnitContainer(legacyClassWide)).toBe(true); + // An exemplar is about a problem but belongs to no offering, so it is kept here too. + expect(isInClassUnitContainer(exemplar)).toBe(true); + }); + + it("is false for a document kept in an offering", () => { + expect(isInClassUnitContainer(problem)).toBe(false); + expect(isInClassUnitContainer(group)).toBe(false); + }); + + it("is false for a document kept at the class, with no unit", () => { + expect(isInClassUnitContainer(personal)).toBe(false); + }); + + it("reads only the container, whatever the owner holds", () => { + // A group id cannot decide this guard: it says who owns the document, not where it is kept. + expect(isInClassUnitContainer({ unit: "sas", groupId: "3" })).toBe(true); + }); + + it("treats an empty-string unit as no unit", () => { + expect(isInClassUnitContainer({ unit: "", offeringId: null })).toBe(false); + }); + }); + + describe("getCurriculumLabel", () => { + it("names the problem a document belongs to", () => { + expect(getCurriculumLabel(problem)).toBe("sas-1.2"); + expect(getCurriculumLabel(group)).toBe("sas-1.2"); + expect(getCurriculumLabel(exemplar)).toBe("qa-1.1"); + }); + + it("names the unit alone when the document is scoped no narrower", () => { + expect(getCurriculumLabel(classWide)).toBe("sas"); + expect(getCurriculumLabel(legacyClassWide)).toBe("sas"); + }); + + it("returns undefined when the document has no unit", () => { + expect(getCurriculumLabel(personal)).toBeUndefined(); + expect(getCurriculumLabel({ unit: "" })).toBeUndefined(); + }); + + it("keeps the investigation when a document has one but no problem", () => { + // No registered scope type produces this shape; the label degrades rather than dropping it. + expect(getCurriculumLabel({ unit: "sas", investigation: "1" })).toBe("sas-1.x"); + }); + }); +}); diff --git a/src/models/document/document-axes.ts b/src/models/document/document-axes.ts new file mode 100644 index 0000000000..3aea24a16a --- /dev/null +++ b/src/models/document/document-axes.ts @@ -0,0 +1,79 @@ +/** + * Guards over a document's stored axis fields (`context_id`, `unit`, `investigation`, `problem`, + * `offeringId`, `groupId`), stamped at creation from the kind's registered `ownerType` and + * `containerType` (see document-kinds.ts). + * + * Each guard answers about one axis, reading only that axis's fields; a consumer needing a position on + * more than one asks each. See docs/document-axes/reading-axes-in-code.md for the field-by-shape table + * and what is not covered yet, and docs/document-axes/axes.md for what the axes mean. + * + * A guard reads stored fields only, never the kind registry: Sort Work lists documents from other + * units, whose kinds are not registered in the current session. + */ + +/** + * The prefix of the synthetic uid that owns a class's documents, `class_`. Shared by the + * side that mints the uid (DB.userIdForClassWideDocuments) and hasClassOwner, which reads it back. + */ +export const kClassOwnerPrefix = "class_"; + +/** The fields the guards read. Structural, so this stays a leaf module. */ +export interface IDocumentAxisFields { + uid?: string | null; + unit?: string | null; + investigation?: string | null; + problem?: string | null; + offeringId?: string | null; + groupId?: string | null; +} + +/** + * Owner axis: the document belongs to a single group, whoever created it. + * + * Only group-owned documents carry a `groupId`; others leave it unset so a stale group id can never + * be read back, since a user's group may change (see DocumentMetadataModel.groupId). + */ +export function hasGroupOwner(doc: IDocumentAxisFields): doc is IDocumentAxisFields & { groupId: string } { + return !!doc.groupId; +} + +/** + * Owner axis: the document belongs to the class as a whole, with no personal author. + * + * A class owner has no field of its own — it is a synthetic uid, so this reads the uid's grammar. + * Callers ask the question and leave that to the guard, which is what lets the representation change + * without touching them. + */ +export function hasClassOwner(doc: IDocumentAxisFields): boolean { + return !!doc.uid?.startsWith(kClassOwnerPrefix); +} + +/** + * Container axis: the document is kept in the class's copy of one unit, rather than in a single + * offering of one problem. It says nothing about who owns it — a class-wide slot and an exemplar are + * both kept here. + * + * `offeringId` is the only positive marker of the offering container, so a caller that cannot see it + * must not use this guard: an exemplar carries the same unit/investigation/problem as a problem + * document and is told apart from it by nothing else. + */ +export function isInClassUnitContainer(doc: IDocumentAxisFields): boolean { + return !!doc.unit && !doc.offeringId; +} + +/** + * A short label for a document's curriculum position: "sas-1.2" when it is about a problem, "sas" when + * it is about a unit and nothing narrower, undefined when it has no unit at all. + * + * Callers use it as a stand-in when a document's real title cannot be resolved, so the coordinates + * name the document instead. It reads the stored fields alone, so it describes a document from any + * unit, including one whose config is not loaded. + * + * An investigation with no problem ("sas-1.x") is not a shape any registered container type produces; it + * is handled so a partial position still reads as one rather than losing the investigation. + */ +export function getCurriculumLabel(doc: IDocumentAxisFields): string | undefined { + if (!doc.unit) return undefined; + if (!doc.investigation) return doc.unit; + return `${doc.unit}-${doc.investigation}.${doc.problem ?? "x"}`; +} diff --git a/src/models/document/document-kinds.test.ts b/src/models/document/document-kinds.test.ts index 0b376b747e..1cf5929fb4 100644 --- a/src/models/document/document-kinds.test.ts +++ b/src/models/document/document-kinds.test.ts @@ -1,7 +1,7 @@ import { GroupDocument, PersonalDocument, ProblemDocument } from "./document-types"; import { getDocumentKindInfo, getDocumentKindLabel, getDocumentKindMetadataFields, getDocumentOwner, - getDocumentOwnerType, getDocumentScopeFields, getDocumentTitle, isValidDocumentKind, + getDocumentOwnerFields, getDocumentOwnerType, getDocumentLocationFields, getDocumentTitle, isValidDocumentKind, registerDocumentKind, resetDocumentKindRegistryForTests } from "./document-kinds"; @@ -30,23 +30,23 @@ describe("document kinds registry", () => { it("registerDocumentKind adds new kinds", () => { registerDocumentKind("testAddedKind", - { metadataFields: { concurrent: true }, ownerType: "user", scopeType: "class" }); + { metadataFields: { concurrent: true }, ownerType: "user", containerType: "class" }); expect(getDocumentKindInfo("testAddedKind")?.metadataFields.concurrent).toBe(true); }); it("registerDocumentKind throws when a kind is registered more than once", () => { registerDocumentKind("testDuplicateKind", - { metadataFields: {}, ownerType: "user", scopeType: "class" }); + { metadataFields: {}, ownerType: "user", containerType: "class" }); expect(() => registerDocumentKind("testDuplicateKind", - { metadataFields: {}, ownerType: "user", scopeType: "class" })).toThrow(/already registered/); + { metadataFields: {}, ownerType: "user", containerType: "class" })).toThrow(/already registered/); // built-in kinds are registered at module load, so re-registering one throws too expect(() => registerDocumentKind(GroupDocument, - { metadataFields: { concurrent: true }, ownerType: "group", scopeType: "group" })).toThrow(); + { metadataFields: { concurrent: true }, ownerType: "group", containerType: "offering" })).toThrow(); }); it("registerDocumentKind throws for a kind that is not a valid camelCase identifier", () => { expect(() => registerDocumentKind("not-camel-case", - { metadataFields: {}, ownerType: "user", scopeType: "class" })).toThrow(/not a valid identifier/); + { metadataFields: {}, ownerType: "user", containerType: "class" })).toThrow(/not a valid identifier/); }); describe("getDocumentKindMetadataFields", () => { @@ -63,11 +63,11 @@ describe("document kinds registry", () => { }); }); - describe("owner scope", () => { + describe("owner", () => { const ctx = { userId: "u-1", groupOwnerId: "group_off_3", classOwnerId: "class_c1" }; - it("resolves user-scoped and unregistered kinds to the user as owner", () => { - expect(getDocumentOwnerType(PersonalDocument)).toBe("user"); // registered user-scoped kind + it("resolves user-owned and unregistered kinds to the user as owner", () => { + expect(getDocumentOwnerType(PersonalDocument)).toBe("user"); // registered user-owned kind expect(getDocumentOwnerType(undefined)).toBe("user"); // unregistered defaults to user expect(getDocumentOwner(PersonalDocument, ctx)).toBe("u-1"); }); @@ -79,51 +79,69 @@ describe("document kinds registry", () => { it("resolves a class kind to the class owner", () => { registerDocumentKind("testDqb", - { metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit" }); + { metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit" }); expect(getDocumentOwnerType("testDqb")).toBe("class"); expect(getDocumentOwner("testDqb", ctx)).toBe("class_c1"); }); - it("falls back to the user when the scope's synthetic owner was not supplied", () => { + it("falls back to the user when the synthetic owner was not supplied", () => { expect(getDocumentOwner(GroupDocument, { userId: "u-1" })).toBe("u-1"); }); }); - describe("scope fields", () => { + describe("owner fields", () => { + it("stamps a groupId for a group-owned kind", () => { + expect(getDocumentOwnerFields(GroupDocument, { groupId: "3" })).toEqual({ groupId: "3" }); + }); + + it("stamps nothing for a kind owned by a user or a class", () => { + expect(getDocumentOwnerFields(ProblemDocument, { groupId: "3" })).toEqual({}); + expect(getDocumentOwnerFields(PersonalDocument, { groupId: "3" })).toEqual({}); + expect(getDocumentOwnerFields(undefined, { groupId: "3" })).toEqual({}); + }); + + it("stamps nothing when the group-owned kind has no group to record", () => { + expect(getDocumentOwnerFields(GroupDocument, {})).toEqual({}); + }); + }); + + describe("location fields", () => { const ctx = { - groupId: "3", offeringId: "off-1", unit: "msu", investigation: "1", problem: "2", context_id: "class-h" + offeringId: "off-1", unit: "msu", investigation: "1", problem: "2", context_id: "class-h" }; - it("returns group + offering scope plus the problem context for the group kind", () => { - expect(getDocumentScopeFields(GroupDocument, ctx)).toEqual({ - groupId: "3", offeringId: "off-1", unit: "msu", investigation: "1", problem: "2", context_id: "class-h" + it("returns the offering and its problem for the group kind, with no owner fields among them", () => { + // A group document is kept in the offering like the problem documents beside it; its group is an + // owner field (getDocumentOwnerFields), not part of where it is kept. + expect(getDocumentLocationFields(GroupDocument, ctx)).toEqual({ + offeringId: "off-1", unit: "msu", investigation: "1", problem: "2", context_id: "class-h" }); }); - it("returns the unit and context_id for a class-unit kind, with curriculum scope stated as absent", () => { + it("returns the unit and context_id for a class-unit kind, stating the absent curriculum explicitly", () => { registerDocumentKind("testWordWall", - { metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit" }); - expect(getDocumentScopeFields("testWordWall", ctx)).toEqual({ + { metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit" }); + expect(getDocumentLocationFields("testWordWall", ctx)).toEqual({ unit: "msu", context_id: "class-h", investigation: null, problem: null }); }); - it("returns offering scope plus the problem context for an offering kind", () => { - expect(getDocumentScopeFields(ProblemDocument, ctx)).toEqual({ + it("returns the offering and its problem for an offering kind", () => { + expect(getDocumentLocationFields(ProblemDocument, ctx)).toEqual({ offeringId: "off-1", unit: "msu", investigation: "1", problem: "2", context_id: "class-h" }); }); it("returns only a null unit and context_id for a class kind and unregistered kinds", () => { - expect(getDocumentScopeFields(PersonalDocument, ctx)).toEqual({ unit: null, context_id: "class-h" }); - expect(getDocumentScopeFields(undefined, ctx)).toEqual({ unit: null, context_id: "class-h" }); + expect(getDocumentLocationFields(PersonalDocument, ctx)).toEqual({ unit: null, context_id: "class-h" }); + expect(getDocumentLocationFields(undefined, ctx)).toEqual({ unit: null, context_id: "class-h" }); }); }); describe("title", () => { it("returns a class-wide kind's registered static title", () => { registerDocumentKind("testDqbTitle", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit", title: "Driving Question Board" }); expect(getDocumentTitle({ kind: "testDqbTitle", type: GroupDocument })).toBe("Driving Question Board"); @@ -153,7 +171,7 @@ describe("document kinds registry", () => { beforeEach(() => { resetDocumentKindRegistryForTests(); registerDocumentKind("testUnitDeclaredKind", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit", title: "Driving Question Board", unit: "sas" }); }); @@ -186,24 +204,24 @@ describe("document kinds registry", () => { }); }); - describe("getDocumentScopeFields for a classUnit kind", () => { + describe("getDocumentLocationFields for a classUnit kind", () => { const ctx = { unit: "sas", investigation: "1", problem: "2", - context_id: "class-hash", groupId: "3", offeringId: "off-1" + context_id: "class-hash", offeringId: "off-1" }; beforeEach(() => { resetDocumentKindRegistryForTests(); registerDocumentKind("testClassWideKind", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit" + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit" }); }); - it("stamps the unit and class, and states the absent curriculum scope explicitly", () => { - // `investigation`/`problem` are written as null rather than omitted: a null scope field means - // "absent scope" (firestore.rules hasScopeField), which is what makes the class+unit scope - // queryable — `where("investigation", "==", null)` cannot match a missing field. - expect(getDocumentScopeFields("testClassWideKind", ctx)).toEqual({ + it("stamps the unit and class, and states the absent curriculum explicitly", () => { + // `investigation`/`problem` are written as null rather than omitted: a null field means "not about + // an investigation or problem" (firestore.rules hasScopeField), which is what makes a class+unit + // document queryable — `where("investigation", "==", null)` cannot match a missing field. + expect(getDocumentLocationFields("testClassWideKind", ctx)).toEqual({ unit: "sas", context_id: "class-hash", investigation: null, @@ -211,16 +229,14 @@ describe("document kinds registry", () => { }); }); - it("does not stamp an offering or a group", () => { - const fields = getDocumentScopeFields("testClassWideKind", ctx); - expect(fields.offeringId).toBeUndefined(); - expect(fields.groupId).toBeUndefined(); + it("does not stamp an offering", () => { + expect(getDocumentLocationFields("testClassWideKind", ctx).offeringId).toBeUndefined(); }); - it("leaves the group scope unchanged", () => { - expect(getDocumentScopeFields(GroupDocument, ctx)).toEqual({ + it("leaves the group kind's location unchanged", () => { + expect(getDocumentLocationFields(GroupDocument, ctx)).toEqual({ unit: "sas", investigation: "1", problem: "2", - context_id: "class-hash", offeringId: "off-1", groupId: "3" + context_id: "class-hash", offeringId: "off-1" }); }); }); diff --git a/src/models/document/document-kinds.ts b/src/models/document/document-kinds.ts index b54a973bd6..78bd2d32cc 100644 --- a/src/models/document/document-kinds.ts +++ b/src/models/document/document-kinds.ts @@ -20,9 +20,13 @@ export type IDocumentKindMetadataFields = Pick; /** How this kind's owner uid is derived (see DocumentOwnerType). */ ownerType: DocumentOwnerType; - /** How this kind's scope axes are derived (see DocumentScopeType). */ - scopeType: DocumentScopeType; + /** Which container this kind's documents live in, and their curriculum reach (see DocumentContainerType). */ + containerType: DocumentContainerType; /** * Static document display title. Leave undefined for dynamic titles like * group documents or in the future problem documents. @@ -115,42 +119,53 @@ export function getDocumentOwner(kind: string|null|undefined, ctx: IDocumentOwne } /** - * The scope fields a document draws from its runtime context, supplied by the caller because they depend on - * the user's class, current group, offering, unit, and problem. Doubles as the return shape of - * getDocumentScopeFields (the subset a given kind actually stamps). + * The stored owner-axis fields besides `uid`. A group owner's `groupId` is stored alongside the owner uid + * that already encodes it (`group__`), so Firestore rules and group-member lookups can + * read the group without parsing the uid. + */ +export interface IDocumentOwnerFields { + groupId?: string; +} + +/** + * The owner fields to stamp on a new document of the given kind, beyond the owner uid getDocumentOwner + * returns. Only a group owner has one. + */ +export function getDocumentOwnerFields( + kind: string|null|undefined, ctx: { groupId?: string } +): IDocumentOwnerFields { + if (getDocumentOwnerType(kind) !== "group" || !ctx.groupId) return {}; + return { groupId: ctx.groupId }; +} + +/** + * The container and curriculum values a document draws from its runtime context, supplied by the caller + * because they depend on the user's class, offering, unit, and problem. Doubles as the return shape of + * getDocumentLocationFields (the subset a given kind actually stamps). */ -export interface IDocumentScopeContext { +export interface IDocumentLocationContext { unit: string | null; investigation?: string | null; problem?: string | null; context_id: string; - groupId?: string; offeringId?: string; } /** - * The scope fields to stamp on a document of the given kind, selected by its registered `scopeType`. + * The container and curriculum fields to stamp on a document of the given kind, selected by its registered + * `containerType` — which fixes both, so one lookup answers for both axes. */ -export function getDocumentScopeFields( - kind: string|null|undefined, ctx: IDocumentScopeContext -): IDocumentScopeContext { - const scopeType = getDocumentKindInfo(kind)?.scopeType; - switch (scopeType) { - case "group": return { - unit: ctx.unit, - investigation: ctx.investigation, - problem: ctx.problem, - context_id: ctx.context_id, - offeringId: ctx.offeringId, - groupId: ctx.groupId - }; +export function getDocumentLocationFields( + kind: string|null|undefined, ctx: IDocumentLocationContext +): IDocumentLocationContext { + switch (getDocumentKindInfo(kind)?.containerType) { case "classUnit": return { unit: ctx.unit, context_id: ctx.context_id, - // Stated explicitly rather than omitted. A scope field written as null means "absent scope" - // (firestore.rules `hasScopeField`), the same convention class-scoped documents use for - // `unit: null`. It is what lets Sort Work query for documents scoped to a unit but not to a - // problem — Firestore cannot match a field that is missing. + // Stated explicitly rather than omitted. A curriculum field written as null means "not about an + // investigation or problem" (firestore.rules `hasScopeField`), the same convention class-contained + // documents use for `unit: null`. It is what lets Sort Work query for documents about a unit but not + // a problem — Firestore cannot match a field that is missing. investigation: null, problem: null }; @@ -222,16 +237,18 @@ export function getDocumentKindLabel(kind?: string | null): string | undefined { } function registerBuiltInDocumentKinds() { + // A group document is kept in the offering, like the problem documents beside it; what makes it a group's + // is its owner, which is also where its stored `groupId` comes from (see getDocumentOwnerFields). registerDocumentKind(GroupDocument, { metadataFields: { concurrent: true }, ownerType: "group", - scopeType: "group" + containerType: "offering" }); const personalLikeKindInfo = { metadataFields: { }, ownerType: "user", - scopeType: "class" + containerType: "class" } as const; registerDocumentKind(PersonalDocument, personalLikeKindInfo); registerDocumentKind(LearningLogDocument, personalLikeKindInfo); @@ -241,7 +258,7 @@ function registerBuiltInDocumentKinds() { const problemLikeKindInfo = { metadataFields: { }, ownerType: "user", - scopeType: "offering" + containerType: "offering" } as const; registerDocumentKind(PlanningDocument, problemLikeKindInfo); registerDocumentKind(ProblemDocument, problemLikeKindInfo); diff --git a/src/models/document/document-scope.test.ts b/src/models/document/document-scope.test.ts deleted file mode 100644 index 97975b97ed..0000000000 --- a/src/models/document/document-scope.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getCurriculumScopeLabel, hasGroupOwnerScope, hasUnitCurriculumScope } from "./document-scope"; - -describe("document scope guards", () => { - // One case per document shape CLUE stores, so the guards are pinned against every shape they - // must distinguish rather than only the two this feature introduces. - const personal = { unit: null, investigation: null, groupId: null }; - const problem = { unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: null }; - const group = { unit: "sas", investigation: "1", problem: "2", offeringId: "off-1", groupId: "3" }; - const exemplar = { unit: "qa", investigation: "1", problem: "1" }; // curriculum-authored: no offering - const classWide = { unit: "sas", investigation: null, groupId: null }; - const legacyClassWide = { unit: "sas" }; // created before investigation/problem were stamped - - describe("hasGroupOwnerScope", () => { - it("is true only when the document carries a group id", () => { - expect(hasGroupOwnerScope(group)).toBe(true); - expect(hasGroupOwnerScope(personal)).toBe(false); - expect(hasGroupOwnerScope(problem)).toBe(false); - expect(hasGroupOwnerScope(exemplar)).toBe(false); - expect(hasGroupOwnerScope(classWide)).toBe(false); - }); - - it("reads only the owner dimension, whatever the curriculum scope holds", () => { - // The two dimensions are independent: a group id decides this guard on its own. - expect(hasGroupOwnerScope({ groupId: "3" })).toBe(true); - expect(hasGroupOwnerScope({ unit: "sas", groupId: "3" })).toBe(true); - }); - }); - - describe("hasUnitCurriculumScope", () => { - it("is true for a document scoped to a unit and nothing narrower", () => { - expect(hasUnitCurriculumScope(classWide)).toBe(true); - expect(hasUnitCurriculumScope(legacyClassWide)).toBe(true); - }); - - it("is false for every other curriculum position", () => { - expect(hasUnitCurriculumScope(personal)).toBe(false); // no unit - expect(hasUnitCurriculumScope(problem)).toBe(false); // narrowed to an investigation - expect(hasUnitCurriculumScope(group)).toBe(false); // narrowed to an investigation - expect(hasUnitCurriculumScope(exemplar)).toBe(false); // narrowed to an investigation - }); - - it("is false for a document in an offering, which assigns one problem", () => { - // An offering narrows the curriculum dimension on its own, whatever the other fields hold. - expect(hasUnitCurriculumScope({ unit: "sas", offeringId: "off-1" })).toBe(false); - }); - - it("reads only the curriculum dimension, whatever the owner scope holds", () => { - // A group id does not narrow curriculum scope, so it cannot decide this guard. No kind creates - // this shape today; the guard answers about its own dimension regardless. - expect(hasUnitCurriculumScope({ unit: "sas", groupId: "3" })).toBe(true); - }); - - it("treats an empty-string unit as no unit", () => { - expect(hasUnitCurriculumScope({ unit: "", investigation: null, groupId: null })).toBe(false); - }); - }); - - describe("getCurriculumScopeLabel", () => { - it("names the problem a document belongs to", () => { - expect(getCurriculumScopeLabel(problem)).toBe("sas-1.2"); - expect(getCurriculumScopeLabel(group)).toBe("sas-1.2"); - expect(getCurriculumScopeLabel(exemplar)).toBe("qa-1.1"); - }); - - it("names the unit alone when the document is scoped no narrower", () => { - expect(getCurriculumScopeLabel(classWide)).toBe("sas"); - expect(getCurriculumScopeLabel(legacyClassWide)).toBe("sas"); - }); - - it("returns undefined when the document has no unit", () => { - expect(getCurriculumScopeLabel(personal)).toBeUndefined(); - expect(getCurriculumScopeLabel({ unit: "" })).toBeUndefined(); - }); - - it("keeps the investigation when a document has one but no problem", () => { - // No registered scope type produces this shape; the label degrades rather than dropping it. - expect(getCurriculumScopeLabel({ unit: "sas", investigation: "1" })).toBe("sas-1.x"); - }); - }); -}); diff --git a/src/models/document/document-scope.ts b/src/models/document/document-scope.ts deleted file mode 100644 index bbdf7a7529..0000000000 --- a/src/models/document/document-scope.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Guards over a document's stored scope association fields (`context_id`, `unit`, `investigation`, - * `problem`, `offeringId`, `groupId`), stamped at creation from the kind's registered `scopeType` - * (see document-kinds.ts). - * - * Each guard answers about one axis, reading only that axis's fields; a consumer needing a position on - * more than one asks each. See docs/document-axes/reading-axes-in-code.md for the field-by-shape table - * and what is not covered yet, and docs/document-axes/axes.md for what the axes mean. - * - * A guard reads stored fields only, never the kind registry: Sort Work lists documents from other - * units, whose kinds are not registered in the current session. - */ - -/** The scope fields the guards read. Structural, so this stays a leaf module. */ -export interface IDocumentScopeFields { - unit?: string | null; - investigation?: string | null; - problem?: string | null; - offeringId?: string | null; - groupId?: string | null; -} - -/** - * Owner scope: the document belongs to a single group, whoever created it. - * - * Only group-scoped documents carry a `groupId`; others leave it unset so a stale group id can never - * be read back, since a user's group may change (see DocumentMetadataModel.groupId). - */ -export function hasGroupOwnerScope(doc: IDocumentScopeFields): doc is IDocumentScopeFields & { groupId: string } { - return !!doc.groupId; -} - -/** - * Curriculum scope: the document belongs to a unit and nothing narrower. - * - * Both negative terms narrow this same dimension — an `investigation` directly, an `offeringId` - * because an offering assigns one problem. It says nothing about who owns the document. - */ -export function hasUnitCurriculumScope(doc: IDocumentScopeFields): boolean { - return !!doc.unit && !doc.investigation && !doc.offeringId; -} - -/** - * A short label for a document's curriculum scope: "sas-1.2" when it is scoped to a problem, "sas" - * when it is scoped to a unit and nothing narrower, undefined when it has no unit at all. - * - * Callers use it as a stand-in when a document's real title cannot be resolved, so the coordinates - * name the document instead. It reads the stored fields alone, so it describes a document from any - * unit, including one whose config is not loaded. - * - * An investigation with no problem ("sas-1.x") is not a shape any registered scope type produces; it - * is handled so a partial scope still reads as a scope rather than losing the investigation. - */ -export function getCurriculumScopeLabel(doc: IDocumentScopeFields): string | undefined { - if (!doc.unit) return undefined; - if (!doc.investigation) return doc.unit; - return `${doc.unit}-${doc.investigation}.${doc.problem ?? "x"}`; -} diff --git a/src/models/document/document-utils.test.ts b/src/models/document/document-utils.test.ts index 609cd34864..dd383325bc 100644 --- a/src/models/document/document-utils.test.ts +++ b/src/models/document/document-utils.test.ts @@ -3,8 +3,8 @@ import { AppConfigModel } from "../stores/app-config-model"; import { DocumentMetadataModel } from "../document/document-metadata-model"; import { UserModel } from "../stores/user"; import { createDocumentModel } from "./document"; -import { GroupDocument, PersonalDocument, ProblemDocument, ProblemPublication, SupportPublication } - from "./document-types"; +import { ExemplarDocument, GroupDocument, PersonalDocument, ProblemDocument, ProblemPublication, + SupportPublication } from "./document-types"; import { canUserEditDocument, getDocumentDisplayTitle, isDocumentAccessibleToUser } from "./document-utils"; import { registerDocumentKind } from "./document-kinds"; import { unitConfigDefaults } from "../../test-fixtures/sample-unit-configurations"; @@ -194,7 +194,7 @@ describe("document utils", () => { test("a class-wide document uses its kind's registered title (resolved by kind, not stored)", () => { registerDocumentKind("testClassWideTitle", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit", title: "Driving Question Board" }); const metadata = DocumentMetadataModel.create({ @@ -222,7 +222,7 @@ describe("document utils", () => { test("does not borrow the current unit's title for another unit's document of the same kind", () => { registerDocumentKind("testSharedKind", { - metadataFields: { concurrent: true }, ownerType: "class", scopeType: "classUnit", + metadataFields: { concurrent: true }, ownerType: "class", containerType: "classUnit", title: "Our Big Questions", unit: "test" }); const ownUnitDoc = DocumentMetadataModel.create({ @@ -259,6 +259,16 @@ describe("document utils", () => { const metadata = (props: Record) => DocumentMetadataModel.create({ uid: "someone-else", type: GroupDocument, key: "k", ...props }); + /** + * A group document as the app actually stamps it: kept in an offering, so it carries an + * `offeringId` and the owning class. Both matter — without the `offeringId` the document reads as + * class-wide, and the class check would then let any classmate edit another group's work. + */ + const groupDocMetadata = (groupId: string, offeringId = kOffering) => metadata({ + uid: groupOwner(groupId, offeringId), concurrent: true, groupId, + unit: "sas", investigation: "1", problem: "2", offeringId, context_id: "class-1" + }); + it("allows a user to edit their own document", () => { expect(canUserEditDocument({ documentMetadata: metadata({ uid: "me", type: ProblemDocument }), user: student @@ -273,19 +283,15 @@ describe("document utils", () => { it("allows a member of the owning group to edit a group document", () => { expect(canUserEditDocument({ - documentMetadata: metadata({ - uid: groupOwner("3"), concurrent: true, groupId: "3", unit: "sas", investigation: "1" - }), - user: groupedStudent + documentMetadata: groupDocMetadata("3"), user: groupedStudent })).toBe(true); }); - it("refuses another group's document", () => { + it("refuses another group's document, even to a classmate of its owners", () => { + // The document is in this student's own class, so only its offering keeps the class-wide branch + // from reaching it. expect(canUserEditDocument({ - documentMetadata: metadata({ - uid: groupOwner("7"), concurrent: true, groupId: "7", unit: "sas", investigation: "1" - }), - user: groupedStudent + documentMetadata: groupDocMetadata("7"), user: groupedStudent })).toBe(false); }); @@ -293,20 +299,13 @@ describe("document utils", () => { // Sort Work's "All" filter lists documents from every offering the class has worked through, // so this document does reach the check. Group ids are unique only within an offering. expect(canUserEditDocument({ - documentMetadata: metadata({ - uid: groupOwner("3", "other-offering"), concurrent: true, groupId: "3", - unit: "sas", investigation: "1" - }), - user: groupedStudent + documentMetadata: groupDocMetadata("3", "other-offering"), user: groupedStudent })).toBe(false); }); it("refuses a group document when the user is not in a group", () => { expect(canUserEditDocument({ - documentMetadata: metadata({ - uid: groupOwner("3"), concurrent: true, groupId: "3", unit: "sas", investigation: "1" - }), - user: student + documentMetadata: groupDocMetadata("3"), user: student })).toBe(false); }); @@ -328,6 +327,31 @@ describe("document utils", () => { })).toBe(false); }); + // An exemplar belongs to no offering, so it sits in the same container as a class-wide document + // and `isInClassUnitContainer` is true for it. What keeps it read-only is that it is single-writer. + it("refuses a curriculum exemplar", () => { + expect(canUserEditDocument({ + document: createDocumentModel({ + uid: "ivan_idea_1", type: ExemplarDocument, key: "ex-1", + unit: "sas", investigation: "1", problem: "2" + }), + user: student + })).toBe(false); + }); + + it("refuses the metadata record written when a teacher comments on an exemplar", () => { + // Unlike the curriculum document it mirrors, this record is stamped with the commenting class's + // `context_id` (create-firestore-metadata-document.ts), so the class check inside the container + // branch would pass. Being single-writer is the only thing standing between it and an Edit button. + expect(canUserEditDocument({ + documentMetadata: metadata({ + uid: "ivan_idea_1", type: ExemplarDocument, + unit: "sas", investigation: "1", problem: "2", context_id: "class-1" + }), + user: student + })).toBe(false); + }); + it("refuses a document that is not concurrent even inside the user's own scope", () => { expect(canUserEditDocument({ documentMetadata: metadata({ unit: "sas", investigation: null, context_id: "class-1" }), diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index 3441caece0..d1480bddb8 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -10,7 +10,7 @@ import { AppConfigModelType } from "../stores/app-config-model"; import { UserModelType } from "../stores/user"; import { DocumentModelType, IExemplarVisibilityProvider } from "./document"; import { DocumentContentModelType } from "./document-content"; -import { getCurriculumScopeLabel, hasUnitCurriculumScope } from "./document-scope"; +import { getCurriculumLabel, isInClassUnitContainer } from "./document-axes"; import { getDocumentKindLabel, getDocumentTitle } from "./document-kinds"; import { GroupDocument, isExemplarType, isPlanningType, isProblemType, isPublishedType, isSupportType } from "./document-types"; @@ -34,26 +34,26 @@ function getDocumentTitleFromProblem(currentUnit: UnitModelType, document: Docum } const upperType = upperFirst(document.type); - const scopeLabel = getCurriculumScopeLabel(document); - if (!scopeLabel) { + const curriculumLabel = getCurriculumLabel(document); + if (!curriculumLabel) { return `${upperType} doc without ${translate("contentLevel.unit")}`; } - return `${upperType} doc from ${scopeLabel}`; + return `${upperType} doc from ${curriculumLabel}`; } /** * A stand-in title for a document that stores none and whose kind resolves no title — a class-wide * document from a unit whose config is not loaded, or one whose kind another unit also declares. The - * kind names what the document is; the curriculum scope says where it came from. + * kind names what the document is; the curriculum position says where it came from. * - * The scope is read from the stored fields rather than assumed, because an unresolvable kind gives no - * indication of how the document is scoped. + * The position is read from the stored fields rather than assumed, because an unresolvable kind gives + * no indication of where the document sits. */ function getUnresolvedDocumentTitle(document: DocumentModelType | IDocumentMetadataModel) { const kindLabel = getDocumentKindLabel(document.kind); if (!kindLabel) return undefined; - const scopeLabel = getCurriculumScopeLabel(document); - return scopeLabel ? `${kindLabel} (${scopeLabel})` : kindLabel; + const curriculumLabel = getCurriculumLabel(document); + return curriculumLabel ? `${kindLabel} (${curriculumLabel})` : kindLabel; } export function getDocumentTitleWithTimestamp( @@ -88,7 +88,7 @@ export function getDocumentDisplayTitle( } else { const storedTitle = getDocumentTitleWithTimestamp(document, appConfig); if (storedTitle) return storedTitle; - // Nothing stored and no kind title: name it by kind and scope if we can, otherwise return the + // Nothing stored and no kind title: name it by kind and curriculum position if we can, otherwise return the // stored value unchanged so callers see the same empty result as before. return getUnresolvedDocumentTitle(document) ?? storedTitle; } @@ -148,7 +148,7 @@ export function isDocumentAccessibleToUser ({ * `properties?: Record` while the MST `DocumentMetadataModel` holds an observable * map there, so a metadata model instance is not assignable to it. `isDocumentAccessibleToUser` * sidesteps the same problem by taking `IDocumentMetadataBase`, which has no `properties` — this - * adds the two axis/scope fields the base type lacks. + * adds the two axis fields the base type lacks. */ type IEditPermissionMetadata = IDocumentMetadataBase & { concurrent?: boolean | null; @@ -169,7 +169,7 @@ interface ICanUserEditDocumentParams { * alone can't tell a live document from its published copy. A researcher never gets an edit * affordance, even inside a class or group they observe. Beyond those exclusions, a user may * always edit their own document; otherwise only a `concurrent` (multi-writer) document is - * editable by someone other than its owner, and then only from inside its scope: a class-wide + * editable by someone other than its owner, and then only by someone the document reaches: a class-wide * document by any member of its class (teachers included — they belong to the class too), a group * document by any member of its group. * @@ -184,17 +184,17 @@ export function canUserEditDocument({ const type = documentMetadata?.type ?? document?.type; const concurrent = documentMetadata?.concurrent ?? document?.concurrent; const unit = documentMetadata?.unit ?? document?.unit; - const investigation = documentMetadata?.investigation ?? document?.investigation; + const offeringId = documentMetadata?.offeringId ?? document?.offeringId; const contextId = documentMetadata?.context_id ?? document?.contextId; if (type && isPublishedType(type)) return false; if (!!uid && uid === user.id) return true; if (user.isResearcher) return false; if (!concurrent) return false; - // Beyond this point the user must be inside the document's scope, asked at the narrowest level the - // document is scoped to. + // Beyond this point the user must be inside the document's container, asked at the narrowest level the + // document is kept at. A group document is kept in an offering, so its group is asked first. if (isUserInDocumentsGroup(uid, user)) return true; - if (hasUnitCurriculumScope({ unit, investigation })) { + if (isInClassUnitContainer({ unit, offeringId })) { return !!contextId && contextId === user.classHash; } return false; diff --git a/src/models/stores/document-group.ts b/src/models/stores/document-group.ts index bfd05b3d1c..67620a5f61 100644 --- a/src/models/stores/document-group.ts +++ b/src/models/stores/document-group.ts @@ -8,7 +8,7 @@ import { } from "../../utilities/sort-document-utils"; import { upperWords } from "../../utilities/string-utils"; import { translate } from "../../utilities/translation/translate"; -import { hasGroupOwnerScope, hasUnitCurriculumScope } from "../document/document-scope"; +import { hasClassOwner, hasGroupOwner } from "../document/document-axes"; import { IDocumentMetadataModel } from "../document/document-metadata-model"; import { getTileComponentInfo } from "../tiles/tile-component-info"; import { getTileContentInfo } from "../tiles/tile-content-info"; @@ -195,21 +195,21 @@ export class DocumentGroup { const sortKeys: Map = new Map(); const groupSection = (groupId: string) => - ({ sectionLabel: `${groupTerm} ${groupId}`, sortKey: { scope: "group", groupId } as GroupSectionSortKey }); + ({ sectionLabel: `${groupTerm} ${groupId}`, sortKey: { section: "group", groupId } as GroupSectionSortKey }); this.documents.forEach((doc) => { const { sectionLabel, sortKey } = (() => { - // A document scoped to a group belongs to that group, whoever created it. - if (hasGroupOwnerScope(doc)) return groupSection(doc.groupId); - // A document that spans a whole unit is not one student's work; it belongs to the class. - if (hasUnitCurriculumScope(doc)) { - return { sectionLabel: kWholeClassSectionLabel, sortKey: { scope: "class" } as GroupSectionSortKey }; + // A document owned by a group belongs to that group, whoever created it. + if (hasGroupOwner(doc)) return groupSection(doc.groupId); + // A class-owned document is not one student's work, so it sections under the class itself. + if (hasClassOwner(doc)) { + return { sectionLabel: kWholeClassSectionLabel, sortKey: { section: "class" } as GroupSectionSortKey }; } // Otherwise it belongs to its owner, and so to whichever group its owner is in now. const group = this.stores.groups.groupForUser(doc.uid); return group ? groupSection(group.id) - : { sectionLabel: `No ${groupTerm}`, sortKey: { scope: "none" } as GroupSectionSortKey }; + : { sectionLabel: `No ${groupTerm}`, sortKey: { section: "none" } as GroupSectionSortKey }; })(); if (!documentMap.has(sectionLabel)) { @@ -237,17 +237,15 @@ export class DocumentGroup { }; this.documents.forEach((doc) => { - // Owner scope decides the section, so it is asked first, narrowest level outward. - if (hasGroupOwnerScope(doc)) { + // The owner decides the section, so it is asked first, narrowest level outward. + if (hasGroupOwner(doc)) { // A group document is listed under every member of the group that owns it. const group = this.stores.groups.getGroupById(doc.groupId); group?.users.forEach(user => { addDocForUser(doc, user.classUser); }); - } else if (hasUnitCurriculumScope(doc)) { - // A document spanning a whole unit is owned by the class, so it has no personal author. This - // reads curriculum scope to answer an owner-scope question: the class owner is a synthetic uid - // (`class_`), and no guard reads it yet. Once one does, ask that instead. + } else if (hasClassOwner(doc)) { + // A class-owned document has no personal author to file it under. addDocToSection(doc, kNoNameSectionLabel); } else { addDocForUser(doc, this.stores.class.getUserById(doc.uid)); diff --git a/src/utilities/sort-document-utils.test.ts b/src/utilities/sort-document-utils.test.ts index 2842e2a74f..8ca3e02639 100644 --- a/src/utilities/sort-document-utils.test.ts +++ b/src/utilities/sort-document-utils.test.ts @@ -212,10 +212,10 @@ describe("sort-document-utils", () => { it("puts the whole-class section first, groups in numeric order, and no-group last", () => { const labels = ["No Group", "Group 10", "Whole Class", "Group 2"]; const sorted = sortGroupSections(labels, keys([ - ["No Group", { scope: "none" }], - ["Group 10", { scope: "group", groupId: "10" }], - ["Whole Class", { scope: "class" }], - ["Group 2", { scope: "group", groupId: "2" }], + ["No Group", { section: "none" }], + ["Group 10", { section: "group", groupId: "10" }], + ["Whole Class", { section: "class" }], + ["Group 2", { section: "group", groupId: "2" }], ])); expect(sorted).toEqual(["Whole Class", "Group 2", "Group 10", "No Group"]); }); @@ -223,9 +223,9 @@ describe("sort-document-utils", () => { it("orders non-numeric group ids after numeric ones, alphabetically", () => { const labels = ["Group b", "Group 3", "Group a"]; const sorted = sortGroupSections(labels, keys([ - ["Group b", { scope: "group", groupId: "b" }], - ["Group 3", { scope: "group", groupId: "3" }], - ["Group a", { scope: "group", groupId: "a" }], + ["Group b", { section: "group", groupId: "b" }], + ["Group 3", { section: "group", groupId: "3" }], + ["Group a", { section: "group", groupId: "a" }], ])); expect(sorted).toEqual(["Group 3", "Group a", "Group b"]); }); @@ -235,14 +235,14 @@ describe("sort-document-utils", () => { // per unit, so "Team 2" and "Group 2" must sort identically. const labels = ["Team 10", "Team 2"]; const sorted = sortGroupSections(labels, keys([ - ["Team 10", { scope: "group", groupId: "10" }], - ["Team 2", { scope: "group", groupId: "2" }], + ["Team 10", { section: "group", groupId: "10" }], + ["Team 2", { section: "group", groupId: "2" }], ])); expect(sorted).toEqual(["Team 2", "Team 10"]); }); it("treats a label with no sort key as no-group", () => { - const sorted = sortGroupSections(["Mystery", "Whole Class"], keys([["Whole Class", { scope: "class" }]])); + const sorted = sortGroupSections(["Mystery", "Whole Class"], keys([["Whole Class", { section: "class" }]])); expect(sorted).toEqual(["Whole Class", "Mystery"]); }); }); diff --git a/src/utilities/sort-document-utils.ts b/src/utilities/sort-document-utils.ts index 8d6fe8bb6d..b7932c245a 100644 --- a/src/utilities/sort-document-utils.ts +++ b/src/utilities/sort-document-utils.ts @@ -53,11 +53,11 @@ export const kNoNameSectionLabel = "No Name"; * (`studentGroup` is overridable per unit) and has no number at all for some sections. */ export type GroupSectionSortKey = - | { scope: "class" } - | { scope: "group"; groupId: string } - | { scope: "none" }; + | { section: "class" } + | { section: "group"; groupId: string } + | { section: "none" }; -const kGroupSectionScopeOrder: Record = { +const kGroupSectionOrder: Record = { class: 0, group: 1, none: 2, @@ -68,14 +68,14 @@ const kGroupSectionScopeOrder: Record = { * no-group section. A section with no sort key is ordered as if it had none. */ export const sortGroupSections = (docMapKeys: string[], sortKeys: Map) => { - const keyFor = (label: string): GroupSectionSortKey => sortKeys.get(label) ?? { scope: "none" }; + const keyFor = (label: string): GroupSectionSortKey => sortKeys.get(label) ?? { section: "none" }; return docMapKeys.sort((a, b) => { const keyA = keyFor(a); const keyB = keyFor(b); - if (keyA.scope !== keyB.scope) { - return kGroupSectionScopeOrder[keyA.scope] - kGroupSectionScopeOrder[keyB.scope]; + if (keyA.section !== keyB.section) { + return kGroupSectionOrder[keyA.section] - kGroupSectionOrder[keyB.section]; } - if (keyA.scope === "group" && keyB.scope === "group") { + if (keyA.section === "group" && keyB.section === "group") { const numA = parseInt(keyA.groupId, 10); const numB = parseInt(keyB.groupId, 10); // Group ids are numeric in practice; order any non-numeric id after the numeric ones rather From d991b1003822b7f1f7410667ef089591a9530354 Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 15:56:42 -0400 Subject: [PATCH 25/51] refactor: canUserEditDocument chooses one metadata source instead of six [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate read each field as `documentMetadata?.x ?? document?.x`, which implied the two sources could disagree and that the fresher one should win per field. They cannot: every field it reads — `uid`, `type`, `concurrent`, `unit`, `offeringId`, `context_id` — is stamped once at creation. The fallback exists for a simpler reason, that one of the two call sites supplies no metadata at all: the workspace opens documents without looking their metadata up, while Sort Work passes both. So choose the source outright, as `isDocumentAccessibleToUser` directly above already does, and hand it to `isInClassUnitContainer` whole rather than picking fields out at the call site. This also removes an unintended fallthrough. `DocumentMetadataModel` fields are `maybeNull` and `??` falls through on null, so a class-contained document — which stores `unit: null` explicitly — was reading its unit off the document instead. The answer was the same, but the sources were being mixed in a case nobody chose. Co-Authored-By: Claude Opus 5 (1M context) --- src/models/document/document-utils.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/models/document/document-utils.ts b/src/models/document/document-utils.ts index d1480bddb8..671c5432e5 100644 --- a/src/models/document/document-utils.ts +++ b/src/models/document/document-utils.ts @@ -173,19 +173,19 @@ interface ICanUserEditDocumentParams { * document by any member of its class (teachers included — they belong to the class too), a group * document by any member of its group. * - * Fields are read from the reactive Firestore metadata, falling back per field to the lazily-fetched - * full document. A groupmate's document appears in the metadata before its content finishes loading, - * and reading it per field is what lets the Edit button appear without a reload. + * The Firestore metadata is preferred over the loaded document, because it is reactive and arrives + * first: a groupmate's document is listed before its content finishes loading, and the Edit button + * appears without a reload. The document is the fallback for the workspace, which opens documents + * without looking their metadata up. Every field read here is stamped once at creation, so the two + * sources never disagree — which is why one is chosen outright rather than field by field. */ export function canUserEditDocument({ document, documentMetadata, user }: ICanUserEditDocumentParams): boolean { - const uid = documentMetadata?.uid ?? document?.uid; - const type = documentMetadata?.type ?? document?.type; - const concurrent = documentMetadata?.concurrent ?? document?.concurrent; - const unit = documentMetadata?.unit ?? document?.unit; - const offeringId = documentMetadata?.offeringId ?? document?.offeringId; - const contextId = documentMetadata?.context_id ?? document?.contextId; + const metadata = documentMetadata ?? document?.metadata; + if (!metadata) return false; + + const { uid, type, concurrent, context_id: contextId } = metadata; if (type && isPublishedType(type)) return false; if (!!uid && uid === user.id) return true; @@ -194,7 +194,7 @@ export function canUserEditDocument({ // Beyond this point the user must be inside the document's container, asked at the narrowest level the // document is kept at. A group document is kept in an offering, so its group is asked first. if (isUserInDocumentsGroup(uid, user)) return true; - if (isInClassUnitContainer({ unit, offeringId })) { + if (isInClassUnitContainer(metadata)) { return !!contextId && contextId === user.classHash; } return false; From 5abb7884fb88ab0742c1d03450e6c3ef94f96efe Mon Sep 17 00:00:00 2001 From: Scott Cytacki Date: Wed, 29 Jul 2026 19:41:43 -0400 Subject: [PATCH 26/51] refactor: address canonical slots as container + owner + label [CLUE-610] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A canonical slot is a container plus an owner plus a label, but the pointer path expressed the owner only for group documents, and expressed it as `groups/`: canonical/v1/classes//(offerings/|units/)/[groups/]/slots/