CLUE-610: rename the generic document type from "group" to "axes" - #2952
Conversation
…LUE-610] Both tests reused whatever `db` the previous test left behind instead of opening their own studentAuth client, so the caller identity under test was an accident of declaration order and either test would throw undefined `db` if run in isolation.
…l ones [CLUE-610] The merged-write comments claimed a half-swept document (type:"axes" without kind:"group") matches neither getDocumentTitle branch and renders with no title. That branch checks isAxesType(type) && groupId, and isAxesType accepts both "axes" and "group", so a half-swept group document still matches and still renders correctly — the claimed hazard doesn't exist on this branch. Replace it with the two hazards that are real: a group document missing concurrent+kind loses its history manager and canonical-pointer slot label; a class-wide document missing curriculum scope is invisible to Sort Work's unit-scoped query. Also brings the function docstring in line with the three-part (not two-pass) behavior the header already describes.
…LUE-610] Ten no-behavior-change fixes from the final whole-branch review: - Replace the sweep script's merged-write rationale in all four places (design spec, script header, script body comment, test comment) with the one argument that survives verification: type is the script's own work-queue key (where("type", "==", "group")), so committing it separately from the axis fields would make write order load-bearing and could permanently strand a half-migrated document with no way to find it again. Two earlier rationales in the same spots were checked against the code and found false. - Name and accept the deploy-window collision in the design spec (§7) and the plan's deploy-sequencing section: old clients meeting a brand-new "axes"-typed document at the moment 7.5.0's writers ship, before any drain is possible. Mitigation: deploy outside class hours. - Strengthen the sweep script's test mock so it can catch a silent under-migration: pin the query predicate (collectionGroup + where args) instead of discarding them, and give db.batch() a fresh recorder per call so a 401-document case can assert the 400/1 commit split. - Reword the db.ts kind-lookup comment to state the actual constraint (no "axes" registry entry, so the group kind's `concurrent: true` is what's being borrowed) instead of a narrower and partly inaccurate one. - Delete a redundant db.test.ts case whose assertions are a strict subset of the amended create-path test just above it. - Note above both widened firestore.rules clauses that "group" and "axes" are both live until CLUE-604's sweep has run everywhere, so neither branch is dead code. - Correct "all ten call sites" to "every call site" in the isAxesType comment (an eleventh site was added after that comment was written). - Align the design spec with shipped code: DBGroupDocMetadata.type is "group" | "axes", not narrowed to "axes"; the isAxesType snippet now shows its type-guard return annotation. - Remove the design spec's link to an untracked session-handoff note so the committed doc doesn't 404 once that note is gone. - Note in the sweep script header that write volume now scales with every matched document, not just the ones needing a concurrent/scope backfill. Covering tests, npm run check:types, and npm run lint:build are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ree comments [CLUE-610] The 401-document test counted batch allocations, so deleting the tail commit still passed it. The mock now records whether each batch was committed, and the test asserts on that — verified by deleting the tail commit and watching it fail. Also folds the plan-only deploy steps into the design spec, since the plan is transient: the rules deploy is now its own sequencing step with its command.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2952 +/- ##
==========================================
+ Coverage 85.67% 85.98% +0.30%
==========================================
Files 987 988 +1
Lines 56591 56605 +14
Branches 14946 14950 +4
==========================================
+ Hits 48487 48669 +182
+ Misses 8084 7916 -168
Partials 20 20
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-610-generic-type-rename
|
| Run status |
|
| Run duration | 03m 44s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
…and schedule the cleanup [CLUE-610]
…generic-type-rename
…generic-type-rename The axis-profile backfill and the type rename both restructured the sweep script into one merged write per document, for different reasons. Unioned the passes: `type` for every matched document, then `axisProfile`, `concurrent`, and curriculum scope. `BackfillResult` carries both new counts. The create-shape fixtures the deferred-rules branch added spell `type: "axes"`, since they assert what the client sends. Group-document creation now takes its offering from the caller's token, and a teacher token carries no `offering_id`. The axes-typed canonical-race delete test had been setting up its document through a rules-bound teacher client, so it now writes that document with adminWriteDoc, as its siblings do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Renames the generic axis-driven document type from "group" to "axes" while supporting both values during migration.
Changes:
- Adds
AxesDocumentand updates readers/writers for transitional compatibility. - Extends Firestore rules and tests to accept both values.
- Updates the backfill script to atomically migrate type and axis fields.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/models/document/document.ts |
Recognizes both axis-type values. |
src/models/document/document.test.ts |
Tests axes-typed models. |
src/models/document/document-utils.ts |
Widens document accessibility handling. |
src/models/document/document-utils.test.ts |
Tests axes titles and access. |
src/models/document/document-types.ts |
Defines the new type and predicate. |
src/models/document/document-types.test.ts |
Tests type recognition and sorting. |
src/models/document/document-kinds.ts |
Supports axes-typed group titles. |
src/models/document/document-kinds.test.ts |
Tests transitional title behavior. |
src/lib/db.ts |
Writes and reads the new type. |
src/lib/db.test.ts |
Tests creation and backfill behavior. |
src/lib/db-types.ts |
Widens RTDB type declarations. |
src/components/tiles/tile-activity-badges.tsx |
Supports badges for axes documents. |
src/components/tiles/tile-activity-badges.test.tsx |
Tests axes activity badges. |
src/components/document/document-workspace.tsx |
Opens axes-typed primary documents. |
src/components/document/document-title.tsx |
Handles axes document titles. |
scripts/backfill-group-document-axes.ts |
Migrates types with merged writes. |
scripts/backfill-group-document-axes.test.ts |
Tests migration batching and queries. |
firestore.rules |
Widens transitional authorization clauses. |
firebase-test/src/documents-rules.test.ts |
Tests axes document permissions. |
firebase-test/src/canonical-pointers-rules.test.ts |
Tests axes document deletion rules. |
docs/superpowers/specs/2026-08-07-clue-610-generic-type-rename-design.md |
Documents design and rollout sequencing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e-rename ones [CLUE-610] The kind/concurrent gate accepts both type values, but the axis-profile gate beside it still tested the pre-rename one. Both creators now send "axes", so no newly created group or class-wide document was getting a profile — and the sweep could not repair them, since its query matches only the pre-rename value. The profile tests all passed the pre-rename type explicitly, so none of them exercised the value the client actually sends. The class-wide test now sends what the client sends, and the axes-typed stamping test asserts the profile too, giving the accept-both gate a case for each value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LUE-610] The current-state docs explained the kind/profile stamp gate by quoting the single type value it used to test, which says the wrong thing now that the gate accepts both and new documents store "axes". The metadata field entry also called `type` written-once and immutable. The sweep rewrites it, so that entry now names the script and says why it gets past the rule that keeps the field read-only for clients. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
499e8a5 to
708e2fc
Compare
|
The failing cypress test is one that was failing before. |
… type [CLUE-610] The badges report who is co-editing a tile. That is what the `concurrent` axis says, and every neighbouring feature asking the same question already reads it: the concurrent history manager, the non-owner write-sync suppression, the concurrent title bar, the thumbnail. The badges were the last holdout testing the document type instead, and widening that test to accept both type values would have left the one site that already meant "concurrent" phrased as a type test. No behavior changes: every concurrent document is axes-typed today, and the on-open backfill derives `concurrent` from the kind registry for any axes-typed document, so documents predating the stored field carry it on the model by the time a tile can render. The two tests that stood for the type now stand for the axis, and each fails against a type test: a non-concurrent document typed like the ones that do get badges renders none, and a concurrent document of another type renders them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kswenson
left a comment
There was a problem hiding this comment.
👍 Looks good -- the following review, developed in conjunction with Claude Code 🤖, identified a few issues worthy of consideration. Reviewed against #2951 as its base, which I have also approved.
PR Review Summary
Changes: 24 files, +925 / −150 lines. Stacked on #2951.
What it does
Renames the generic document type from the stored value "group" to "axes", across the app,
firestore.rules, and the one-time sweep script. The value no longer means "this is a group
document" — regular group documents and class-wide collaborative documents both store it, and they
are told apart by guards over their axis fields. What it actually marks is a document whose
behavior is read from its axes rather than its type, and it was still named for the first such
document. That blocks the next axis-native kind and hides what each reader means.
Nothing rewrites stored data here. This is a transitional accept-both window: readers accept
both values, writers now write only "axes", and CLUE-604's sweep flips stored data later. The
commits land readers-widen-before-writers-flip, so no commit in the middle can write a value the
app cannot read.
The approach
The ordering discipline is the good part. Readers widen (2fc531292) before writers flip
(58dbd1a6d) before the rules widen (9ed6aad23, cb8017666), and the PR is explicit that the
rules must deploy ahead of the app even though they were committed after it. The reasoning for
that — a widening rules change goes first because it changes no existing decision, a narrowing one
goes last because it must wait for a drain — is correct and worth keeping in the design doc.
isAxesType as a type guard rather than a boolean is the right call, and I verified the
stated reason: it narrows to exactly "axes" | "group", which is precisely what the runtime check
accepts, and db.ts needs that narrowing to build a discriminated-union member. A boolean return
genuinely would not compile there.
The sweep's one merged write per document is well-reasoned and I want to record why, because it
is subtle: type is the script's own work-queue key (where("type","==","group")), so committing
type separately from the axis fields would make write order load-bearing across the chunk
boundary, and a document whose type landed while its axis write failed would be permanently
half-migrated. The merged write makes every abort safe — each document is either fully migrated or
fully untouched, and a re-run selects exactly the remainder.
Assessment
This is careful, well-sequenced work and I'm approving it. The issues below are offered as input
to your plan rather than as gates — you have far more context on the CLUE-610/604/612 sequence than
I do, and I'd rather name the things that look like they could bite than guess at which of them are
already accounted for. Issue 1 is the one I'd genuinely weigh; the rest are minor.
The central risk on a rename like this is a missed reader, and a dedicated exhaustive sweep found
none — every site that decides on type either uses isAxesType or was deliberately rebased
onto an axis field, across src/, shared/, functions-v1/, functions-v2/, scripts/,
cypress/, MST enums, and the RTDB/Firestore type declarations. Two independent checks of the rules
confirm the widening is genuinely monotone (an added disjunct at each of two clauses, parenthesised
so precedence is unchanged), that no third type-testing clause was missed, and that both clauses
have both-values tests in both directions.
Two commits deserve credit rather than suspicion:
056985387fixes a real bug this branch introduced and caught itself —profileNamewas still
gated ontype === GroupDocumentafter the writers flipped, so no newly created document would
have had anaxisProfilestamped at all, and the sweep (which queries for"group") could
never have repaired them. Good catch.05ffb3cddrebases the tile activity badges ontoconcurrentinstead of the type. That is a
behavior change rather than a rename, and it is correct: every axes-typed document isconcurrent
by profile,concurrentis populated synchronously on both the open and create paths, and the new
tests fail against the old type-based gate.
Verification I ran locally
| Check | Result |
|---|---|
npm run check:types |
clean |
| jest — document models, db, tile badges, scripts | 19 suites, 347 passed |
| firebase-test rules suite (emulator) | 10 suites, 434 passed, 2 skipped |
| CI — Build, Jest, smoke, 14/15 regression shards | pass |
CI — regression shard 09 (tiles_copy_test_spec.js) |
fail — known-flaky spec |
Two notes on that table. The PR body says 430 rules tests; it is now 434 — the body is slightly
stale, not a discrepancy. And shard 09's failure is in tiles_copy_test_spec.js, a spec already
known to be flaky; I confirmed independently that this PR touches no copy or canvas code, and that
the one tile-adjacent change (tile-activity-badges.tsx) is an early-return gate governing whether
a badge renders inside a tile, with no path to changing how many tile-rows a copy produces.
Issues
1. Three checks were widened from a closed test to an open one, and the open set is designed to
grow — one of them gates student read access. — medium-high
src/models/document/document-utils.ts:131, src/models/document/document-types.ts:67,
src/models/document/document.ts:124
Raising this for your judgment rather than as a gate — you know the CLUE-610/604/612 sequence far
better than I do, and it may be that the right home for this is CLUE-611's per-site rebase rather
than here. The observation is that the concern isn't the accept-both widening itself; it's that
three checks moved from a closed test to an open one.
// document-utils.ts:131
- const isGroupDoc = metadata.type === GroupDocument; // matches exactly one literal
+ const isGroupDoc = isAxesType(metadata.type); // matches any axis-native documentand that value gates student read access:
if (user.isStudent) {
return ownDocument || isShared || isPublished || isGroupDoc || ...
}Today the two forms cover the same documents, so nothing is broken now. The problem is what happens
next. After CLUE-604's sweep, every axis-native document stores type: "axes" — that is the
point of the rename — and this PR's own rationale is that the old name "blocks the next kind." So
the moment someone adds the next axis-native kind, isAxesType returns true for it, and every
student in the class silently gains read access to it. Nothing in that PR's diff would touch this
file or show up in its review. The check has been bound to a set that is designed to grow, and the
growth is the feature this PR exists to enable.
Your own design doc already classifies it this way. The §5b table's "Means" column reads, for
this row: "read access — a permissions question" — and the table's preamble says the column is
recorded "so a later pass can rebase each onto the right axis; it is not acted on here." So the
misalignment is identified and deliberately deferred, which is a defensible call; I'm flagging it
because this is the one row where what's deferred is an access decision rather than a naming tidy.
It is also unmarked. This PR is careful about transitional sites elsewhere — document-kinds.ts:275,
document-types.ts:39, db.ts:121 and :1137 all carry explicit TRANSITIONAL comments. Here the
comment was updated ("Group and class-wide documents are accessible to everyone") without a marker,
so nothing tells the next reader that the predicate is standing in for something narrower than what
it literally tests.
The owner axis already answers the real question, and both guards are already used on metadata
objects of exactly this shape in Sort Work (document-group.ts:213-279):
const isGroupDoc = hasGroupOwner(metadata) || hasClassOwner(metadata);That is what the comment already claims, it stays correct when a fourth kind arrives with a
different owner, and — a bonus — it doesn't reference the transitional type at all, so it needs no
follow-up edit in CLUE-604's cleanup.
isSortableType (document-types.ts:67) is the same shape, and easy to miss. It widens through a
literal array rather than the predicate, so a grep for isAxesType does not find it:
export function isSortableType(type: string){
return [ProblemDocument, PersonalDocument, LearningLogDocument, ExemplarDocument,
GroupDocument, AxesDocument].indexOf(type) >= 0;
}It gates sorted-documents.ts:87 — which documents appear in Sort Work — so the next axis-native
kind is listed automatically, with no decision taken. On its own that is a lower-stakes default than
read access. What makes it worth mentioning is that it compounds with the one above: a future
axis-native kind would, in a single stroke and with neither file appearing in its diff, be readable
by every student and listed in Sort Work.
document.ts:124 (isGroup) is the same pattern, much milder. Also widened to isAxesType,
also named for groups, but it gates a MobX reaction (document-workspace.tsx:58) that closes a
stale group document on a group switch, not an access decision. It is safe today only by accident:
a class-wide document satisfies isGroup, but getDocumentOwnerFields (document-kinds.ts:197)
returns {} unless the owner type is "group", so groupId is undefined and the reaction returns
early. Nothing declares that dependency and no test pins it. hasGroupOwner fixes this one too.
This cuts against your stated scope line — "no reader is rebased onto a real axis... ten
judgment calls and this release has a deadline" — and I think that line is right for the other eight.
The case for an exception is that these are the sites where the deferral leaves behind a default
rather than just a misleading name — and the fixes are one-liners against guards that already exist.
The cheapest alternative, if you'd rather hold the scope line to CLUE-611, is a TRANSITIONAL marker
on document-utils.ts:131 and isSortableType naming what they really mean, so that whoever adds
the next axes-typed kind trips over them. Either way it's your call — I'm not asking to re-review.
2. "Each widened reader has a case for both values" is not true for three of the nine. — medium
The PR's testing section makes this claim, and it is the right policy — the transitional window is
exactly when a regression would ship. But:
document.ts:124(isGroup) — only the new value is tested. There is exactly one.isGroup
assertion in the entire test suite (document.test.ts:374) and it usesAxesDocument. If
isAxesTypewere narrowed back to accept only"axes", every test still passes. The untested
branch,"group", is the value every group document in the database has today. See issue 1
above — this getter has a problem larger than its coverage.document-title.tsx:28anddocument-workspace.tsx:209have no test coverage at all — no test
file exists for either component. Both are one-line predicate swaps, so the risk is low, but the
claim doesn't hold for them.db.ts'sCREATE_GROUP_DOCUMENTlog gate is tested only with the new value. Moot in practice —
after this PR no caller passes the old one — so I'd leave it.
An isGroup-with-GroupDocument case is three lines. If you take the issue-1 rebase, the test to
add alongside it is a class-wide-shaped document, which pins the part that is currently accidental.
3. The new "axes" delete tests are thinner than their "group" siblings. — low
firebase-test/src/canonical-pointers-rules.test.ts:86-99
The pre-existing "group" describe block has three cases; the new "axes" block duplicates two,
omitting "a user outside the class may not delete the group document." That case exercises the
class_hash conjunct rather than the type disjunct, so it doesn't undermine the monotonicity
claim — the "axes" branch just has slightly thinner regression coverage than "group".
4. The deploy plan has no rollback paragraph. — low, docs only
Rollback is cheap here (Release Production is four aws s3 cp calls driven by a tag input, and every
prior version stays in version/), which makes its absence more noticeable, not less. If 7.5.0 is
rolled back after "axes" documents exist, those documents are unreachable to students until
roll-forward. No data is lost — content lives in RTDB and only one Firestore string is unrecognised —
and teachers are unaffected (isDocumentAccessibleToUser returns early for
isTeacherOrResearcher). Worth one paragraph in §9 since the deploy plan is part of this PR's
deliverable. Suggested wording:
On rollback, re-dispatch Release Production with the prior tag;
"axes"documents created in the
window become unreachable to students until roll-forward. If a rollback is expected to be
long-lived, reverse the sweep by flipping the query value and the written value in
backfill-group-document-axes.ts— it runs as a service account and is unaffected by
preservesReadOnlyDocumentFields.
For what it's worth, no prior CLUE migration has shipped a rollback story either — including
consolidate-metadata-docs.ts, which is considerably more destructive — so this is a suggestion,
not a house rule being broken.
5. Step 5 of the plan could name its own re-check. — low, belongs to CLUE-604
Step 5 (7.6.0 removes the accept-both readers) is gated on "the sweep having run everywhere," which
describes a past event rather than a re-checked condition — and 7.5.0 → sweep → 7.6.0 spans a
release cycle during which document creation continues. The detection and the repair are both one
command (the script is dry-run by default and prints group-typed docs: N total), so a clause like
"re-run the dry run immediately before cutting 7.6.0; expect 0" is nearly free. Mostly CLUE-604's
runbook rather than this PR's.
Adjacent gap worth a line while you're here
src/components/document/document-workspace.tsx:341 — handleOpenGroupDocument has no .catch,
while both sibling handlers in the same file end in .catch(error => ui.setError(error)). It is an
async arrow reached from an onClick, and there is no global unhandledrejection handler and no
error boundary on that path, so any throw is silent to the user. This is pre-existing and does
not mitigate the transition risk (the bundle that fails is the already-deployed one), but it is a
small inconsistency sitting directly next to code this PR edits.
Scheduling — not a code issue, but it's the PR's own precondition
The PR states this must ship in 7.5.0, and that if 7.5.0 has been cut it "needs re-planning
rather than merging." As of today (2026-08-21): v7.4.0 (2026-08-05) is still the newest tag, so the
window is open — but that is 16 days, against 12 days for v7.3.0→v7.4.0, and both #2951 and
#2952 are still unmerged. Releases are manually dispatched, so there is no scheduled cutoff to
check against. The window is a live race between merging this stack and whoever next cuts a 7.5.0
containing unrelated work. Worth confirming with Scott that the plan still holds.
Findings that did NOT survive verification
Recorded so they don't get re-litigated:
- "A straggler's
"group"document written after the sweep is permanently missed" — backwards. The
sweep queries for"group", so those are exactly what a re-run finds; andtypeis read-only
to clients, so a swept document can't revert. Only the runbook nit (5) survives. - "No try/catch around
batch.commit()means one bad document aborts the run" — Firestore's
commit()issues an atomicCommitRPC with one status for the whole batch and no per-document
attribution, so the proposed "skip the bad one" fix is not implementable against this API. The
payload is four fixed field names over{"axes","group","classWide",true,null}, so a per-document
rejection isn't reachable; malformed input would throw atset(), notcommit(). Fail-fast is
also the correct policy, since step 5 removes the readers on the assumption the sweep succeeded. - "The mixed-version window is longer than 'an instant' / the failure is silent" — the PR already
says "no drain is possible because the window opens when the release does," which is that exact
point, and the silent path is in the already-deployed bundle, so this PR cannot fix it. One fair
nuance survives: the affected document is the group's only shared document, so an affected group
loses group work for that session — an impact framing the body doesn't state. - "Missed readers" — a dedicated exhaustive sweep found none.
…ype [CLUE-610] isDocumentAccessibleToUser tested the document type, and widening that test to the shared axis-native value would have moved it from a closed test to an open one: every kind added later would be readable by every student in the class the moment it was declared, with neither this file nor that decision appearing in the new kind's diff. `concurrent` is the field that answers the permissions question, and it is what the Firestore rules key their own grant on (isConcurrentClassDocument), because they cannot enforce group membership either. The owner is not used: a group- or class-owned document that is not readable by every member is a shape we may want later. Keeps the pre-sweep type as a transitional disjunct. Group documents created before `concurrent` was stamped store neither it nor the new type, and this runs on Firestore metadata for documents that have not been opened -- the thumbnail calls it to decide whether to suppress its own click, so the on-open backfill has not run and cannot. It tests the pre-sweep literal rather than isAxesType so a future axis-native kind cannot inherit read access through it. Also records that the function asks nothing about the class because it is only ever asked about the user's own class, which was previously unwritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… site [CLUE-610] `isGroup` sat in a family of getters that are all tests on `type` (isProblem, isPlanning, isPersonal, isLearningLog, isSupport, isPublished), but the question it answers is about the owner axis: a class-wide document shares the axis-native type and belongs to no group. As a type test it excluded class-wide documents only by accident -- they satisfy it, and the group-switch reaction returns early only because getDocumentOwnerFields leaves their groupId undefined. Nothing declared that dependency. Deleted rather than renamed to hasGroupOwner. It had one consumer, and a model getter of that name calling the imported guard of the same name reads as recursion at a glance; it would also leave a second way to ask a question the guard already answers, forcing that choice on every axis question after it. With it gone, every remaining member of the family really is a test on `type`. Reactivity is unaffected: the guard reads `uid`, an observable prop, inside the reaction's data function. The tests that stood for the getter now assert hasGroupOwner on models of both type values, and the class-wide case additionally pins groupId as undefined -- the fact the reaction's early return depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it [CLUE-610] handleOpenGroupDocument is an async arrow reached from an onClick with nothing awaiting it, and there is no global unhandledrejection handler or error boundary on that path, so a throw left the user watching the click do nothing. Both sibling document-open handlers in this file already end in .catch(error => ui.setError(error)). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "group" describe block has three cases and the "axes" block duplicated only two, omitting the one where a user outside the class attempts the delete. That case exercises the class_hash conjunct rather than the type disjunct, so it is not a gap in the monotonicity of the rules change -- but the block carrying it is the pre-sweep one, which CLUE-604's cleanup deletes. Without this the class check would lose its coverage at that point rather than at a deliberate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LUE-610] isSortableType is the third check that would have moved from a closed test to an open one, and it is the easiest to miss: it widens through a literal array, so a grep for isAxesType does not find it. It is not rebased -- it is a membership list keyed on a type string, and rebasing it means changing its signature to take a document, which belongs to CLUE-611's per-site pass. Instead it says so, says that a later axis-native kind is listed in Sort Work by default, and directs a kind that should not be listed to check the axis fields rather than add another type test. Listing is a far weaker default than read access. In the design doc: - 5b records the two sites taken off the type and why, including why the read-access check reads `concurrent` rather than the owner and why its transitional disjunct tests the pre-sweep literal rather than isAxesType. - 8 stops claiming every widened reader has a case for both values, and names the two components that have no test file at all. - 7 step 5 gates the reader removals on re-running the sweep's dry run immediately before cutting 7.6.0 rather than on the sweep having run, which is a past event rather than a re-checked condition. - 9 adds a rollback paragraph, and replaces the pre-sweep private-document risk with the one that remains: dropping the transitional disjunct before the sweep has run in an environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the review — pushed five commits addressing it. Summary of what changed and what I deliberately didn't do. Issue 1 — closed test → open testTook the rebase on the two where the deferral leaves a default behind, and marked the third. Your framing of that distinction is what decided it. Read access ( There's a wrinkle worth flagging, because it bit us before. This exact rebase was tried on 2026-07-22 and reverted the same day, and the revert was right at the time: this check runs on un-opened Firestore metadata. So the check keeps the pre-sweep type as a transitional disjunct: const isConcurrentDoc = !!metadata.concurrent || metadata.type === GroupDocument;It tests the pre-sweep literal, not This is also where your observation about the check being an access decision paid off twice: the function now records that it asks nothing about the class because it is only ever asked about the user's own class —
Issue 2 — the "both values" claimCorrected rather than patched over. Issue 3 — thinner axes delete testsAdded. Your read is right that it exercises the Issues 4 and 5 — deploy planRollback paragraph added to §9, close to your wording. Step 5 now says to re-run the dry run immediately before cutting 7.6.0 and expect Adjacent gap
Verification
435, not 434 — that's your count plus the delete case above. The PR body's stale 430 and 3613 are updated. Still open, and it's the one you flagged that I can't closeThe 7.5.0 scheduling precondition. |
Two conflicts, both in comments rather than logic. firestore.rules: master removed the Jira keys from this file, because the repo is public and the instance behind them is not. This branch's transitional notes on the two accept-both clauses named CLUE-604's sweep, so they are reworded the same way -- say what has to happen rather than which ticket says so. The second note did not conflict textually but had the same problem, so it is reworded too. document-utils.test.ts: master added a getDocumentLogParams suite immediately above the accessibility suite this branch rewrote. Both kept; the accessibility suite keeps this branch's title, since it no longer tests the document type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLUE-610
Renames the generic document
typefrom the stored value"group"to"axes", across the app, the Firestore rules, and the one-time sweep script.Stacked on #2951 (
CLUE-610-deferred-open-and-create-rules) — review that first. The diff shown against this base is only this work.Why
type: "group"no longer means "this is a group document". Regular group documents and class-wide collaborative documents both store it, and they are told apart only by guards over their stored axis fields. What the value actually marks is a document whose behavior is read from its axes rather than from its type — but it is still named for the first such document. That costs us twice:type. A freshDocumentTypeenum value reintroduces the per-type branching this refactor exists to remove; storing"group"says something false about a document that is not a group's.GroupDocumentmean "a group's document", some mean "a concurrent document", some mean "an axis-native document". The shared literal makes them look identical.Why now, and the deadline
typeis a stored value, so changing it needs an admin sweep, and a sweep needs a full client drain in front of it. CLUE-604 already schedules exactly that sweep — addingtypeis one more field in a write that is already happening to these documents.This means the app change has to ship in 7.5.0. Landing it in 7.6.0 instead would make the sweep wait for 7.6.0 to drain, but 7.6.0's own content is the cleanup the sweep unblocks — so 7.6.0 would have to be split, or the sweep run twice. v7.4.0 is still the newest tag as of 2026-08-08, so the window is open; if 7.5.0 has been cut by the time this is picked up, this needs re-planning rather than merging.
The shape of it: a transitional accept-both window
Nothing rewrites stored data here. Until CLUE-604's sweep runs and old clients drain, both values exist, so readers accept both and
GroupDocumentstays exported.Readers widen before writers flip, so no commit in the middle can write a value the app cannot read. The commits are task-sized and land in that order:
1768c35c2AxesDocument = "axes"and theisAxesTypepredicate2fc53129258dbd1a6d"axes"; RTDB declarations widened9ed6aad23,cb80176666d03bba57,0065dbfdfisAxesTypeis deliberately a type guard, not aboolean—db.ts:727narrowstypeto a literal to build a discriminated-union member, and a plain boolean return breaks that. It also means the post-sweep cleanup is one edit to one function rather than one per call site.Two things worth a careful look
The sweep script now makes one merged write per document rather than committing two disjoint write lists. This matters more than it looks:
typeis the script's own work-queue key — the driving query iswhere("type", "==", "group"), so once a document'stypeflips it stops matching and can never be returned by a re-run. Committingtypeseparately from the axis fields would make write order load-bearing across the 400-document chunk boundary, and a document whosetypelanded while its axis-field write failed would be permanently half-migrated with no recovery path.The rules widening is monotone — an added
||term at each of the two clauses, no operand removed — so every document that reached a decision before reaches the identical decision now, and exactly one additional type literal is granted.isValidDocumentCreateRequestneeded no change: it constrains only thattypeis present, never its value, because the create-time rules key on owner instead.Review follow-ups
Added after @kswenson's review. Each is its own commit.
Three checks would have gone from a closed test to an open one. Widening them to the shared axis-native value binds them to a set this rename exists to let grow, so a future axis-native kind would inherit the behaviour with neither file appearing in its diff. Two are rebased; the third is marked.
Student read access (
isDocumentAccessibleToUser) now readsconcurrent, which is the field that answers the permissions question and what the Firestore rules key their own grant on (isConcurrentClassDocument) — they cannot enforce group membership either. The owner is deliberately not used: a group- or class-owned document that is not readable by every member is a shape we may want later.It keeps the pre-sweep type as a transitional disjunct, and that disjunct is load-bearing. Group documents created before
concurrentwas stamped store neither it nor the new type, and this check runs on un-opened Firestore metadata — the thumbnail calls it to decide whether to suppress its own click, so the on-open backfill has not run and cannot. It tests the pre-sweep literal rather thanisAxesType, so a future kind cannot inherit read access through it. The function also now states that it asks nothing about the class because it is only ever asked about the user's own class, which was previously unwritten.The
isGroupgetter is deleted, not widened. It had one consumer — the group-switch reaction — which now askshasGroupOwner(primary)directly. As a type test it excluded class-wide documents only by accident: they satisfy it, and the reaction returns early only because theirgroupIdis undefined. With it gone, every remainingis<Type>getter really is a test ontype.isSortableTypeis marked, not rebased. It is a membership list keyed on a type string, so rebasing it means changing its signature to take a document — CLUE-611's pass. Its comment now says a later axis-native kind is listed in Sort Work by default, and directs a kind that should not be listed to check the axis fields rather than add another type test. Listing is a far weaker default than read access.Also: the "axes" delete block gains the outside-class case its "group" sibling had (that block is what CLUE-604's cleanup deletes, so the coverage would otherwise vanish silently);
handleOpenGroupDocumentgains the.catchboth its siblings already had; and the design doc gains a rollback paragraph, a dry-run re-check before the 7.6.0 reader removals, and the two testing exceptions noted above.Deploy sequencing
This fits the release plan already agreed for the CLUE-550 line of work — 7.5.0 CLUE-610, 7.6.0 CLUE-604, 7.7.0 CLUE-612:
npm run deploy:firestore:rules. Rules first, as usual, and here required rather than merely conventional. Step 7 goes the other way round, for the reason below."axes"on creation. Deadline-bound, see above. Deploy outside class hours — see the caveat below.APPLY=1, against each environment in turn. An operational step gated on step 3 having actually happened, deliberately separate from any version going out. Shipping the script is not running it — this PR ships it; the run is CLUE-604's."group"inisAxesType,isSortableType, andDocumentTypeEnumValues. They share CLUE-604's gate exactly (safe once the sweep has run everywhere, unsafe before), so there is no reason to leave them as unscheduled cleanup. Follows step 4 rather than accompanying it."group"branch from the canonical-race delete. (concurrentChangeOkis deleted wholesale by CLUE-612, so its own branch needs no separate handling.)Steps 1 and 4 are the two that are not a release going out.
Two things survive that cleanup rather than being dropped with it. The
GroupDocumentconstant stays — it is still the group kind's registered name, and thekindaxis is not renamed. And"group"stays in the RTDB type declarations, because RTDB is never swept and keeps a permanent mix of both values.Why the rules move first here and last in 7.7.0
Both are rules changes and they sit on opposite sides of their releases. The rule is direction:
typevalue and takes nothing away, so deploying it against the currently-live app changes no decision. It must precede the app that starts writing the new value, or that app's writes hit rules that don't know the value yet.For step 7 the write in question is the on-open
concurrentbackfill. Two independent things retire it: CLUE-604's step 3 deletes the code in 7.6.0, and the sweep (step 4) removes the unmigrated documents that would trigger it in the first place. Neither is enough alone, because a browser still running the 7.5.0 bundle contains the backfill — hence step 6's drain, and hence 7.6.0 and 7.7.0 being separate releases at all.And why step 1's rules must precede the app rather than merely accompany it. The widening is monotone — an added
||term at each clause, no operand removed — so deploying it against the currently-live app changes no decision, which is what makes rules-first safe. The reverse order is not: once the app writes"axes", two class members can race to create the same canonical document, and the loser's cleanup delete is governed by a rules clause that under un-widened rules matches only"group". That delete runs throughdeleteOrphanDocument, which is best-effort and swallows its own error — so the failure is silent, and the symptom is Firestore metadata quietly accumulating for documents nobody can reach.Deploy 7.5.0 outside class hours. From the instant the writers ship, a student still on the old bundle who opens a brand-new shared document — one a classmate on the new bundle just created — fails to open it, and no drain is possible because the window opens when the release does. Only a group's or class's first document created in that window is exposed; everything already stored is untouched. Accepted rather than designed around, since deferring the writers forces the second sweep this work exists to prevent.
Deliberately not here
typeis not swept. Existing records keep"group"forever — onlycreatedAtis read back from that tree, and the tree is slated for removal. The declarations widen so both values are representable; the data is not touched.concurrentbackfill andgetDocumentTitleare widened here and removed by CLUE-604, so the two stories do not both claim those sites.Testing
Full unit suite (3715) and the emulator-backed rules suite (435) both pass;
check:typesandlint:buildclean. The rules tests cover both values at both clauses.Each widened reader has a case for both values — the transitional window is the only time both occur, and exactly when a regression would ship — with two stated exceptions:
document-title.tsxanddocument-workspace.tsxhave no test file at all, so their one-line predicate swaps go in uncovered. (A third,db.ts'sCREATE_GROUP_DOCUMENTlog gate, is covered with the new value only, which is moot because after this PR no caller passes the old one.)A literal-string sweep across
src,shared,functions-v2,scripts, andfirestore.rulesfound no reader left comparing against a bare"group"; the remaining hits are type declarations that legitimately admit both values, the owner axis (DocumentOwnerType), the kind registry, and unrelated uses of the word — a CSS class, drawing-object types, ARIA roles, a Sort Work section key, a sticky-note audience.The sweep script's tests pin the query predicate (
type == "group", not the value it writes) and the 400-document chunk boundary, including that the final partial batch is actually committed.Jira
CLUE-610 and CLUE-604 have been updated: CLUE-610 gained the rename plus its release requirements, and CLUE-604's step 2 was restated around the merged write. CLUE-612 was re-read and needs nothing — it is written entirely in terms of the shared marker, never the type value.
🤖 Generated with Claude Code