CLUE-610: Deferred open and create-time ownership rules - #2951
Conversation
…rate metadata comment [CLUE-610]
…-consistent group [CLUE-610] Adjusts two existing student-create tests to name the acting student as `uid` instead of relying on specDocumentDoc()'s default teacher owner — they were passing only because the create rule ignored `uid`, which is the hole this change closes.
…er [CLUE-610] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E-610] groupDocumentsEnabled and classWideDocuments are independent unit config, so a unit declaring class-wide documents without enabling group documents lost the eager re-open on reload and landed on an empty workspace.
…one [CLUE-610] documentOwnerIsCallersClass lets any class member mint any number of class_<class_hash>-owned, concurrent:true documents; the class identity is corroborated by the token but the authority to mint under it is not. Record this in both registers that already carry the group-membership residual.
…LUE-610] concurrentCreateOk tested `!= true`, which lets a truthy non-boolean like concurrent: "true" skip the owner check; DocumentModel.concurrent is types.maybe(types.boolean), so such a document throws when opened. `== false` is strictly tighter at no cost, since the deployed client only ever writes the field absent or boolean true.
…pe list [CLUE-610] kDeployedShapes lists every document shape the deployed client creates except group and class-wide documents, the only two shapes the new create rule can reject; their breakage in the window between the rules deploy and the app deploy is accepted, not an oversight.
…LUE-610] The "create path" test asserted the opened result but not that findFirestoreMetadata was skipped, so reverting the create path to return a bare key instead of reusing its own write would still pass.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2951 +/- ##
==========================================
- Coverage 86.10% 85.53% -0.58%
==========================================
Files 983 982 -1
Lines 56122 56120 -2
Branches 14807 14809 +2
==========================================
- Hits 48325 48000 -325
- Misses 7777 8102 +325
+ Partials 20 18 -2
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-deferred-open-and-create-rules
|
| Run status |
|
| Run duration | 03m 39s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
…-610] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pen-and-create-rules
…LUE-610] `getOrCreateClassWideDocument` returns a documentKey and deliberately opens nothing, while `getOrCreateGroupDocument` and `getOrCreateCanonicalDocument` both return an opened document. Rename it `resolveClassWideDocument`, so `getOrCreate*` means "opens one" and `resolve*` means "yields a key" throughout. `ICanonicalDocumentRef` becomes `IResolvedCanonicalDocument`: everywhere else in db.ts a `ref` is a Firestore or RTDB reference. Two comments on the resolver said "the class" where they meant every client racing for the slot. Canonical slots are not specific to class-wide documents — a single user's several tabs will converge on one document the same way — so say so. The metadata field's comment now names the resolve/open separation it was calling "this split". Docs: the roadmap's owner-axis row carried a paragraph of enforcement detail that is not status — what firestore.rules can and cannot corroborate about an owner, and the group and class residuals. That moves to the current-state doc, which is renamed reading-axes-in-code.md -> axes-current-state.md now that it records enforcement alongside reading. The row keeps the residual only as the reason the axis is still in progress. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Defers opening class-wide documents until needed and tightens document creation ownership rules.
Changes:
- Separates canonical document resolution from opening.
- Restores deferred class-wide primary documents on reload.
- Adds ownership/concurrency validation, tests, and documentation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/models/document/document-utils.ts |
Updates documentation reference. |
src/models/document/document-axes.ts |
Updates axes documentation reference. |
src/lib/db.ts |
Introduces deferred canonical document opening. |
src/lib/db.test.ts |
Tests resolver and opening behavior. |
src/components/document/document-workspace.tsx |
Restores class-wide primary documents. |
firestore.rules |
Validates document owners and concurrent creation. |
firebase-test/src/documents-rules.test.ts |
Tests document creation rules and deployed shapes. |
docs/superpowers/specs/2026-08-07-clue-610-deferred-open-and-create-rules-design.md |
Documents the design and rollout. |
docs/document-axes/README.md |
Updates owner-axis status and links. |
docs/document-axes/axes-current-state.md |
Documents current rule enforcement and residuals. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The create rule required a group owner to agree with the document's own `offeringId` and `groupId`. Both are client-controlled, so the agreement proved nothing about the offering: a student could create a document owned by `group_<any offering>_<any group>` and then claim that slot's canonical pointer, for any offering of their class — including assignments the class has not started. Only `context_id` confined them to their own class. Read the offering from the `offering_id` claim instead. The portal mints it for a learner and deliberately omits it for a teacher or researcher, which matches who creates group documents: getOrCreateGroupDocument requires the caller to be in a group, and only students are. A caller without the claim is denied. It survives a reload — convertURLToOAuth2 rewrites the launch URL with resourceLinkId, which the portal resolves back to that offering's learner. The residual is now what the comments already claimed: a student can create a document owned by another group in their own offering, because no claim proves group membership. The rules tests had been seeding group documents through a teacher client, which this correctly denies; those seeds move to adminWriteDoc, which is what a fixture should be. The student auths carry an `offering_id` claim and the teacher and researcher auths do not, mirroring what the portal mints. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pen-and-create-rules Picks up the base branch's post-review refactors now that it has landed on master. The one conflict was two rows of the document-axes status table: master's "none" wording for the curriculum range, and this branch's rename of reading-axes-in-code.md to axes-current-state.md in the permissions row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pen-and-create-rules Master brought in the axis-profiles work, which overlapped this branch in two files. docs/document-axes/README.md: this branch renamed reading-axes-in-code.md to axes-current-state.md and added the rules content; master rewrote the same status rows to introduce axis profiles. Kept master's axis-profile wording and its new status row, re-applied this branch's rules additions on top, and pointed the links at the new filename. src/lib/db.ts: two independent type declarations landed at the same line; kept both IResolvedCanonicalDocument and IDocumentMetadataAtCreation. docs/document-axes/axes.md: the rename auto-merged, so master's new link to reading-axes-in-code.md was left dangling. Repointed to axes-current-state.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLUE-610] The create rules cannot corroborate that a caller belongs to the group they name, so a student can create a document owned by another group in their own offering. The docs framed that as a gap awaiting either a group claim in the portal-minted token or group membership mirrored into Firestore. Neither is planned: CLUE groups are a CLUE concept, and the portal has no reason to model them. Say so, and drop the text that presented closing this as scheduled work — including the owner-axis row's claim that it is why the axis is still in progress. That row stays in progress on the work that actually remains: the user level, and a getter returning which owner a document has. 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.
PR Review Summary
Changes: 13 files, +600 / −80 lines
What it does
CLUE-610's second half — the things that shouldn't be left as-is once a real curriculum unit
turns the class-wide document on. Two independent changes share one gate:
- Opening a unit no longer waits on the class-wide document.
getOrCreateCanonicalDocumentsplits into a resolver (resolveCanonicalDocument, which
converges the class onto one document per slot) and a thin opener over it. A declared
class-wide document is resolved at unit load but not opened, trading a second metadata read,
an RTDB fetch, aDocumentModeland a history subscription — measured at ~700ms per student
per load — for a single pointer read. It is opened on demand instead. - A document can no longer be created under someone else's name.
isValidDocumentCreateRequestpreviously constrained neitheruidnorconcurrent. It now
admits three owner shapes — the caller,class_<class_hash>of the caller's own class, or
group_<offeringId>_<groupId>agreeing with both the caller's token and the document's own
fields — and permitsconcurrent: trueat creation only under one of the two synthetic owners.
A third, smaller fix rides along: guaranteeInitialDocuments was gated on groupDocumentsEnabled,
which a unit may legitimately leave off while declaring classWideDocuments, so such a unit lost
its primary document on reload. The gate now covers both.
The approach
The split is the right shape. Separating convergence from opening is the real insight here —
the get-or-create has to happen at unit load because that is what makes the whole class agree on
one document, but opening it does not. Returning firestoreMetadata from the paths that already
hold it (db.ts:936, db.ts:969) keeps getOrCreateGroupDocument at exactly its previous read
count, so the deferral costs group documents nothing.
On the rules side, the choice not to constrain type (firestore.rules:232-236) initially looks
like a gap and is in fact correct: class-wide and group documents both store type: "group",
and target-architecture.md explicitly names branching on type as the anti-pattern the axes
work is retiring. Tying the owner check to type would buy no boundary and would have to be
reverted under a coordinated rules deploy when CLUE-604 lands.
Both residuals are recorded rather than assumed away, in the rules comments and in
axes-current-state.md. That is the right instinct, and the reason the review below is mostly
about the description of one residual rather than its existence.
Assessment
This is solid work and I'd approve it. I ran a five-lens find pass and then put every
candidate finding in front of an adversarial verifier, and the security findings all dissolved
in the same direction: the attacks are real but pre-date the PR, and this change strictly
narrows them. Worth stating plainly, because it is the core question on a rules change:
- Pre-PR,
isValidDocumentCreateRequesthad nouidconstraint at all — any class member
could mint a document under anyuidwhatsoever, including a classmate's real
platform_user_id, already stampedconcurrent: true. The PR inserts two conjuncts into an
AND chain and touches nothing else in the file. It is monotonically restrictive: it can only
deny more. - The post-PR residual (own offering, any group in it) is a strict subset of what was
reachable before.
I checked the deploy story too, since that is where a rules-ahead-of-app change usually breaks:
every client create path funnels through getDocumentOwner(), which only ever returns the
caller's uid or one of the two synthetic owners — exactly the three shapes the rule admits. The
foreign-uid writes I went looking for (exemplars, comment-created documents, published
supports) all turn out to be admin-credential writes from Cloud Functions, which bypass
firestore.rules entirely, or go to a different collection with its own rule. So the "nothing
else may break" claim holds for the shapes I could find.
Verification I ran locally
| Check | Result |
|---|---|
npm run check:types |
clean |
npx jest src/lib/db.test.ts |
43/43 pass |
| firebase-test rules suite (emulator) | 10 suites, 430 passed, 2 skipped |
| CI — Build, Jest, 10 regression shards, smoke | all pass |
One test-quality result worth calling out: a verifier mutation-tested the new rules by disabling
documentOwnerOk() and concurrentCreateOk() in turn. Each mutation turns the expected tests
red, so the new clauses have genuine regression-detecting coverage rather than tests that merely
happen to pass. That is better than most rules test suites manage.
Issues
1. The reopen-primary fix has no automated coverage at any layer. — medium
src/components/document/document-workspace.tsx:204
The || classWideDocuments?.length gate is a real bug fix (a unit declaring classWideDocuments
with groupDocumentsEnabled off silently lost its primary document on reload), and it is
verified only by hand in Chrome, as the PR body says. There is no document-workspace test file
in the repo at all, guaranteeInitialDocuments is reachable only from componentDidMount, and
no cypress spec references classWideDocuments. So the next refactor of this method regresses
the fix silently. Given the rest of the PR is unusually well tested, this one stands out.
Failure scenario: someone later simplifies the gate back to groupDocumentsEnabled, or changes
the primaryDocMetadata.type === GroupDocument check; npm test and the full regression suite
both stay green, and class-wide-only units lose their primary document on reload again.
2. The firebase-test rules suite is not run by any CI workflow. — medium (repo-level)
grep -rln firebase-test .github/workflows/ returns nothing. The 145 new lines of rules tests —
the stated safety net for shipping these rules a release ahead of the app — only ever run when
someone remembers to run them locally. This isn't a defect introduced by the PR, but this PR is
the one whose deploy argument rests on those tests, so it's the natural moment to raise it.
Two frictions make "remember to run it locally" worse than it sounds: firebase-test/node_modules
isn't installed by the root npm install, and npm run test:exec now fails outright —
firebase-tools 15 requires Java 21 and the documented requirement in CLAUDE.md is just "Java".
I had to fall back to npx firebase-tools@13 to run it at all.
3. The accepted group residual understates its consequence. — low/medium, documentation only
firestore.rules:196-201, docs/document-axes/axes-current-state.md:127-132
Both stop at "a student can still create a document owned by another group in their own
offering," which reads like a stray object nobody sees. The actual reachable consequence is
larger, because two unchanged rules compose with it: canonicalPointerCreatable
(firestore.rules:755) checks only class_hash and never group membership, and pointers are
immutable (allow update, delete: if false) — so the same caller can win and permanently hold
that group's canonical slot, which every real group member then silently converges onto via the
fast path. isConcurrentClassDocument (firestore.rules:613) then grants history read/write to
the whole class, not the group. Since the residual is being deliberately accepted and written
down, the write-up should say what is being accepted. A sentence or two, not a code change.
4. Jira keys land in firestore.rules for the first time. — low
firestore.rules:119 (CLUE-612), :120 (CLUE-604), :125 (CLUE-612)
This repo is public; the Jira instance behind these keys is not. A reader outside Concord hits
That deletion is CLUE-612 and has no way to find out what CLUE-612 says, which is a shame here
because the surrounding comments are otherwise unusually self-contained — they explain the
transitional allowance, why it exists, and what has to drain before it can go, all without
requiring the ticket. The keys are the one part a public reader can't follow.
firestore.rules had zero Jira keys before this PR, so this is a new precedent in that file
rather than a continuation. (The wider repo does have ~62 in src/, so this isn't a rule anyone
has been enforcing — hence low severity.) Suggestion: keep the explanatory prose exactly as it is,
and move the bare ticket references to the PR description, or phrase them so the sentence survives
without the key — "once the backfill migration has drained" rather than "once CLUE-604 has drained."
Pre-existing, worth a follow-up ticket (not this PR's to fix)
Three separate verifiers independently landed on the same unchanged hole, which suggests it's
worth capturing somewhere:
canonicalPointerCreatable lets any class member permanently squat any canonical slot.
firestore.rules:755-758 requires only keys().hasAll(["documentKey", "createdAt", "createdBy"])
— presence, not type or non-emptiness. Compare the neighboring isValidCommentTagRequest, which
does check label is string && label.size() > 0. Because pointers are immutable and the resolver's
fast path returns whatever the pointer names, a single hand-crafted write of
{documentKey: "", ...} into a slot permanently kills that slot for the whole class — no document
required. Identical before and after this PR. Adding documentKey is string && documentKey.size() > 0
would be a small, safe hardening, and would sit naturally alongside CLUE-612.
Nits — take or leave
resolveClassWideDocumentispublic, but its return value has no production consumer.
createDeclaredClassWideDocumentsdiscards it, and Sort Work finds these documents through an
independent metadata query, never through the pointer key. That appears to be intentional (the
caller's only job is convergence), butpublicon a method whose result nobody reads invites the
next reader to go looking for the consumer. Worth a word, orprivate.concurrent: nullat create is rejected where an omitted orfalsevalue is accepted —
data.get("concurrent", false)substitutes the default only for an absent key. Unreachable
from the app, which never stamps an explicit null; noting it only because a future script could.- Units with
classWideDocumentsandgroupDocumentsEnabledoff now pay one extra
findFirestoreMetadataread per mount whenever any primary document is persisted, even an
ordinary personal document. That's inherent to the fix — you can't know whether the primary
document is a group document without reading its metadata — so I don't think it's avoidable,
just worth knowing it's there in a PR that's otherwise about removing a read. - The dead
case SupportPublication:atsrc/lib/db.ts:770cost me a verifier run. It has had
no caller since supports moved tomcsupports, and it reads as a live client create path. Not
this PR's debt, but a one-line comment or a deletion would save the next reader the same trip.
Findings that did NOT survive verification
Recorded so they don't get re-litigated later — each was checked against the pre-PR rules at
merge-base 5fa79768d and refuted:
- "A classmate can capture another group's canonical document" — mechanically true, entirely
pre-existing; the PR strictly narrows the create side. Rolled into the documentation point (3)
and the follow-up ticket above. - "The rules don't tie
typeto the owner shape" — deliberate and correct; class-wide documents
aretype: "group", so the tie would close nothing and would fight the axes migration. - "The classmate-plus-
concurrenttest passes for the wrong reason" — the create is denied
independently by both clauses; mutation testing showedconcurrentCreateOkis properly isolated
by its two sibling tests. A legitimate belt-and-braces deny test. - "
SupportPublicationis missing from the deployed-shapes list" — supports are admin-written to
mcsupportsby a Cloud Function and already covered insupports-rules.test.ts. - "The
confirmed existscomment overstates what was verified" — the comment says "without
fetching its metadata" and calls the property an invariant in the same breath; the invariant is
genuinely established by create-then-claim pluspointerConfirmsCanonicalplus the canonical-delete ban. - "Deferring the open makes a corrupt pointer fail silently" — pre-PR it landed in the same
fire-and-forget.catchwith a bareconsole.error. Real delta is one lost log line, in a state
no app writer can produce.
The workspace's reopen gate was widened to cover units that declare a class-wide document without enabling group documents, and nothing tested it: there is no document-workspace test file, guaranteeInitialDocuments is reachable only from componentDidMount, and no cypress spec named classWideDocuments. The next refactor of that method would have regressed the fix silently. Add a unit that declares a class-wide document with groupDocumentsEnabled off -- the combination the gate exists for -- and a cypress spec that edits the class-wide document from Sort Work and reloads. The spec closes the document in Sort Work before reloading. Sort Work re-opens whichever document it had open, so left open it loads the document on reload no matter what the workspace does, and the assertion passes with the gate narrowed back to groupDocumentsEnabled. Closing it first is what makes the test fail when the gate is narrowed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rules comment and the axes notes both stopped at "a student can create a document owned by another group in their own offering," which reads like a stray object nobody sees. Two rules this change does not touch compose with it to make the consequence larger. canonicalPointerCreatable checks only that the caller is in the class, never group membership, and pointers are immutable -- so the same caller can claim that group's canonical slot and hold it permanently, and every real member of the group converges onto that document through the resolver's fast path. isConcurrentClassDocument then grants read and write on its history to the whole class rather than to the group. The residual is being accepted deliberately, so write down what is being accepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repo is public and the Jira instance behind these keys is not, so a reader outside Concord hits "That deletion is CLUE-612" with no way to find out what it says. The surrounding comments are otherwise self-contained: they explain the transitional allowance, why it exists, and what has to drain before it can go, all without the tickets. The keys were the one part a public reader could not follow, and they were the first in this file. Keep the prose and say what has to happen rather than which ticket says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its return value has no production consumer -- createDeclaredClassWideDocuments discards it, and Sort Work finds these documents through an independent metadata query rather than through the pointer key -- so `public` invited the next reader to go looking for a consumer that does not exist. Make it private and say why the key is returned at all. Also note that nothing here creates a SupportPublication: supports are written to mcsupports by a Cloud Function with admin credentials, so that case reads as a live client create path when it is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Testing section listed the reopen case only under manual verification. It now has an automated test, and the note says what makes that test meaningful: closing the document in Sort Work first, because Sort Work re-opens whatever it had open and would otherwise carry the assertion on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Responses below, in the order raised. Four commits, plus one docs commit updating the design spec's Testing section. 1. The reopen-primary fix has no automated coverage — fixed
The part worth reporting is that my first version of this test passed with the fix reverted. Sort Work re-opens whichever document it had open, so it was loading the document on reload regardless of what
The unit is a single file with inline problem sections, so no new section-file tree. It also asserts the "Whole Class" section holds exactly one document, which has held across every run — the pointer converges, and repeated runs don't accumulate throwaway documents in the QA partition. 2.
|
Making it private only moved the problem: the two tests that exercise the resolver directly had to reach it through `(db as any)`, which costs real type checking in the tests to satisfy a visibility keyword. The review point behind the change was that `public` on a method whose return value nobody reads sends the next reader hunting for a consumer that does not exist. The comment answers that on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Built on #2949, now merged; this PR targets master.
CLUE-610's remaining half: the things that should not be left open once a real curriculum unit turns the class-wide document on.
Opening a unit no longer waits on the class-wide document
getOrCreateCanonicalDocumentsplits into a resolver — which converges the whole class onto one document per slot — and a thin opener over it. A declared class-wide document is now resolved at unit load but not opened, so the fast path costs one pointer read instead of a second metadata read, an RTDB fetch, aDocumentModel, and a history subscription for every student on every load. That was measured at ~700ms.Group documents are unchanged:
getOrCreateGroupDocumentstill opens, and because the resolver hands back the metadata it already holds on the legacy and create paths, opening one costs no extra read.The document is opened on demand instead — from Sort Work, or by
guaranteeInitialDocumentsafter a reload with it as the primary document. That second path was gated ongroupDocumentsEnabled, which a unit can legitimately leave off while declaringclassWideDocuments; the gate now covers both, so such a unit no longer loses its primary document on reload.A document can no longer be created under someone else's name
isValidDocumentCreateRequestconstrained neitheruidnorconcurrent, so a class member could create a document stamped with a classmate's id — it would then appear under that classmate's name in Sort Work — or one already marked class-shared, which hands the whole class read and write on its history.The obvious fix does not apply, because documents several people edit together are deliberately owned by a synthetic id rather than a person. So the rule accepts three shapes, each corroborated by the caller's token or by fields the document itself carries: the caller;
class_<class_hash>of the caller's own class; orgroup_<offeringId>_<groupId>agreeing with the document's ownofferingIdandgroupId.concurrent: trueat creation requires one of the two synthetic owners.Two residuals are deliberate, and are recorded in
docs/document-axes/README.mdand in the rules comments rather than left implicit:concurrentalso remains settable on an existingtype: "group"document, a transitional allowance the backfills need. CLUE-612 closes it once CLUE-604's migration has drained.Deploying this
The rules must go out a release ahead of the app, per the usual pattern. Group and class-wide document creation is expected to fail in the window between the two — the canonical pointer paths in #2949 move — and neither is in use in a released unit. Nothing else may break, which is what the new "document shapes the deployed client creates" tests hold: one create per type the current client writes, all still permitted. Deliberately no
keys().hasOnly(...)allowlist on the create — that would have to enumerate every shape the deployed client writes and is the change most likely to break unrelated document types.Verification
npm test,check:types,lint:build, and thefirebase-testemulator suite all green.The reload case above is now covered by
cypress/e2e/functional/document_tests/class_wide_document_spec.js, on a newdemo/units/qa-class-wideunit that declares a class-wide document withgroupDocumentsEnabledoff. The spec closes the document in Sort Work before reloading: left open, Sort Work re-opens it on reload and the assertion passes even with the gate narrowed back togroupDocumentsEnabled. Confirmed by reverting the gate and watching it go red.That suite of rules tests, which the deploy argument above leans on, was not run by any workflow. #2976 adds it to CI.
Confirmed in Chrome against live Firestore on
demo/units/qa: the document is created but absent fromstores.documentson both the create and fast paths; Sort Work shows it under all four filters and opens it on demand with an Edit button; a reload with it as the primary document restores it withgroupDocumentsEnabledoff (checked with the Problem tab active, so Sort Work could not be what re-opened it); group documents open unchanged.Worth knowing for anyone repeating that:
unit=qaloads the unit from the remote curriculum repo, which has no class-wide slot, so the feature silently isn't there. The local unit has to be named outright —unit=http://localhost:<port>/demo/units/qa/content.json.One nuance the manual run turned up: expanding the "Whole Class" section in Sort Work opens the document, because rendering the thumbnail fetches it. Still on demand, still off the unit-load path, but a lower bar than an explicit open.
🤖 Generated with Claude Code