Skip to content

CLUE-610: name the axis profiles and record each document's profile - #2965

Merged
scytacki merged 6 commits into
masterfrom
CLUE-610-axis-profiles
Aug 19, 2026
Merged

CLUE-610: name the axis profiles and record each document's profile#2965
scytacki merged 6 commits into
masterfrom
CLUE-610-axis-profiles

Conversation

@scytacki

@scytacki scytacki commented Aug 14, 2026

Copy link
Copy Markdown
Member

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 personalLikeKindInfo and problemLikeKindInfo.

All four now live in src/models/document/document-axis-profiles.ts:

profile owner container curriculum concurrent kinds today
problemLike user offering problem no problem, planning, problemPublication, support
personalLike user class none no personal, learningLog, and their publications
group group offering problem yes group
classWide class classUnit unit yes whatever units declare

A 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, and DocumentModel. The write path uses a local IDocumentMetadataAtCreation, and createFirestoreMetadataDocument returns IDocumentMetadata, 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 typecheck ignores undeclared properties — already relied on by canonical, and pinned in src/models/mst.test.ts.

It is read-only after creation (firestore.rules). The update rules constrained uid, type, key, createdAt and context_id, which left any member of a document's class able to stamp or rewrite axisProfile on 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 (createFirestoreMetadataDocument returns early when the document exists), the canonical claim writes only canonical, and the on-open backfill writes only kind and concurrent. The one writer after creation is the backfill script, which runs as a service account past these rules — so unlike concurrent, 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.ts gains a third pass, stamping axisProfile on every document its
query returns that lacks one. It derives the name from scope — a groupId means the group profile, its
absence classWide — rather than from kind, because a class-wide document's kind is whatever its unit
declared 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 concurrent would otherwise take two batched
writes to reach the same state.

This has to land before #2952's sweep runs anywhere. That PR adds a pass writing type: "axes", and
type is this script's own query key — the moment a document's type lands as "axes" it stops matching
where("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 own
package.json and resolves nothing from src; the unit test asserts against kGroupProfile.name and
kClassWideProfile.name imported from the real definitions, so a rename fails there rather than drifting
silently.

Size

+543 / −140 total; production code is +98 / −53. By file, counting non-comment lines:

document-kinds.ts             +22 / −39   registrations collapse to a profile reference
document-axis-profiles.ts     +33 /  −0   the new file, mostly the four profiles
backfill-group-document-axes.ts +30 / −9  the profile pass and the merged write
db.ts                         +12 /  −4   stamping the name at creation
firestore.rules                +1 /  −1   one name added to the read-only field set

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

  • Widening the stamp gate. axisProfile is written only on type: "group" documents, the same gate kind uses, so nothing is stamped that would have to be migrated if the other types' kinds are reorganized. It widens as that gate does.

… 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

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.73%. Comparing base (fbfd132) to head (a6cd015).
⚠️ Report is 79 commits behind head on master.

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     
Flag Coverage Δ
cypress-regression 70.84% <100.00%> (?)
cypress-smoke 41.68% <100.00%> (+<0.01%) ⬆️
jest 56.74% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cypress

cypress Bot commented Aug 14, 2026

Copy link
Copy Markdown

collaborative-learning    Run #19922

Run Properties:  status check failed Failed #19922  •  git commit a6cd0153be: fix: make a document's axis profile read-only after creation [CLUE-610]
Project collaborative-learning
Branch Review CLUE-610-axis-profiles
Run status status check failed Failed #19922
Run duration 10m 48s
Commit git commit a6cd0153be: fix: make a document's axis profile read-only after creation [CLUE-610]
Committer Scott Cytacki
View all properties for this run ↗︎

Test results
Tests that failed  Failures 1
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 5
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 219
View all changes introduced in this branch ↗︎

Tests for review

Failed  cypress/e2e/functional/document_tests/tiles_copy_test_spec.js • 1 failed test • Regression tests

View Output

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

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.

Comment thread docs/document-axes/axes.md Outdated
Comment thread docs/document-metadata/metadata-fields.md
Comment thread src/models/document/document-axis-profiles.test.ts Outdated
Comment thread src/models/document/document-axis-profiles.ts
Base automatically changed from CLUE-610-class-wide-documents to master August 19, 2026 01:03
@scytacki scytacki changed the title DRAFT CLUE-610: name the axis profiles and record each document's CLUE-610: name the axis profiles and record each document's profile Aug 19, 2026
@scytacki
scytacki marked this pull request as ready for review August 19, 2026 01:29
scytacki and others added 2 commits August 18, 2026 21:51
…-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>

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

Introduces named document-axis profiles and persists profile provenance for migration targeting.

Changes:

  • Centralizes supported axis combinations and updates kind registration.
  • Stamps and backfills axisProfile metadata.
  • 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.

Comment thread src/lib/db.ts
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>
@scytacki
scytacki merged commit 5fa7976 into master Aug 19, 2026
29 of 31 checks passed
@scytacki
scytacki deleted the CLUE-610-axis-profiles branch August 19, 2026 03:13
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