Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions docs/document-axes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ flips the rows it delivers **in the same PR**, and names the stage/ticket under
| `canonical` (single pointed-to doc for a slot) | pointer slots addressed as container + owner + label, rule-enforced | done | CLUE-524; class+unit slots added CLUE-550 Stage 2; CLUE-610 gave every slot an explicit owner segment, taken from the document's `uid` |
| `concurrent` (multi-writer vs single-writer) | stored per-doc; rule-readable; `DocumentModel` prop sourced from Firestore at open | done | CLUE-550 Stage 1 |
| `kind` (preset/cohort tag: defaults, presentation, templates) | stored per-doc tag; dereferenced only in the kind registry | done | CLUE-550 Stage 1 (stored + registry seeded); titles resolved by kind Stage 2; presentation wired by CLUE-610 (workspace title bar reads the registry; no consumer branches on kind); CLUE-610 also scopes a unit-declared kind's definition to its unit — see "Static and dynamic kinds" in [axes.md](./axes.md) |
| `owner` (who the document belongs to) | creation: kind-declared `ownerType` → owner `uid` (`getDocumentOwner`) plus a group owner's stored `groupId` (`getDocumentOwnerFields`); read: `hasGroupOwner(doc)` and `hasClassOwner(doc)`, each over the uid prefix its minter used | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds); CLUE-610 (both guards; `groupId` moved onto this axis, off the container, and then off the owner *question* — the `uid` is the sole authority). Still to come: the user level, and a getter that returns which owner a document has rather than testing for one |
| `container` (where the document is kept: class → classUnit → offering) | creation: kind-declared `containerType`, stamped by `getDocumentLocationFields(kind, ctx)`; read: `isInClassUnitContainer(doc)`, over an `offeringId` now surfaced on both metadata types | in progress | CLUE-550 Stage 2 (creation side, every kind); CLUE-610 (`containerType` replaces `scopeType`, with no group level — a group document is kept in the offering and owned by the group; the edit gate switched from a curriculum test to this one). Still to come: a guard for the class level, and a getter returning the container |
| `curriculum` (what the document is about: none → unit → investigation → problem) | creation: fixed by the kind's `containerType`, since every container above the class is identified by a curriculum coordinate; read: `getCurriculumLabel(doc)` | in progress | CLUE-550 Stage 2 (creation side, every kind); CLUE-610 (the label, and the unit level states its absent fields explicitly so it is queryable). No consumer asks a yes/no curriculum question, so no guard exists |
| `owner` (who the document belongs to) | creation: `ownerType`, from the kind's axis profile → owner `uid` (`getDocumentOwner`) plus a group owner's stored `groupId` (`getDocumentOwnerFields`); read: `hasGroupOwner(doc)` and `hasClassOwner(doc)`, each over the uid prefix its minter used | in progress | CLUE-550 Stage 2 (creation-side owner derivation registry-declared for all kinds); CLUE-610 (both guards; `groupId` moved onto this axis, off the container, and then off the owner *question* — the `uid` is the sole authority). Still to come: the user level, and a getter that returns which owner a document has rather than testing for one |
| `container` (where the document is kept: class → classUnit → offering) | creation: `containerType`, from the kind's axis profile, stamped by `getDocumentLocationFields(kind, ctx)`; read: `isInClassUnitContainer(doc)`, over an `offeringId` now surfaced on both metadata types | in progress | CLUE-550 Stage 2 (creation side, every kind); CLUE-610 (`containerType` replaces `scopeType`, with no group level — a group document is kept in the offering and owned by the group; the edit gate switched from a curriculum test to this one). Still to come: a guard for the class level, and a getter returning the container |
| `curriculum` (what the document is about: none → unit → investigation → problem) | creation: fixed by the profile's `containerType`, since every container above the class is identified by a curriculum coordinate; read: `getCurriculumLabel(doc)` | in progress | CLUE-550 Stage 2 (creation side, every kind); CLUE-610 (the label, and the unit level states its absent fields explicitly so it is queryable). No consumer asks a yes/no curriculum question, so no guard exists |
| `permissions` (composed grant set) | permission-policy grants (referenced policy) + stored per-doc grants | not started | Consumers are spread widely and each composes the answer by hand; the edit gate `canUserEditDocument` is called out as a worked example, including its `type` branch for published documents. See "Not covered yet" in [reading-axes-in-code.md](./reading-axes-in-code.md) for what a named policy would replace |
| kind registry (by-kind view) | `register`/`get` map keyed on `kind`; `fn(doc)` API | done | CLUE-550 Stage 1 |
| kind registry (by-kind view) | `register`/`get` map keyed on `kind`; `fn(doc)` API | done | CLUE-550 Stage 1; CLUE-610 moved the axis values off each kind and onto a named **axis profile** it registers against (`document-axis-profiles.ts`), so the set of axis combinations is enumerable in one file and a unit config can join one but not invent one |
| axis profiles (named axis-value bundles) | code-defined profiles; each document stores the name of the one it was created from (`axisProfile`), read by migrations only | done | CLUE-610 — see "Axis profiles" in [axes.md](./axes.md). Deliberately absent from every runtime type, so behavior is still decided by the axis guards |
| behavior modules (by-behavior view) | `fn(doc)` reading axis getters / registry; never branch on `kind` | in progress | CLUE-550 Stage 1 (history + write-sync on concurrent; read-access + rules-delete on group type, interim until the permissions axis); CLUE-550 Stage 3a (collaborative thumbnail treatment reads `concurrent`); CLUE-610 (edit gate `canUserEditDocument` and the collaborative title bar read `concurrent`) |
| creation factory (the one `kind → axis` bridge) | reads registry defaults, stamps axis values on a new doc | in progress | CLUE-550 Stage 2 (per-slot class-wide canonical creation; owner and location fields stamped from the kind's `ownerType`/`containerType`) |
| creation factory (the one `kind → axis` bridge) | reads the profile the kind registers, stamps its axis values and its name on a new doc | in progress | CLUE-550 Stage 2 (per-slot class-wide canonical creation; owner and location fields stamped from the kind's `ownerType`/`containerType`) |

Status values: `not started` / `in progress` / `done`.

Expand Down Expand Up @@ -63,8 +64,9 @@ rather than by type, a whole-unit listener keeps them visible under the investig
filters, presentation reads `concurrent` and the kind registry, and one predicate
(`canUserEditDocument`) gates every Edit button. It also settles how these axes are modeled in code:
consumers read narrow named guards over the stored fields, with no level enum and no unified struct;
a kind declares `ownerType` and `containerType`; and `groupId` sits on the owner axis, so there is no
group container level (see [reading-axes-in-code.md](./reading-axes-in-code.md)). Canonical slots follow:
a kind names an axis profile, which declares `ownerType` and `containerType`; and `groupId` sits on the
owner axis, so there is no group container level (see
[reading-axes-in-code.md](./reading-axes-in-code.md)). Canonical slots follow:
each is addressed as its container plus its owner plus a label, with the owner segment read straight from
the document's `uid` so the pointer path no longer depends on `groupId` at all. Both owner guards read the
`uid` the same way, which leaves it the single authority on who a document belongs to; the stored
Expand Down
49 changes: 44 additions & 5 deletions docs/document-axes/axes.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,13 +336,52 @@ entries to a unit config: it needs a configuration source loaded independently o
association on the document naming that source. Until such a source exists, personal-like presets stay
static.

**How a kind sets that today.** A kind declares only `containerType`, and `getDocumentLocationFields`
derives both the container and curriculum axes' fields from it, so a document's curriculum position
currently follows from its container: `class` yields no unit, `classUnit` the unit, `offering` the
problem. The bound is stated on `curriculum` because that is what rule 2 constrains, and it survives
the coupling being broken — a kind declaring a class container *and* a unit curriculum would stamp
**How a kind sets that today.** A kind's profile declares only `containerType`, and
`getDocumentLocationFields` derives both the container and curriculum axes' fields from it, so a
document's curriculum position currently follows from its container: `class` yields no unit, `classUnit`
the unit, `offering` the problem. The bound is stated on `curriculum` because that is what rule 2
constrains, and it survives the coupling being broken — a profile declaring a class container *and* a unit curriculum would stamp
`unit` and satisfy rule 2, which a container-based bound would wrongly forbid.

## Axis profiles — naming a combination of axis values

The axes describe a document one question at a time, but people do not talk that way. Nobody says "a
unit-level, class-owned, canonical, concurrent document" — they say **a class-wide document**. That
shorthand is not sloppiness; it names a real thing, a *combination* of positions that documents are
actually created at. This section gives that thing a name: an **axis profile**.

A profile is a named bundle of axis values. `classWide` is one. So are the two CLUE has always had without
naming them — the bundle shared by personal documents, learning logs, and their publications, and the
bundle shared by problem, planning, and problem-like publications. They are defined in one place, which
makes that place the complete list of axis combinations the application supports.

**A profile is not a `kind`.** Many kinds share one: what makes a learning log different from a personal
document is presentation and its creation recipe, not any axis. `kind` says which preset a document came
from; its profile says where that preset put it on the axes.

**A unit may add kinds; it may not add axis combinations.** This is what lets `kind` stay open-ended while
the set of axis combinations stays closed. A unit config declares kinds, but it declares no axis values —
every kind is declared against a profile, and profiles are not authorable. So a configuration can add a
document to an existing combination and cannot invent one, and the set of combinations stays reviewable in
one place rather than growing with the units.

**A profile is recorded, not resolved.** A document records which profile it was created from. That exists
for one reason: a migration that changes what a profile means has to find every document created from it,
and selecting those by their axis values would mean querying the very fields the migration is there to
change — a query that has to be rewritten every time the answer moves.
Because it is provenance rather than a cache, it stays true after such a migration: it says which profile
the document was made from, not what its axes hold now.

**Nothing in the running application reads that record.** It is deliberately out of the app's reach: only
something reading stored documents directly — a migration, or offline analysis — can see it. That keeps the
axes themselves the only way to ask how a document behaves, which is the point of this whole folder. A
document behaves the way it does because of where it sits on the axes, never because of the name of the
bundle it started at.

Where the profiles are defined, and how a kind names one, is in
[reading-axes-in-code.md](./reading-axes-in-code.md); the stored record is in
[metadata-fields.md](../document-metadata/metadata-fields.md).

### `permissions` — who may do what

**What it is.** The permission set: who may `read`, `write`, `publish`, `copy`, and whether the content
Expand Down
14 changes: 13 additions & 1 deletion docs/document-axes/reading-axes-in-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,25 @@ guard for it exists.
## How a kind declares its axes

Those fields are stamped at creation from what the kind registered in
`src/models/document/document-kinds.ts`. A kind declares two things:
`src/models/document/document-kinds.ts`. A kind does not spell out axis values: it names an **axis
profile** (`src/models/document/document-axis-profiles.ts`), and the profile declares them. Many kinds
share one — what separates a learning log from a personal document is presentation and its creation
recipe, not any axis — and the profiles file is therefore the complete list of axis combinations the
application creates documents at. See "Axis profiles" in [axes.md](./axes.md).

A profile declares two knobs, plus `concurrent`:

- `ownerType` — `"user"`, `"group"`, or `"class"`. It picks the owner `uid` (`getDocumentOwner`) and,
for a group owner, the `groupId` stored beside it (`getDocumentOwnerFields`).
- `containerType` — `"class"`, `"classUnit"`, or `"offering"`. It picks the fields that say where the
document is kept and what it is about (`getDocumentLocationFields`).

The profile's name is stamped onto the document it creates, in `axisProfile`, and that record is
deliberately declared on no runtime type — not `IDocumentMetadata`, not `DocumentMetadataModel`, not
`DocumentModel` — so reading it back would mean widening a type first. Only a migration or offline
analysis, reading Firestore directly, can see it. See the
[`axisProfile` field](../document-metadata/metadata-fields.md#axisprofile) for what is stored and where.

One knob covers both container and curriculum because every container above the class is *identified
by* a curriculum coordinate — a classUnit by its unit, an offering by its problem — so a kind has no
curriculum position left to choose separately. `getDocumentLocationFields` is named for the pair rather
Expand Down
42 changes: 39 additions & 3 deletions docs/document-metadata/metadata-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ so this table doubles as a migration-progress view.
| `strategies` | Firestore | commented docs | `DocumentMetadataModel.strategies` | Yes, class-wide |
| `lastHistoryEntry` | Firestore | concurrent-history docs | not surfaced | No |
| `canonical` | Firestore | group | not surfaced | No |
| `axisProfile` | Firestore | group | not surfaced — deliberately | No |
| `offeringId` | Firestore + RTDB | problem family | `DocumentModel.offeringId`, `DocumentMetadataModel.offeringId` | No — immutable |
| `groupId` | Firestore | group (the **owning** group) | `DocumentModel.groupId`, `DocumentMetadataModel.groupId` | No — immutable |

Expand Down Expand Up @@ -79,6 +80,9 @@ prop, which is why they show as "not surfaced" above. They still reach `Document
properties the model does not declare — pinned by the `typecheck` tests in
[mst.test.ts](../../src/models/mst.test.ts).

`axisProfile` relies on the same behavior, but by design rather than by omission: leaving it undeclared is
what keeps the running app from reading it. See its section below.

### Derived (no stored field)

Held on the runtime model only, computed from something other than the document's own metadata. Listed
Expand Down Expand Up @@ -320,7 +324,7 @@ declare it. Anything reading properties from the Firestore metadata should treat
`undefined`)
- **Applies to:** group documents — the group that **owns** the document
- **Runtime:** `DocumentModel.groupId`, `DocumentMetadataModel.groupId`
- **Updated by:** nothing — creation only, stamped from the kind's `ownerType` by
- **Updated by:** nothing — creation only, stamped from the `ownerType` of the kind's axis profile by
`getDocumentOwnerFields`
- **Reactive:** No — immutable

Expand All @@ -347,8 +351,8 @@ holds.
- **Reactive:** Yes, to group membership changes — not to document changes

The group the **user who owns** the document belongs to. Set only where the owner is a user (`ownerType:
"user"` in the kind registry): a group- or class-owned document's synthetic `uid` is not a member of any
group, so there is nobody to look up. A fact about that user's membership rather than about the document,
"user"` on the kind's axis profile): a group- or class-owned document's synthetic `uid` is not a member
of any group, so there is nobody to look up. A fact about that user's membership rather than about the document,
which is why it is derived rather than stored: a student's group changes, and a frozen copy would go
stale. The four-up view (`getProblemDocumentsForGroup`), Student Work routing, and the content listener's
"whose documents do I monitor" test all want this one.
Expand Down Expand Up @@ -387,6 +391,38 @@ identity of a document.
`key` is also the document's `treeId` for the history system. For group documents `uid` is a synthetic
value derived from the group (`group_{offeringId}_{groupId}`) rather than a real user id.

### `axisProfile`

The name of the [axis profile](../document-axes/axes.md#axis-profiles--naming-a-combination-of-axis-values)
the document was created from — the named bundle of axis values it started at (`classWide`, `group`,
`personalLike`, `problemLike`).

- **Stores:** Firestore only
- **Location:** `documents/{key}.axisProfile`
- **Applies to:** `type: "group"` documents (group + class-wide) — the same gate as `kind`
Comment thread
scytacki marked this conversation as resolved.
- **Runtime:** **none, deliberately** — declared on no runtime type
- **Updated by:** nothing — creation only, from the registered kind's profile
- **Reactive:** No

Exists for migrations. A migration that changes what a profile means has to find every document created
from it; selecting those by their axis values would mean querying the fields the migration is about to
change, and would need rewriting each time they move. Because the field records *provenance* — which
profile the document was made from — it stays true after such a migration rather than going stale.

The `type: "group"` gate is transitional, and is the same gate `kind` uses for the same reason: a value
stamped before a type's kind is settled is a value we would have to migrate afterwards. It widens as each
type is converted, and is deleted once they all are — see
["Which documents get stamped"](../document-axes/target-architecture.md#which-documents-get-stamped--a-gate-that-narrows-as-types-are-converted)
in the target architecture. So `axisProfile` arrives on a document by the same route `kind` does, and
outlives `type`: the end state is every document carrying a profile and a kind, and `type` carrying no
meaning at all.

It is absent from `IDocumentMetadata`, `DocumentMetadataModel`, and `DocumentModel` on purpose, so the
running app cannot read it and cannot come to branch on it; the axis guards stay the only way to ask how a
document behaves. Reading it would mean widening a type first, which is a visible change rather than an
accident. Like `canonical`, it survives `DocumentMetadataStore`'s validation because MST's `typecheck`
ignores undeclared properties (see the note under the summary tables).

### `unit`, `investigation`, `problem`

- **Stores:** Firestore + RTDB *(declared)*
Expand Down
Loading
Loading