CLUE-610: name the axis profiles and record each document's profile - #2965
Conversation
… made from [CLUE-610] An axis profile is a named bundle of positions on the axes — the thing people already mean by "a class-wide document". The four the application uses now live in one file, and every kind is registered against one, so a unit config can add a document to an existing axis combination but cannot invent one. Each document records the profile it was created from, so a migration that changes what a profile means can find its documents without querying the axis fields it is there to change. The field is declared on no runtime type: reading it would mean widening a type first, which keeps the axis guards the only way to ask how a document behaves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2965 +/- ##
===========================================
+ Coverage 69.96% 85.73% +15.77%
===========================================
Files 973 979 +6
Lines 55842 55888 +46
Branches 14736 14744 +8
===========================================
+ Hits 39068 47918 +8850
+ Misses 16740 7951 -8789
+ Partials 34 19 -15
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-axis-profiles
|
| Run status |
|
| Run duration | 10m 48s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
1
|
|
|
0
|
|
|
5
|
|
|
0
|
|
|
219
|
| View all changes introduced in this branch ↗︎ | |
Tests for review

cypress/e2e/functional/document_tests/tiles_copy_test_spec.js • 1 failed test • Regression tests
| Test | Artifacts | |
|---|---|---|
| Test copy tiles from one document to other document > Verifies copy button states based on tile selection |
Test Replay
Screenshots
|
|
# Conflicts: # src/models/document/document-kinds.ts
tealefristoe
left a comment
There was a problem hiding this comment.
I think this all looks good 👍
My only high level suggestion would to be make sure all documents that discuss kind are up to date now that some responsibilities are split off and handled by profile.
…-610] Documents created before `axisProfile` existed carry no profile name, which is exactly the cohort a later migration needs to select. The sweep now stamps one on every document its query returns that lacks it, deriving the name from scope -- a groupId means the group profile, its absence the class-wide one -- rather than from `kind`, since a class-wide document's kind is whatever its unit declared and this script cannot resolve unit-declared kinds through the registry. The passes now build one merged write per document instead of one write per pass. The profile pass overlaps both of the others, so a group document missing `concurrent` would otherwise take two batched writes to reach the same state. The script repeats the profile names as literals because scripts/ resolves no modules from src; the test asserts against the definitions themselves so a rename fails here rather than drifting silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es [CLUE-610] Review feedback. The docs still described a kind as declaring `ownerType` and `containerType` itself, which stopped being true when the axis values moved onto the profiles: a kind names a profile, and the profile declares them. Corrected wherever it was asserted. Also replaces an elliptical claim ("open-ended without the axes being") with what it was trying to say, and answers, in the metadata field reference, whether the `type: "group"` stamp gate is temporary: it is, it is the same gate `kind` uses, and `axisProfile` outlives `type`. Drops a profile test that only restated what the test above it already pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
axes.md defines the axes in plain language, grounded in behavior; the code belongs in reading-axes-in-code.md. The axis-profile section had drifted across that line, naming the file the profiles live in, the stored field, and the three runtime types the field is kept off of. Those move to reading-axes-in-code.md, beside the kind registration they explain, and the concept section says the same things as behavior: profiles are defined in one place, a document records which one it was made from, and nothing in the running app can see that record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces named document-axis profiles and persists profile provenance for migration targeting.
Changes:
- Centralizes supported axis combinations and updates kind registration.
- Stamps and backfills
axisProfilemetadata. - Adds tests and architecture documentation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/models/document/document-utils.test.ts |
Uses class-wide profiles in tests. |
src/models/document/document-kinds.ts |
Maps document kinds to profiles. |
src/models/document/document-kinds.test.ts |
Tests profile-based kind behavior. |
src/models/document/document-axis-profiles.ts |
Defines supported axis profiles. |
src/models/document/document-axis-profiles.test.ts |
Pins profile definitions and runtime exclusion. |
src/lib/db.ts |
Stamps profile names during creation. |
src/lib/db.test.ts |
Tests profile persistence. |
scripts/backfill-group-document-axes.ts |
Backfills profiles with merged writes. |
scripts/backfill-group-document-axes.test.ts |
Tests migration passes and idempotency. |
docs/document-metadata/metadata-fields.md |
Documents axisProfile. |
docs/document-axes/README.md |
Updates the axis roadmap. |
docs/document-axes/reading-axes-in-code.md |
Explains profile-based registration. |
docs/document-axes/axes.md |
Defines the profile architecture. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The update rules constrained uid, type, key, createdAt and context_id, so any member of a document's class could stamp or rewrite `axisProfile` on it. The field is the cohort key a migration selects by, so a client able to write it could aim a migration at documents that were never made from that profile. Nothing legitimate writes it after creation: the metadata write is create-only, the canonical claim writes only `canonical`, and the on-open backfill writes only `kind` and `concurrent`. The one later writer is the backfill script, which runs as a service account past these rules -- so unlike `concurrent`, this needs no transitional allowance for a client-side backfill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Built on #2949, now merged — independent of #2962 and #2963, which explore a different direction.
It names a concept the code has always had without naming it, and gives migrations the stable cohort key they need.
An axis profile
A profile is a named bundle of positions on the axes — the thing people already mean by "a class-wide document" instead of "a unit-level, class-owned, canonical, concurrent document". Two of the four have existed in the code since the registry landed, as the unnamed locals
personalLikeKindInfoandproblemLikeKindInfo.All four now live in
src/models/document/document-axis-profiles.ts:problemLikepersonalLikegroupclassWideA kind registers against a profile rather than spelling out axis values. That has a consequence worth stating plainly, because it answers the "central area" concern directly: a unit config declares kinds, but declares no axis values at all. Every kind lands on a profile written in code, so a configuration can add a document to an existing axis combination and cannot invent one. The file above is the complete, reviewable list of combinations the application supports, and it does not grow as units are authored.
The test churn in this PR is that constraint showing up: several tests previously registered ad-hoc combinations like user-owned-but-concurrent that no profile provides. They now have to pick a real one.
Storing it, and why the runtime can't see it
Each document records the name of the profile it was created from, in
axisProfile.This is for migrations. Changing what a profile means — new axis, different values — requires finding every document created from it, and selecting those by their axis values means querying the exact fields the migration is about to change; that query has to be rewritten every time the answer moves, and it gets harder as transitional markers like
type: "group"retire. Because the field records provenance rather than caching a derived value, it also stays true after such a migration instead of going stale.It is declared on no runtime type — absent from
IDocumentMetadata,DocumentMetadataModel, andDocumentModel. The write path uses a localIDocumentMetadataAtCreation, andcreateFirestoreMetadataDocumentreturnsIDocumentMetadata, so a consumer cannot read the field back without widening a type first. That keeps the axis guards the only way to ask how a document behaves, and makes any future read a visible, reviewable change rather than a convenient one-liner.Being honest about what that does and doesn't guarantee: the barrier is at the type level. The value is really on the object at runtime; nothing stops a cast. What it prevents is the accidental version.
The field survives Firestore→MST validation because MST's
typecheckignores undeclared properties — already relied on bycanonical, and pinned insrc/models/mst.test.ts.It is read-only after creation (
firestore.rules). The update rules constraineduid,type,key,createdAtandcontext_id, which left any member of a document's class able to stamp or rewriteaxisProfileon it — and since the field is the cohort key a migration selects by, a client able to write it could aim a migration at documents that were never made from that profile. It now joins that read-only set, with two rules tests covering both stamping it onto a document that has none and changing an existing value.Nothing legitimate loses a write: the metadata write is create-only (
createFirestoreMetadataDocumentreturns early when the document exists), the canonical claim writes onlycanonical, and the on-open backfill writes onlykindandconcurrent. The one writer after creation is the backfill script, which runs as a service account past these rules — so unlikeconcurrent, this needs no transitional allowance. The rules go out a release ahead of the app, as usual; this one only forbids an update no deployed client performs, so the window is uneventful.Backfilling the documents that predate the field
scripts/backfill-group-document-axes.tsgains a third pass, stampingaxisProfileon every document itsquery returns that lacks one. It derives the name from scope — a
groupIdmeans thegroupprofile, itsabsence
classWide— rather than fromkind, because a class-wide document's kind is whatever its unitdeclared and a script running outside the app cannot resolve unit-declared kinds through the registry. That
is the same split the two existing passes already select on, and it is the last migration that has to
identify a document by its axis values rather than by a stored cohort key.
The passes now build one merged write per document rather than one write per pass: the profile pass
overlaps both of the others, so a group document missing
concurrentwould otherwise take two batchedwrites to reach the same state.
This has to land before #2952's sweep runs anywhere. That PR adds a pass writing
type: "axes", andtypeis this script's own query key — the moment a document's type lands as"axes"it stops matchingwhere("type", "==", "group")and this script can never find it again. Running the type rename first,without the profile pass, would strand those documents and force a second migration with a different query.
The script repeats the profile names as literals, because
scripts/compiles against its ownpackage.jsonand resolves nothing fromsrc; the unit test asserts againstkGroupProfile.nameandkClassWideProfile.nameimported from the real definitions, so a rename fails there rather than driftingsilently.
Size
+543 / −140 total; production code is +98 / −53. By file, counting non-comment lines:
The rest is tests (+208/−39, mostly the profile pins, the registration rewrites, the backfill's passes, and
the two rules tests) and docs (+105/−16).
Not included
axisProfileis written only ontype: "group"documents, the same gatekinduses, so nothing is stamped that would have to be migrated if the other types' kinds are reorganized. It widens as that gate does.