CLUE-610: Sort Work + workspace UI for class-wide documents - #2949
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…sses [CLUE-610] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…UE-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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ment [CLUE-610] 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) <noreply@anthropic.com>
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/<classHash> subtree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thorization [CLUE-610] 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…cs, narrow hasGroupScope [CLUE-610] 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…LUE-610] 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…riculum [CLUE-610] 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_<classHash>` 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) <noreply@anthropic.com>
…six [CLUE-610] 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) <noreply@anthropic.com>
] 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/<groupId>`: canonical/v1/classes/<h>/(offerings/<off>|units/<u>)/[groups/<grp>]/slots/<label> Every slot now carries an owner segment holding the document's own `uid`, synthetic owners included: canonical/v1/classes/<h>/[offerings/<off>|units/<u>]/owners/<uid>/slots/<label> Taking the owner from `uid` is what makes this a simplification rather than another branch. The rules already have `data.uid`, so `canonicalPointerPath` drops its `groupId` conditional and `data.groupId` disappears from firestore.rules entirely — no group id to read, no owner type to infer. The two create blocks now cover every owner type, so they scale with container levels rather than container x owner, and the anticipated user-owned slot for problem/planning documents needs no new segment form. The class still leads the path: the read rule compares that segment to the caller's class claim, which a synthetic group owner id cannot supply. Below it each container names only itself — an offering contributes its own id, not the classUnit it falls inside. Pointers are immutable by rule and so cannot be moved; addressing each by its own container is what would let a container level be inserted later without stranding the pointers that already exist. Callers now pass the container and let getOrCreateCanonicalDocument derive the owner from the kind, through the same getDocumentOwner call that stamps the document's `uid`. Previously the caller assembled the path's owner while the registry derived the document's; with the owner in the path, a divergence would surface as a rules rejection instead of being caught here. getDocumentOwner now throws for an unregistered kind instead of defaulting to the creating user. Defaulting would hand a group's or a class's document to whoever created it and file it in that user's slot, both silently. It also confines document creation to the kinds the current unit declares, since a unit-declared kind is registered only while its unit is loaded. Two db tests were relying on that default, calling getOrCreateClassWideDocument without the registration production performs first; they now register the kind. Existing group-document pointers self-heal: the new path misses, findLegacyGroupDocument adopts the document, and the backfill writes a pointer at the new path. Class-wide documents have no legacy fallback, so a dev class holding one gets a second; they are unreleased, so no production data is affected. Pointers at the old paths become unreachable orphans. Also renames the rules' `hasScopeField` to `hasPresentField`, which is what it tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLUE-610] A group id is unique only within an offering: group 3 of one assignment and group 3 of the next are different sets of students. Sort Work's "All" filter spans every offering the class has worked through, and its by-name sort looked the group up with `getGroupById(doc.groupId)` against a store that holds only the current offering's groups. So a group document from an earlier assignment was listed under whoever is in this offering's group 3 now. Worse, when this offering had no group of that number, the lookup returned undefined, `group?.users.forEach` short-circuited, and nothing filed the document at all — it vanished from the sort. The groups store now records which offering it holds, and `getGroupByOwnerId` matches a document's whole owner uid, building each candidate with `getGroupOwnerId` rather than taking a stored id apart. A group outside that offering resolves to nothing, which is the truth: its membership is not knowable from here. Those documents get one section per assignment rather than per group — a class works through many, and a section per group of each would swamp the sort. Every document in an offering shares a curriculum position, so `getCurriculumLabel` names the section: "Groups from sas-2.4". The by-name sort gained sort keys, like the by-group sort already had, so these sections and the authorless one order after the named students instead of landing alphabetically among the surnames. That moves "No Name" to the end, matching the by-group sort's trailing "No Group"; three tests documented the old position in their comments. Group membership often does carry over between assignments, which is why the wrong attribution usually looked right. If showing a previous assignment's groups by name is wanted, it needs that offering's membership loaded deliberately. `getGroupOwnerId` also gives the synthetic group-owner grammar one home, replacing the template literal in `userIdForGroupDocuments`. That getter now returns undefined when the user is in no group instead of building an id containing "undefined", and `getDocumentOwner` refuses to create a group- or class-owned document without its synthetic owner rather than falling back to the creating user — which would have made one student the owner and filed the document's canonical slot under them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er [CLUE-610] `hasGroupOwner` tested the stored `groupId`; it now tests the `group_` prefix of the owner uid, the same way `hasClassOwner` tests `class_`. Each prefix is a constant shared by the function that mints the uid and the guard that reads it back, so both owner guards read one field and the uid is the single authority on who a document belongs to — it is already what the canonical slot is addressed by and what the Firestore rules read. Testing a prefix is not the same as taking a uid apart: nothing recovers an `offeringId` or a `groupId` out of one, which is why `getGroupByOwnerId` builds the whole owner id and compares. `groupId` leaves `IDocumentAxisFields`, since no guard reads it. Sort Work's `byGroup` used the guard's type narrowing for its section label and now asks for the label explicitly; `byName` already went through `getGroupByOwnerId(doc.uid)` and is unchanged. Behavior is unchanged on stored data. A `groupId` with a non-`group_` uid has never been written — only group documents have ever had the field stamped — and a `group_` uid without a `groupId` does not exist, the two having been introduced together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLUE-610] `canUserEditDocument` answers a permissions question by composing the other axes by hand. Point at it from the axes docs as a legible example of that, and note in the docblock which axis it belongs to. Permission decisions are spread widely through the code, so the example is called out for being compact enough to read whole, not for coming first. Two consequences are recorded where it lives: its `type` test for published documents is the last type branch inside the gate, and its `concurrent` test would likely dissolve too, since a policy granting write to a class or group states the multi-writer case directly. What is left after that is not permission logic but resolving which class or group the grant points at, which the owner and container guards already answer. Docs only — no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… key [CLUE-610] The three `as GroupSectionSortKey` casts existed only because the object literals had no contextual type, so `section` widened to `string` and would not satisfy the union. Naming the label-and-sort-key pair and annotating the two functions that build one supplies that context, and `section` stays a literal. This is the mechanism `byName` already relied on: its `addDocToSection` parameters give its literals a contextual type, which is why it needed no casts. `byGroup` returns the pair rather than passing it, so the type had to be named. The casts were also the weaker kind — `as` would have accepted a wrong `section` value silently, where a typo is now a compile error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind name does not identify a definition on its own: a unit-declared kind exists only while that unit is loaded, and two units may declare the same kind meaning different things. getKindDefinitionFor(doc) makes that comparison the only way to read a definition off an existing document, so a consumer either handles the absent case or does not compile. Creation keeps looking kinds up by name, which is sound because a document being created takes its kind from the configuration in hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@tealefristoe thank you for another thorough review. I added one small change to this PR but haven't addressed your inline comments yet. I'm curious what you think of the draft PRs and new axis profile concept described below. Storing TitlesI was torn by the question of storing the titles in the class-wide documents or not. Since you wanted it, I had Claude draft what that would look like: #2962 The first thing this exploration turned up was that I should have made a Another thing this exposed more clearly was how we handle documents from other units. Before any of this work we already had to deal with this with problem documents. These do not have a title in the document. Their title is the name of the problem. In the sort work tab when looking at all documents the user can see these problem documents from other units. What we've done is name them by their curriculum position instead, like "Problem doc from sas-1.2". Since we don't have that unit's curriculum loaded we don't know the real title to give them. Along these same lines, even if we store the title of class-wide documents in the document itself, we should differentiate a Driving Question Board from the current unit and the Driving Question Board from another unit. So Claude came up with a rule for this:
Supporting this means there is actually a net increase in the number of production code lines (+20/−12). So it doesn't help with complexity. It does make it more user friendly though. The other thing this points out is that storing the title for class-wide documents would be inconsistent with what we do with problem documents. I think it makes sense to not store the title for problem documents. Given all of this my inclination is to leave it as is and not store the title. If we get complaints about not being able to show the correct title, then I think the best fix would be to load the unit that defines the titles for both the problem and class-wide documents. This way we solve both problems and keep everything consistent. Replacing Dynamic Kinds with a classWide kind and variantsI wanted to see what this would look like so Claude built a PR on top of the title removal PR to see what it would look like to add a new field to track the variant and remove dynamic kinds: #2963 Before looking at these changes my argument against this was that this goes against the point of this document axes refactoring. By making the kind "classWide" it seems we are treating the kind as if it is a type again. By having this as a kind it is really tempting to simplify places in the code that are currently using axis values and have them just look at the kind. For example in Sort Work when it is deciding how to organize the sections it could just check the kind instead of using the generic "hasClassOwner". And when deciding if someone can edit a document it would be easy to start just looking at the kind instead of what it currently does. After looking at the draft PR, adding the variant field doesn't really simplify the code. Just looking at production code it is +42/−41, a wash. It requires adding this field to several type definitions and then it needs to be used in several places instead of, or in addition to It does reduce the comments though. Those comments explained how a dynamic kind's definition is scoped to its config. One place that is simplified is The main simplification might be a conceptual one. However I find that supporting variants of kinds just shifts this concept to a new field. The actual documentation (written by Claude) actually got bigger by 3 lines. Looking at this the other way, one of the things you wanted was to enumerate every kind in one place. This is the reason that Axis Profiles (a new concept)What your comments prompted me to come up with was a label for something that is already happening in the code and documentation. And it addresses your strongest argument against dynamic kinds: migrations. If we keep dynamic kinds, we are referring to class-wide documents but really those don't exist as something in the state of documents. So what do we call this class-wide thing? Likewise in the code we have personalLikeKindInfo, problemLikeKindInfo, and group documents. These 4 bundles of axis values have four things we need from them:
So what I came up with was calling this concept a "document axis profile" or "axis profile" or just "profile". So we have a class-wide axis profile, and we perhaps say we have a problem axis profile and personal axis profile. Then taking this further we can use it to address two of your other concerns: migrations of dynamic kinds, and seeing all axis values in one place so we can make sure all known combinations are tested. By storing the axisProfile on the document itself it supports the migrations. Without it we'd have to search each unit for its dynamic kinds, then find documents with that unit and kind and then update those to the new axis values. With it we can just search for the axisProfile. Additionally by defining the axis profiles in code and only allowing kinds to use specific profiles we are now limiting the number of combinations. This is similar to the kind - variant approach you came up with, but now it is axisProfile - kind. Both store two strings and carry the same information; the difference is which one holds the cohort and which holds the per-document identity. That difference is what makes the advantage possible. Under kind - variant the cohort key is Because this sounded good I made a draft PR of it: #2965 It does have a net gain in production code lines, +67/−43, but to my mind it seems to reduce the complexity of the code since it is formalizing something that was already there in the form of problemLike and personalLike. Comparing it to the PR adding the variants, it doesn't need to have the variant passed into the CSS; that stays just the kind. The one thing it doesn't solve is having one place that shows all possible kinds. As long as we have dynamic kinds this isn't possible. However my argument is that the usage of the kind should be limited enough that we don't need it. The axisProfile covers creation and migration. The one thing that isn't addressed is how we will handle copying documents. Since we haven't implemented this yet it seems fine to punt that. We have multiple options to handle it, and with axis profiles we could support copying dynamic kind documents from other units. Where I landAgainst your three recommendations:
I'm not trying to close this off. If the draft PRs and axis profiles don't convince you, please let me know. Your thoroughness and critique is making this code better. |
|
I'm glad to hear the reviews are helpful. They take a ton of time and are honestly pretty draining for me to provide, so it's good to know they're making a difference. There's a lot going on, so I'll try to respond in sections here. First, a general thought about where I'm coming from with these reviews:
About saving classWide titles vs looking them up dynamically:
classWide kind vs dynamic kinds:
Axis profiles:
Only tangentially related thoughts:
|
…E-610] getClassOwnerId joins getGroupOwnerId in document-axes.ts, so both synthetic owner uids are built by the module that recognizes them and db.ts no longer knows the uid's grammar. Also picks up a batch of review comments: requireOwnerId says what it requires, the name-section sort key sits beside the function that uses it, the groups term is resolved once per sort rather than per document, and the group-owner test helper delegates to the shared minter instead of rebuilding the string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LUE-610] getDocumentOwnerFields' second parameter was an anonymous object with a single optional field, which said nothing about what the caller was meant to pass. It now takes the group id directly, so the call site reads as what it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `context_id` and `concurrent` move onto IDocumentMetadataBase, which retires the IEditPermissionMetadata intersection: canUserEditDocument takes the base. - Groups gains a `groupsByOwnerId` view, so getGroupByOwnerId is a map read rather than a scan, following `groupsByUser`. - The curriculum axis's empty position reads "none" rather than "nothing", the word the table in the same doc already uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
] The comment on isValidDocumentKind asserted that a kind is the canonical-pointer slot segment. That reads as a rule that every slot label is a kind, which is not true: the group document's slot is labeled "default" while its kind is "group". Scope the justification to the case that creates the constraint — a class-wide document's slot is labeled with its kind — and state that the reverse does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The axes docs named copy/publish templates as a per-preset recipe but never showed one filled in, so there was no way to see what a copy of a personal document or a publication of a problem document becomes. Add the two copy rows to the "Where today's types land" table, and a section reading the copy and publication rows as deltas from their source rows — which is what makes them templates rather than independent shapes. Record what happens to `kind`: a template names it rather than carrying it over, settled for copying by the copy of a problem document becoming a personal one. What kind a publication takes is not settled, so it goes to target-architecture's open questions with the leaning and its cost: nav-tab sections select documents by a list of type names, so keeping the source's kind means the authored navTabs configs have to select on axes instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LUE-610] Every surface presented `canonical` as a boolean: axes.md defined it with "whether", its table column read yes/no, target-architecture called it "pointer-slot occupancy", and the stored-shape table omitted the field entirely. A reader had no way to learn that the value names which slot a document fills — which matters, because a container holds several canonical documents at once, one per slot. Define the axis by which slot rather than whether, and give the table column role names instead of yes/no. Separate a slot from a kind: they line up today only because each class-wide kind defines one slot, and two documents of one kind would be two slots from one recipe. Add the field to reading-axes-in-code's stored-shape table, where actual stored values belong, along with what it holds and why nothing in the client reads it — the code only ever travels slot to document, and already knows the slot before it has the document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E-610] A teacher's problem document and planning document share owner, container, and curriculum exactly, and are told apart by nothing but which single-document role each fills. That makes the case for naming the slot without any of the pointer machinery: the axis was always a "which", CLUE just never had to say so, because nothing enforces either singleton. Lead with that pair and keep the class-wide documents as the version that has enforcement behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@tealefristoe thank you again for sticking with this. I'm going to go with axis profiles. I see you reviewed that PR so I think I'll merge this one first so we can move on and then update the axis profile PR so there is only a brief period of class wide docs without axis profiles. classWide titlesIt is interesting you are asking all of the same questions I did when generating the draft PR for this. The unit disambiguation code already exists in the current PR. It just changes shape when we start storing the titles. This is one reason to generate the draft PRs so I could see what it would really look like. So the +20/−12 is not the cost of disambiguation; it is the difference between the two shapes of it. The simple "if the document has a title, then show it, otherwise fallback to the kind based title", doesn't work well because of the unit disambiguation. A stored title still has to be qualified when the document is from another unit: a "Driving Question Board" stored by sas needs to read as something like "Driving Question Board (sas)" when it shows up in a list next to this unit's own board. So the lookup doesn't end at "is there a stored title?". It still has to know which unit the document belongs to and compare that against the current one, which is the part that makes it more than the simple rule. I asked the same question about needing an axis for editable titles. The current reason that it isn't needed is that concurrent documents show a non-editable title bar. If we did need to customize it in the future we could build that into the permission axis. The connection with problem documents is that the title of the problem document is specified in the unit. If we use variants or dynamic kinds we still need to specify the title of the class wide document in the unit. So if an author changes the unit the title shown to users ought to change. So the argument is about consistent behavior or implementation. If an author changes the problem name and they see the problem document titles update for their users, they would expect changing the title of the class wide document to also cause the titles to update for their users. At this point I think we'll just have to agree to disagree. You've definitely pushed me to pay more attention to this, and I will try to get the "non-current unit" loading code in at some point so both problem and class wide titles are displayed properly even when in a different unit. classWide kind vs dynamic kindsI continue to hope that I don't see using The unit defined class wide documents make this blurry though. In that case the author is providing both the kind identifier, and the slot label with a single value. These unit configurations are defining both the kind and also the template for making a canonical instance of that kind. We could make this more clear and have the author provide a kind identifier, a canonical slot label, and a title. While that makes it more clear that the same kind can be in multiple slots, it means we are requiring the author to add another identifier that is currently unnecessary. So I think it is better not to require this until it is needed. Looking through the code I found a few places that make it seem like this is a rule and that As you wrote using Axis ProfilesYour assessment of them is mostly correct. I misunderstood your variant proposal. My interpretation of it was that variant was needed to remove dynamic kinds. The part I focused on was the document titles and possibly presentation more than the slots. I think that was because I knew about the Regarding locking different kinds together. We'd have both the axisProfile and the kind to tell them apart. If in the future we find that learning logs and personal documents have different axis values then we'd make a new axis profile and migrate the existing documents using the combination of kind and axisProfile. Normally only the axisProfile would be used, but if we need to split an axisProfile then we can use the kind. CopyingThis is now spelled out better in axes.md, but here is a summary. PublishingPublishing a problem or personal document is basically copying it. What fields should change when publishing can be based on the kind. In both cases the owner stays the same, but the permissions change (when they exist for real). The permissions would no longer allow the owner or anyone else to edit it. They would let the class view it. For problem documents they are no longer canonical, so that axis would change differently than for personal documents. Problem documents don't officially use the canonical field, but the canonical axis was built to support them. CopyingCopying a personal document just makes a copy and changes the owner to the person who made the copy. Copying a problem document is different. It changes the owner, but it also turns the new document into a personal document. So this currently means setting the kind, curriculum, and container axes. So rather than having a branch based on the kind in the copying implementation of CLUE, the current design proposes that the kinds define a template for this copying. The template defines how to change the axis values from the original when making the copy. This template could be defined in a few ways. The copy template could be looked up from the value of the kind axis, similar to how the title can be looked up from the kind. Or we could use the new axisProfile to find the copy template. Or we could add a new field to the document metadata which would name a copy template. Token BurningI think the PRs are worth the tokens. Having concrete code to look at helped me. There is a finer point here about how it is the training the AI models that uses the most energy, and just using the models doesn't use a huge amount. But it is nuanced and companies don't publish their stats. |
…LUE-610] The comment on canUserEditDocument said "a researcher never gets an edit affordance", which contradicts the sentence after it granting every user their own document, and contradicts the code: the ownership test runs first, so a researcher may edit a document owned by them. State the rule as written — no edit affordance on anyone else's document — and say why the check sits after the ownership test, since researchers have documents created for them under their own uid. Add the test for that case; only the refusal was covered, so hoisting the check would not have failed anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sort Work + workspace UI for class-wide documents
The groundwork already merged to master creates the Driving Question Board but surfaces it nowhere; this PR makes it visible, sectioned, titled, and editable.
The presentation half (#2940) has merged, so this PR targets
masterand holds the remaining half. That PR was split out of this branch and carriedofferingIdonto both metadata types, the thumbnail and title bar readingconcurrent, and the split of a document's owning group (groupId) from its owning user's group (groupIdOfUserOwner). This PR's container guard builds on the first of those, and its owner guard on the last. Together the two replace the earlier single 47-file PR #2935, which can be closed.The design spec is committed at
docs/superpowers/specs/2026-07-27-clue-610-sort-work-ui-design.mdand is the document to review alongside the code. Note that the spec records the design as it stood when written — several names changed during implementation (see "The axis-modeling checkpoint" below), anddocs/document-axes/is the current-state record. The roadmap atdocs/document-axes/README.mdflipskindto done and records the read side ofowner,container, andcurriculum.PR #2930 (CLUE-525) landed
canUserEditDocumenton master while this work was in flight, and the base branch has since merged it. This PR replaces its semantics, and the two disagree on a real case, so it is flagged here rather than left to be discovered in review.type === GroupDocumentconcurrentaxisgroupId === user.currentGroupIduser.userIdForGroupDocumentsThe group-membership row is the one that changes behavior. A group id is unique only within an offering, so
groupId === user.currentGroupIdmatches across offerings — group 3 of a past assignment is a different set of students, and its document still satisfies the test. That reaches real documents, because Sort Work's "All" filter lists everything a class has produced, including documents from offerings it has moved on from. Comparing owner uids is exact instead:group_<offeringId>_<groupId>carries the offering, so two same-numbered groups can never be confused.The merge resolution therefore removed CLUE-525's
describe("canUserEditDocument")block fromdocument-utils.test.ts: two of its cases assert the superseded contract, most directly "prefers the metadata groupId over the document's" → true, against this PR's "does not treat a document model's groupId as evidence of group ownership" → false. Every other case that block covered is covered here. The one case it uniquely had — a group document reached through thedocumentrather than its metadata — was ported to the new contract rather than dropped.The axis-modeling checkpoint, resolved
The project deferred "does scope need a helper, and if so what shape?" to its richest consumer. Working through this PR's five consumers produced two findings.
First, "scope" was three questions wearing one name. It has been split into
owner(who the document belongs to),container(where it is kept: class → classUnit → offering), andcurriculum(what it is about: nothing → unit → investigation → problem). Every consumer turned out to want exactly one of them, and picking the wrong one is a live defect rather than a style question:document.isGroupkind→ registry presentationtype === GroupDocumentconcurrent→ collaborative stylinguid === user.idconcurrent, then the document's owner and containerbyGroup/byNamesectioningtype === GroupDocumentThe listener is the sharpest illustration. It fetches documents about a whole unit, and it must not be shaped as a container question: an exemplar is kept in the classUnit container but is about a problem, so a container-shaped query would surface exemplars from every problem in a unit whenever a student was on any one of them.
Second, the answer is narrow named guards — no level enum and no unified struct.
src/models/document/document-axes.tsholdshasGroupOwner,hasClassOwner,isInClassUnitContainer, andgetCurriculumLabel, each reading only its own axis's stored fields — notype, nokind, no registry. That last point is load-bearing: Sort Work's "All" filter lists documents from other units, whose kinds were never registered this session, so a registry lookup would silently misfile them. The decision record, the field-by-shape table, and what is not covered yet are indocs/document-axes/reading-axes-in-code.md.On the creation side the kind registry declares
ownerType(user/group/class) andcontainerType(class/classUnit/offering). There is deliberately 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.What's in the PR
Axes
document-axes.ts,document-kinds.ts), per the checkpoint above. On the creation side the kind registry declaresownerTypeandcontainerType; on the read side, narrow guards over stored fields.classUnitcontainer stampsinvestigation: null, problem: nullrather than omitting them, following the convention the rules already encode (hasPresentFieldtreats a null field as absent). Firestore cannot match a missing field, so this is what makes "about a unit but not a problem" queryable.uid, testing thegroup_prefix its minter used, symmetric withhasClassOwnerand itsclass_. That leaves theuidthe single authority on who a document belongs to; the storedgroupIdnow carries only Sort Work's group label and the two queries that retire with the canonical-pointer migration.Sort Work
unit in <variants> && investigation == null. It names notypeand nokind. Equality-only, so no composite index. It is a curriculum query, not a container one: an exemplar is kept in the classUnit container but is about a problem, so a container-shaped query would surface exemplars from every problem in a unit whenever a student was on any one of them.byGroupgets a "Whole Class" section ahead of the numbered groups;byNamefiles class-wide documents under "No Name". Ordering comes from structured sort keys instead ofparseInt-ing the display label — which mattered because the group term is translatable per unit and "Whole Class" contains no digits.byNameresolved a group document's members by bare group id. Group 3 of a past assignment is a different set of students, so its document was attributed to this offering's group 3 — or dropped from the sort when no group of that number existed here. Resolution now goes throughgetGroupByOwnerId, which builds the whole owner id and compares; a document from another offering gets its own section, labelled by where the work came from (Groups from sas-2.4).Workspace
Driving Question Board (sas-2.4)), and a unit-declared title is scoped to its own unit so two units declaring the same kind don't borrow each other's wording.canUserEditDocumentindocument-utils.ts, beside its siblingisDocumentAccessibleToUser, replacing two divergent inline gates (Sort Work's ownership-only check and the resources pane's tab check).Firestore rules
canonical/v1/classes/<classHash>/(offerings/<offeringId>|units/<unit>)/owners/<uid>/slots/<label>, built segment-by-segment from the document's own fields on both sides (getCanonicalPointerPathand the rules'canonicalPointerPath). Each container names only itself, so a new container level can be added later without moving pointers that already exist — they are immutable by rule. Taking the owner segment straight fromuidremoved the last reference togroupIdanywhere infirestore.rules.concurrentfield's write path (see below).Security: two defects found and fixed during review
Both were defects in the plan, caught before merge, and both have regression tests.
The edit predicate initially granted Edit on the user's own published documents. A publication's
uidis the publisher, so the ownership arm matched in the Class Work tab where the old tab-based gate had withheld it. Not a write hole (published documents are forced read-only downstream) but a dead button, and it contradicted a live Cypress assertion innav_panel_test_spec.js. Now excluded viaisPublishedType, with researchers denied explicitly.The history-write rule was forgeable. Gating on
concurrentwas unsound becauseconcurrentwas not a read-only field: any class member couldupdatea classmate's document with{concurrent: true}and thereby grant themselves — and the whole class — read and append on that document's private history. Fixed at the write path rather than the read path:concurrentChangeOk()permitsconcurrentto change only ontype == "group"documents, andtypeis itself read-only so it cannot be flipped first.That write allowance is transitional — it exists only because two backfill paths merge-update
concurrentonto existing group documents. Once the migration is complete it should be removed soconcurrentbecomes settable only at creation, and the create path should be constrained too (isValidDocumentCreateRequestcurrently checks neitherconcurrentnoruid). Breadcrumbs to that effect are infirestore.rules, the backfill script, and both design specs.Separately,
getDocumentOwnernow throws for an unregistered kind rather than defaulting to the creating user. Defaulting would hand a group's or a class's document to whoever created it and — because a canonical slot is addressed by its owner — file it in that user's slot instead of the shared one, both silently. Two existing tests were relying on the default; they now register the kind, as production does.Migration
scripts/backfill-group-concurrent.ts→scripts/backfill-group-document-axes.ts, now normalizing the stored axes of everytype: "group"document in two passes selected so they cover disjoint sets:groupId, missingconcurrent{ concurrent: true, kind: "group" }groupId, missing curriculum fields{ investigation: null, problem: null }Selecting on
groupIdrather than on the value being written matters: a class-wide document stampedkind: "group"would break both its title resolution and its canonical-pointer slot, since the slot label is the kind.Firestore rules deployment sequencing
The rules can be deployed before the code, in the usual order. Two parts of this change look at first like they might not allow that. Neither does, and the reasons are worth recording because both are non-obvious from the diff.
1. The history rule is purely additive.
The new arm is
isConcurrentClassDocument(), gated onconcurrent(not oncanonical). Pre-existing group documents don't carryconcurrentuntil a backfill runs, and the backfill only ships with this code — which reads like a catch-22 against a rules-first deploy: the rules would depend on a field nothing in production writes yet.It isn't one. Under the rules on production today, history create and read are gated by
isAuthed() && userOwnsDocument(), anduserOwnsDocument()compares the document'suidto the caller'splatform_user_id. Every group document'suidis the syntheticgroup_<offeringId>_<groupId>, which never equals a real user id — so no user can create or read group-document history in the authed domain today. The new arm can only widen that, and widens it to nothing whileconcurrentis absent. Deploying it early takes nothing away and enables nothing that is not already inert.2. The canonical-pointer path move denies a path production code still uses — and that is fine.
The path already moved once, in 7.4.0, which is now on production: pointers live there at
canonical/v1/…/classes/<context_id>/(offerings/<offeringId>/groups/<groupId> | units/<unit>)/slots/<label>. This PR moves it a second time, inserting anowners/<uid>segment. So unlike the first move, this one denies a path that production code is running against today.These rules delete the 7.4.0 match blocks (
offerings/{offeringId}/groups/{groupId}/slots/{label}andunits/{unit}/slots/{label}) and grant only thecanonical/v1/…/owners/<uid>/slots/<label>layout above; anything unmatched falls through to the top-level default-deny. Confirmed against the emulator — under these rules a class member's read and create at the legacy path are both denied. So between the rules deploy and the code release, a client on current code would take apermission-deniedon the first step ofgetOrCreateCanonicalDocument, thepointerRef.get(). Nothing catches it, sogetOrCreateGroupDocument()would reject.That costs nothing, because group documents are not released in the authed domains. They are opt-in per unit via
groupDocumentsEnabled, which defaults off; the only unit enabling it isdemo/units/qa, and thedemopartition has its own recursive-wildcardallow read, write: if isAuthed(), so it is unaffected by the authed pointer paths in either deploy order — as aredev,qa, andtest. The authed-domain group-document path is already non-functional, per the history rule above. Denying the legacy pointer path breaks it further, not something that works.So no dual-path rules release is needed. The rules do not have to carry both the legacy and the
canonical/v1pointer blocks through a transition window: deploy the rules, release the code after.Existing legacy-path pointers need no migration either. The new code never consults them — with no
v1pointer present,getOrCreateCanonicalDocumentfalls through tofindLegacyGroupDocument, which queries thedocumentscollection bycontext_id/offeringId/groupId, finds the same group document, and claims av1pointer for it. The document's existingcanonicallabel is rewritten to the same value, whichcanonicalFieldOkpermits as a no-op change. The old pointers are simply abandoned.Testing
Full suite green: 3591 Jest tests plus the emulator rules suite.
check:typesandlint:buildclean.New coverage includes the axis guards across every stored document shape, sectioning and sort-key ordering, the cross-offering group cases (attribution, the missing-group-number case, and section ordering), the whole-unit listener (registration per filter, dedupe, disposal, clearing on filter change), the edit predicate across all arms including negative cases, and rules tests for the owner segment of a canonical slot plus synthetic-owner history writes and the escalation that a class member cannot flip a classmate's document to concurrent.
Three verification notes, since none is visible in the diff:
--runInBand. The eight suites share one emulator and reset state per test, so running them in parallel produces ~51 spurious failures.firebase-test/package.json'stestscript does not pass the flag — pre-existing, and worth its own fix.canonicalPointerPathfails all three claim tests, and the guards' behavior tests fail when a guard is stubbed out.git diffbetween them empty), so nothing changed behaviorally in the reorganization. They have since diverged, as this branch has mergedmasterseveral times.Not done — for the reviewer
demo/units/qadeclares a slot today, so no production unit pays it. The fix is specified in the spec (defer the open, not the get-or-create) and wants its own ticket.groupIdis deliberately deferred. Nothing asks it the owner question anymore, butbyGroupstill uses it for a section label, and it does so in a way that intentionally ignores the offering: a group-3 document from a past assignment shares "Group 3" with this one's, which matches students carrying groups forward between offerings. Removing the field forces a behavior decision there, which is the real gate — not any remaining code cleanup.🤖 Generated with Claude Code
Replaces #2942, which was opened from the previous branch name; its review discussion stays readable there.
🤖 Generated with Claude Code