Skip to content

CLUE-610: Sort Work + workspace UI for class-wide documents - #2949

Merged
scytacki merged 55 commits into
masterfrom
CLUE-610-class-wide-documents
Aug 19, 2026
Merged

CLUE-610: Sort Work + workspace UI for class-wide documents#2949
scytacki merged 55 commits into
masterfrom
CLUE-610-class-wide-documents

Conversation

@scytacki

@scytacki scytacki commented Aug 6, 2026

Copy link
Copy Markdown
Member

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 master and holds the remaining half. That PR was split out of this branch and carried offeringId onto both metadata types, the thumbnail and title bar reading concurrent, 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.md and 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), and docs/document-axes/ is the current-state record. The roadmap at docs/document-axes/README.md flips kind to done and records the read side of owner, container, and curriculum.

⚠️ This supersedes the edit predicate that shipped in PR #2930

PR #2930 (CLUE-525) landed canUserEditDocument on 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.

CLUE-525, now on master this PR
collaborative test type === GroupDocument the stored concurrent axis
group membership groupId === user.currentGroupId the document's owner uid vs user.userIdForGroupDocuments
metadata vs. document read field by field one source chosen outright
published documents editable by the publisher never editable by anyone
researchers editable where owned never given an edit affordance
class-wide documents not addressed editable by any member of the class

The group-membership row is the one that changes behavior. A group id is unique only within an offering, so groupId === user.currentGroupId matches 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 from document-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 the document rather 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), and curriculum (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:

Consumer Branched on before What it actually asks
Workspace title bar document.isGroup kind → registry presentation
Thumbnail treatment type === GroupDocument concurrent → collaborative styling
Edit gate uid === user.id concurrent, then the document's owner and container
Sort Work listener curriculum breadth: a whole-unit document must survive a problem filter
byGroup / byName sectioning type === GroupDocument which owner the document belongs to

The 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.ts holds hasGroupOwner, hasClassOwner, isInClassUnitContainer, and getCurriculumLabel, each reading only its own axis's stored fields — no type, no kind, 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 in docs/document-axes/reading-axes-in-code.md.

On the creation side the kind registry declares ownerType (user / group / class) and containerType (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

  1. Axis guards and the owner/container/curriculum split (document-axes.ts, document-kinds.ts), per the checkpoint above. On the creation side the kind registry declares ownerType and containerType; on the read side, narrow guards over stored fields.
  2. Explicit-null curriculum fields — the classUnit container stamps investigation: null, problem: null rather than omitting them, following the convention the rules already encode (hasPresentField treats a null field as absent). Firestore cannot match a missing field, so this is what makes "about a unit but not a problem" queryable.
  3. A group owner is read off the uid, testing the group_ prefix its minter used, symmetric with hasClassOwner and its class_. That leaves the uid the single authority on who a document belongs to; the stored groupId now carries only Sort Work's group label and the two queries that retire with the canonical-pointer migration.

Sort Work

  1. A whole-unit listener — a sibling of the existing unit-less listener, active only under the Investigation/Problem filters, querying unit in <variants> && investigation == null. It names no type and no kind. 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.
  2. Sectioning by ownerbyGroup gets a "Whole Class" section ahead of the numbered groups; byName files class-wide documents under "No Name". Ordering comes from structured sort keys instead of parseInt-ing the display label — which mattered because the group term is translatable per unit and "Whole Class" contains no digits.
  3. A cross-offering defect fixed. Sort Work's "All" filter spans every offering a class has worked through, and byName resolved 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 through getGroupByOwnerId, 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

  1. Titling a class-wide document from another unit — the kind registry resolves slot titles, with a fallback naming a document whose declaring unit's config is not loaded (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.
  2. One shared edit predicatecanUserEditDocument in document-utils.ts, beside its sibling isDocumentAccessibleToUser, replacing two divergent inline gates (Sort Work's ownership-only check and the resources pane's tab check).

Firestore rules

  1. Canonical slots addressed as container + owner + label. A pointer path is now 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 (getCanonicalPointerPath and 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 from uid removed the last reference to groupId anywhere in firestore.rules.
  2. History-write rules for synthetic owners + a guard on the concurrent field'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 uid is 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 in nav_panel_test_spec.js. Now excluded via isPublishedType, with researchers denied explicitly.

The history-write rule was forgeable. Gating on concurrent was unsound because concurrent was not a read-only field: any class member could update a 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() permits concurrent to change only on type == "group" documents, and type is itself read-only so it cannot be flipped first.

That write allowance is transitional — it exists only because two backfill paths merge-update concurrent onto existing group documents. Once the migration is complete it should be removed so concurrent becomes settable only at creation, and the create path should be constrained too (isValidDocumentCreateRequest currently checks neither concurrent nor uid). Breadcrumbs to that effect are in firestore.rules, the backfill script, and both design specs.

Separately, getDocumentOwner now 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.tsscripts/backfill-group-document-axes.ts, now normalizing the stored axes of every type: "group" document in two passes selected so they cover disjoint sets:

pass selects stamps
existing has a groupId, missing concurrent { concurrent: true, kind: "group" }
new no groupId, missing curriculum fields { investigation: null, problem: null }

Selecting on groupId rather than on the value being written matters: a class-wide document stamped kind: "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 on concurrent (not on canonical). Pre-existing group documents don't carry concurrent until 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(), and userOwnsDocument() compares the document's uid to the caller's platform_user_id. Every group document's uid is the synthetic group_<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 while concurrent is 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 an owners/<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} and units/{unit}/slots/{label}) and grant only the canonical/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 a permission-denied on the first step of getOrCreateCanonicalDocument, the pointerRef.get(). Nothing catches it, so getOrCreateGroupDocument() 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 is demo/units/qa, and the demo partition has its own recursive-wildcard allow read, write: if isAuthed(), so it is unaffected by the authed pointer paths in either deploy order — as are dev, qa, and test. 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/v1 pointer 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 v1 pointer present, getOrCreateCanonicalDocument falls through to findLegacyGroupDocument, which queries the documents collection by context_id/offeringId/groupId, finds the same group document, and claims a v1 pointer for it. The document's existing canonical label is rewritten to the same value, which canonicalFieldOk permits as a no-op change. The old pointers are simply abandoned.

Testing

Full suite green: 3591 Jest tests plus the emulator rules suite. check:types and lint:build clean.

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:

  • The emulator suite must be run with --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's test script does not pass the flag — pre-existing, and worth its own fix.
  • The new rules tests were checked for discrimination, not just for passing: hardcoding the owner segment in canonicalPointerPath fails all three claim tests, and the guards' behavior tests fail when a guard is stubbed out.
  • The split from PR CLUE-550 Stage 3: Sort Work + workspace UI for class-wide documents #2935 was verified by tree equality at the time it was made: the two branches' tips were byte-identical (git diff between them empty), so nothing changed behaviorally in the reorganization. They have since diverged, as this branch has merged master several times.

Not done — for the reviewer

  • Manual two-student end-to-end check has not been run. The checklist is in the design spec; the two items no automated test covers are the two-student concurrent edit round-trip and the workspace title bar.
  • Eager-open cost is ~670–730ms per unit load on the fast path, measured against a live dev server. Only demo/units/qa declares 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.
  • Whether to drop the stored groupId is deliberately deferred. Nothing asks it the owner question anymore, but byGroup still 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.
  • Deliberate deviation from PR CLUE-550: auto-generate a class-wide Driving Question Board per unit #2890: its absolutely-positioned centered DQB title bar is not ported — it is cosmetic and would hardcode a kind in CSS, which cuts against making "add another class-wide document" a configuration change.

🤖 Generated with Claude Code


Replaces #2942, which was opened from the previous branch name; its review discussion stays readable there.

🤖 Generated with Claude Code

scytacki and others added 30 commits July 30, 2026 15:06
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>
@scytacki

Copy link
Copy Markdown
Member Author

@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 Titles

I 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 getKindDefinitionFor method in the current PR, so I added that. For the case of dynamic kinds this method takes care of finding the kind definition in a generic way, not just some code specific to titles. If we keep dynamic kinds I'm pretty sure there will be other reasons we need to find the kind definition, for example when copying a document. If we allow a user to copy a dynamic kind document from another unit we'll need to know which axis values to copy. This kind of copying definition hasn't been implemented in any of these PRs but it is described in the docs.

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:

A title identifies a document only within the scope its author controlled. Displayed outside that scope, it needs the coordinate that restores identification.

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 variants

I 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 kind: the workspace title bar stylesheet, the last-resort display label getDocumentKindLabel(kind).

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 registerClassWideDocumentKind, but really this is just replaced with a static registerDocumentKind which doesn't really look much different than what registerClassWideDocumentKind did. Another place is getKindDefinitionFor, which goes away, but it isn't much code.

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 registerClassWideDocumentKind is in document-kinds.ts. It does keep the axis values for this profile (see below) in the same place as the static kinds.

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:

  • Assignment at creation — personalLikeKindInfo, problemLikeKindInfo in document-kinds.ts and the class-wide axis values in registerClassWideDocumentKind.
  • Recognition of existing documents — the shorthand the comments and tests already use.
  • Many kinds to one bundle — four kinds share personalLike, four share problemLike, and in the future 2 kinds of class-wide documents
  • Migrations — if we want to change the axis value assignments we probably want to change them for all kinds of this bundle.

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 kind, which the runtime already leans on: it's the registry key, it's the canonical slot label, and it's the CSS hook on the title bar. It has to be readable, so it can't be kept away from the code that shouldn't use it. Under axisProfile - kind the cohort key does nothing at runtime at all, so we can leave it off IDocumentMetadata, DocumentMetadataModel, and DocumentModel entirely — which is what the axis profile PR does. Reading it would require widening a type first, so it shows up as a deliberate change in review rather than as a convenient one-liner. To be clear, this is a compile-time barrier rather than a real one: the value is still on the object and a cast would reach it.

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 land

Against your three recommendations:

  • Save the titles when they're created. I'd rather not, for now. Storing them turns out to be a small net increase in production code rather than a decrease, and it makes class-wide documents inconsistent with problem documents, which resolve their titles the same way. If this becomes a real problem for users, loading the defining unit is the better fix because it solves both at once.
  • Make classWide the kind and tell them apart with a variant. I'd like to counter with axisProfile + kind. It gives you the same migration story and a better version of the central list — every axis combination in one file, rather than every kind name — while keeping the cohort key out of the runtime types.
  • Drop dynamic kinds. I'd like to keep them. Your migration argument was the strongest one against them and I think axis profiles answer it. The part I can't answer is enumerating every kind in one place; that isn't possible while kinds are dynamic, and I'm betting a kind with a small enough job doesn't need it.

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.

@tealefristoe

Copy link
Copy Markdown
Contributor

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:

  • Fewer lines of code is fantastic, and generally indicates things are simpler. But what I'm really trying to get at is a system that will be grokable by everyone working on CLUE, so we can all be on the same page when talking about and developing CLUE. This is a somewhat amorphous idea that I think claude has no concept of, but it will have measurable outcomes like miscommunications and bugs.

About saving classWide titles vs looking them up dynamically:

  • Don't we need the unit disambiguating code even if we look up titles dynamically? Stored or looked up, titles have the same chance of colliding. I don't think it's fair to include those extra lines only with the stored titles.
  • When I suggested storing titles on the document itself, I was thinking it would be easy to follow the pattern of documents that can be renamed (personal documents? Let's assume personal documents, even if that's wrong). So my thought is that when you're looking up a document's title, no matter what kind, you'd first look for a saved title, and only fallback to the kind specific variant if no title is saved. Maybe a new axis would be necessary to specify if a document's title can be changed via the UI or not.
  • I don't think you should have to change the way problem documents handle titles based on classWide documents. They seem independent to me, in the same way that personal documents and problem documents are currently independent.
  • I still think storing the title will be easier to understand, and when things go wrong it will be more obvious what's going on.

classWide kind vs dynamic kinds:

  • I guess a big red flag for me in reading your response about this is that I seem to still not understand what kind is supposed to be. Maybe that's a me problem, but if I don't get it after trying hard to understand all these PRs, who will understand it?
  • The slot/${kind} address rule isn't set in stone, and I think we should reconsider it. To me, it adds an extra meaning to kind, and it doesn't have to be that way. Plus, I believe the only canonical documents that exist right now (group) explicitly already break this rule!
  • Ultimately what I'm proposing is a separate field to specify the slot. So maybe it should be slot instead of variant (though I do feel like slot is a pretty awkward term in general here).
  • Honestly, what would be best would be to use slot/${title} for this, but that has the downside of making it way harder to change the title in the future. Though do note that in the current system, we use kind for both the slot and the fallback title when we're in a different unit, so we're almost already using slot/${title}.
  • I guess part of what makes me think this is ok in general is a bit of a loose interpretation of canonical. Strictly, canonical is supposed to mean there's a single instantiation of that kind of document. But the way I'm thinking about it, there could be an arbitrary finite number of that kind of document. So instead of a single one, we have a set N, which can vary by unit. To me this makes sense, but I can now see how it's a bit of a leap that maybe wasn't obvious to you, and maybe you won't agree with it being a good idea.

Axis profiles:

  • I'm happy to call these things whatever you want 😄 I just want to make sure this is understandable, it's only as complex as it needs to be, and hopefully the names are intuitive so anyone working on CLUE can have a pretty good idea of what they mean without reading any documents.
  • That said, I do want to break this proposal down a bit. From my understanding, it splits kind's responsibilities:
    • Before, kind determined the axes, and stuck around for migrations, to determine the title, to determine some other visuals, and I guess was used to address the canonical slot.
    • Now, kind determines profile, and sticks around for titles, visuals, and canonical slot. profile is now in charge of the axes and sticks around for migrations.
  • Is that a fair assessment? I think the biggest difference with the profile - kind split vs the kind - variant split I was originally proposing is how the responsibilities are distributed. With kind - variant, I basically assumed variant would only be used for the canonical slot, while kind would keep all its other responsibilities (so variant was probably never a very good name). It still seems better to me to separate that single responsibility into a different field, but I can get behind profile as proposed.
  • I guess my only other concern to bring up is if this setup will lock different kinds together. Is there ever a situation where we'd want personal docs and learning logs to have different axes? Would this system allow us to do that?
  • Enumerating every kind in one place isn't as important to me as having a centralized list of all the axis combinations, which profile supports.

Only tangentially related thoughts:

  • I haven't thought carefully about copying documents, but why would you need to look up anything about the kind? Shouldn't you already have access to all the axes in the document itself? Is there a wrinkle I'm not seeing here?
  • Production line changes vs total line changes in these PRs:
    • +20/-12 vs +163/-129
    • +42/-41 vs +194/-238
    • +67/-43 vs +381/-95
      How many camp fires are we burning for even simple code changes these days? 😢

…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>
scytacki and others added 2 commits August 18, 2026 13:56
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>
@scytacki

Copy link
Copy Markdown
Member Author

@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 titles

It 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 kinds

I continue to hope that kind will make more sense after the migration is more complete. With the addition of the axis profiles, the stored kind should be just for the "presentation" of the document and its title when it is a computed title. The part that still makes this confusing (I think) is the use of "kind" as the name we use when discussing and defining documents. It is a name for the combination of axis values that make up that type of document. But I hope once we start discussing document kinds this will feel more natural.

I don't see using kind for the slot label as a rule. It is just a shorthand so when creating the document, we don't have to separately define the slot label. When the document is created there is a separate canonicalLabel creation parameter that is used for this. So if we had the case where we wanted the same kind to be used in multiple canonical slots, we'd just pass a different value for the canonicalLabel, but they'd have the same kind. Regarding your last point in this section: "Strictly, canonical is supposed to mean there's a single instantiation of that kind of document." That is not what the code does, the code supports any number of slots with the same kind of document. This is why the canonical field is a string label not just a boolean. The Firestore rules need to know which slot a document is supposed to be in; they use the canonical field for that.

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 canonical is just a boolean. I've tried to update all of those places to make this more clear.

As you wrote using slot/${title} means an author can't change the title for the kind without us doing a migration. So this makes the title not really authorable. If we do that, I don't think the title should be in the unit configuration anymore. Also it does have to work as a Firebase compatible path segment, so that limits what characters can be used.

Axis Profiles

Your assessment of them is mostly correct. kind is mainly stored for titles and visuals (presentation). As discussed above, the slot label is set when the document is created, so in the code it is independent from the kind. As discussed below, if we need to split an axis profile then the kind can be used for profile splitting migrations. The profile defines the combinations of axis values, and is stored to help with migrations. In the future the profile might help with copying too (see below).

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 canonical field value defining the slot label. So that was already a separate field.

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.

Copying

This is now spelled out better in axes.md, but here is a summary.

Publishing

Publishing 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.

Copying

Copying 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 Burning

I 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.

Comment thread src/models/document/document-utils.ts
…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>
@scytacki
scytacki merged commit e0de700 into master Aug 19, 2026
24 of 27 checks passed
@scytacki
scytacki deleted the CLUE-610-class-wide-documents branch August 19, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants