Status: Design stable, partially implemented (Phase 02a Packet 8 shipped the two aggregates, the schema and its isolation, the payload gate, and the write path; P02d-2 Step 1 adds contextual exact-definition reads and metadata validation. P02d-3 adds internal generation-cached display reads; P02d-4 delivers public consumers in Phase 02d, and the Admin Studio editors with the phases that consume them).
The second module spec in the repository, per Documentation Standards § Per-Module Specifications.
Customization owns the shapes a tenant declares its own data will take, and nothing about the data itself.
It is the module ADR-0018 exists for: one binary and one schema serve a language school, a yoga studio and a coding bootcamp because what differs between them is rows in these tables, not code in any module. The boundary of that claim is Platform Vision § Genericity boundary — content shape, presentation and pure rule evaluation are tenant data; stateful entitlement and external capability invocation are platform features gated by plan.
It owns:
TenantContentType— a tenant-authored JSON Schema declaring the shape of one kind of content, plus the composite renderer that draws it.TenantLevelTaxonomy— the tenant's level or difficulty vocabulary, and the bands inside it. CEFR for a language school, kyu/dan for a dojo, whatever a yoga studio calls its levels.customization_generations— one counter per tenant, embedded in every cache key this module's reads compose. Not an aggregate (ADR-0043 § 7).
It does not own:
- The content. A
TenantContentTypesays what an article looks like; the articles live in Content, from Phase 04. - The renderers.
default-cardand its eight siblings are keys of frontend composites; this module stores the keys, andComposite_Renderer_Keys_Match_The_Frontend_Registryholds the frontend's registered set inside the nine this module declares — containment, not equality. - The evaluator. Scoring and completion rule bodies are
textplus adialectdiscriminator, decided here and given a table in Phase 05 once ADR-0025 chooses the language. - Validation of instances against a schema.
IJsonSchemaValidatorlives in the shared kernel because four modules need it, and this module is only its first caller. It admits LearnStack's ownx-renderer/x-taxonomy/x-languagekeywords and reports where each one sits; resolving them against the registries is this module's, per ADR-0043 § 4, because two of the registries are its own and the third is a tenant's rows.
erDiagram
tenant_content_types {
uuid id PK
uuid tenant_id
varchar key
int schema_version
int schema_revision
text status
jsonb display_name
jsonb json_schema
varchar renderer_key
}
tenant_level_taxonomies {
uuid id PK
uuid tenant_id
varchar key
int schema_version
int schema_revision
text status
jsonb display_name
}
tenant_level_taxonomy_items {
uuid tenant_id PK
varchar taxonomy_key PK
int schema_version PK
varchar key PK
jsonb display_name
smallint sort
jsonb metadata
}
customization_generations {
uuid tenant_id PK
bigint generation
}
tenant_level_taxonomies ||--o{ tenant_level_taxonomy_items : "bands, ON DELETE CASCADE"
Text fallback: four tables. The two definition tables are tenant-owned and
tenant-wide, keyed on a surrogate id with
UNIQUE (tenant_id, key, schema_version) as the natural key and a partial
UNIQUE (tenant_id, key) WHERE status = 'Active' AND deleted_at IS NULL holding
one live revision per concept. Taxonomy items are a child table inside the
taxonomy aggregate, keyed on (tenant_id, taxonomy_key, schema_version, key)
with a composite foreign key that includes tenant_id — because referential
integrity checks run with row security bypassed, and a single-column key would
let one tenant's band point at another's taxonomy. The generation counter is one
row per tenant and references nothing.
Every one of the four is under ENABLE and FORCE ROW LEVEL SECURITY with
the tenant-owned tenant-wide policy from
Database Standards § Table classes.
stateDiagram-v2
[*] --> Draft: Register
Draft --> Active: Publish
Active --> Deprecated: Publish of a successor
Draft --> [*]: deleted, nothing referenced it
Deprecated --> [*]: never; rows that pinned it still resolve
Text fallback: a definition is registered as Draft, published to Active, and
retired to Deprecated when a successor is published. Only a Draft's body is
mutable — a published revision was validated against stored instances that
cannot be re-validated retroactively, so a change a stored instance could fail
raises schema_version instead. A Deprecated revision is history and is never
removed: content rows pin the version they were written against.
sequenceDiagram
participant Admin
participant Handler as PublishTenantContentTypeCommandHandler
participant Store as ITenantContentTypeStore
participant Gen as ICustomizationGenerationStore
participant DB as PostgreSQL
Admin->>Handler: PublishTenantContentTypeCommand(id)
Handler->>Store: FindAsync(id)
Store->>DB: SELECT … WHERE id = … AND deleted_at IS NULL
Handler->>Store: FindActiveAsync(key)
Store->>DB: SELECT … WHERE key = … AND status = 'Active'
Handler->>Store: UpdateAsync(incumbent → Deprecated)
Store->>DB: UPDATE … WHERE row_version = …
Handler->>Store: UpdateAsync(successor → Active)
Store->>DB: UPDATE … WHERE row_version = …
Handler->>Gen: BumpAsync(tenant)
Gen->>DB: INSERT … ON CONFLICT DO UPDATE … RETURNING generation
Text fallback: the handler loads the successor, refuses anything that is not a
Draft, finds the live revision of the same key, deprecates it, publishes the
successor, and bumps the generation. The order is load-bearing: the partial
index admits one live row per key, so publishing before retiring is the one
ordering PostgreSQL rejects — and it would reject it after the successor's
UPDATE had already been sent. All of it is one transaction, because
ADR-0040 gives the scope one.
P02d-2's concurrent seed proof exposed a same-revision publication race: the active read can see a competitor's commit while EF retains the earlier tracked Draft. Both publish handlers return typed concurrency refusal when that read identifies the successor itself, without retiring it. Any failed publication save marks the ambient unit rollback-only, including first publication with no incumbent. CustomizationPublicationConcurrencyTests coordinates the intervening commit and proves absorbed post-save failures cannot commit either the dirty successor or a later write.
Both publish commands accept an optional RequireNoIncumbent precondition.
Contextual Off queries check the logical key's Active identity before registration.
Convergent seed also passes true: a different Active revision is refused before mutation
as business_rule_violation / lockey_customization_key_already_live. Default false
retains ordinary revision succession. The same-row race remains typed concurrency;
an Active winner from another revision is never retired or adopted by authored seed.
Built-ins use the separate semantic initialization path described below.
Implemented by P02b-3 Step 5; validation/review status is recorded below.
ADR-0057
authorizes independent card@1 content-type and plain@1 taxonomy initialization
on completed tenant
provisioning:
flowchart LR
T[Completed provisioning] --> O[Transactional outbox]
O --> D[P02b-2 dispatcher]
D --> C[P02b-3 card@1 subscription]
D --> P[P02b-3 plain@1 subscription]
C --> CT[One root + generation + inbox + MUST audit]
P --> PT[One root + generation + inbox + MUST audit]
Text fallback: Tenancy persists the completed-provisioning fact; in P02b-3, independent subscriptions each create one untouched built-in concept on their own delivery transaction. One may finish before its sibling; retry completes an incomplete pair without overwriting tenant decisions.
P02d-3 Step 3 implemented — 2026-10-02; both review rounds passed.
ICustomizationDefinitionProjectionReader resolves batched exact revision pins
through ADR-0010's application-contract mechanism. Values are immutable; no public
table, schema validation, HTTP endpoint or write is introduced. Active/Deprecated
nondeleted definitions are eligible; missing individual pins remain distinguishable
without failing unrelated members or substituting another revision. Labels resolve
per call with actual locale metadata from the caller's display-locale context.
P02d-4 Step 3 delivers the public response/refusal contract;
P02d-6 Steps 2–3 deliver the catalog, course and lesson views. The coherent loader
supplies
generation-keyed families; dirty or rollback-only
scopes bypass their cache. The writer reader remains uncached.
Cache strategy § 8.2 owns family keys, ambient snapshot loading, dirty-scope bypass, fault/cancellation behavior and freshness. The two families include eligible retained revisions and immutable bands; the writer's exact-purpose reader below stays uncached. P02d-3's accepted package owns implementation steps and proof obligations. P02d-4's accepted Education consumer uses exact pins and ordered allowlisted string fields, with whole-content unavailable outcomes. Internal field requiredness comes from schema.required; no public required flag or JSON Schema evaluation is added. Unknown fields are omitted; Missing required/non-object/non-string body data makes that lesson's whole content unavailable in the Education consumer. Customization supplies internal metadata; unrelated members of its batched definition projection remain usable.
Step 1 implemented, both reviews passed — 2026-10-02. An application interface in
Customization.Application.Contracts resolves an exact content-type or taxonomy
revision for the caller's announced tenant. DTOs contain values only: key, version,
status, JSON Schema/composite and validated presentation, or immutable bands/labels.
The selected interface is IExactCustomizationDefinitionReader; it takes an explicit
binding purpose (NewBinding or ExistingPin), never an inferred live version.
- New Course taxonomy and new Lesson content-type bindings require the exact Active revision; a present band must be declared in that revision.
- A translation on an existing Lesson can use its exact Active or Deprecated pin. No command rewrites a pin or replaces it with the newest revision.
- Absent, deleted, Draft or cross-tenant definitions return the same bounded binding
validation_failed; an error cannot expose another tenant's revision or label. - Read uncached in the caller's ambient transaction/context. Eligibility is observed at this read, not promised Active-at-commit; immutable schema/bands protect the pin if it is concurrently deprecated. Strict commit-time eligibility is not selected.
- Body validation uses
IJsonSchemaValidatoragainst that returned exact schema. Generation-keyed read caching is delivered in P02d-3; P02d-4 public shape is delivered by Step 3. P02d-6 Step 3 implements the bounded ordered-text renderer and unavailable fallback. These are separate from the exact write contract. - Module-owned contextual verification queries give the seeder exact IDs, revision data, labels/bands and state. They are audit Off and introduce no setter exception.
ADR-0051 defines the optional
root x-fields profile and semantic resolver. It preserves the four gates and legacy
schemas; P02d-2 Step 1 implements the parser and semantic resolver. Seed definitions opt
into the profile, whereas built-in card/plain remain unchanged and Active.
graph TD
Contracts[Customization.Application.Contracts<br/>four commands, BuiltInCustomizations]
App[Customization.Application<br/>handlers, validators, ports]
Domain[Customization.Domain<br/>two aggregates, the generation row]
Infra[Customization.Infrastructure<br/>DbContext, stores, migration]
Gate[SharedKernel IJsonSchemaValidator<br/>→ Infrastructure.Validation]
Seeder[Tools.Seeder]
Seeder --> Contracts
Seeder --> App
Seeder --> Infra
App --> Contracts
App --> Domain
App --> Gate
Infra --> App
Infra --> Domain
Text fallback: the seeder is the second composition root, so it references the
Application and Infrastructure projects as well as the contracts — it has to
register the ports itself. Contracts name only SharedKernel types, so a module
sending a command does not take a dependency on Customization.Domain — the forbidden
Module A → Module B.Domain edge. Application declares the ports; Infrastructure
implements them and is wired at the composition root. The JSON-Schema gate is a
shared-kernel port with one adapter, and JsonSchema_Net_Types_NotImportedOutsideInfrastructure
keeps the library's types inside it.
Customization publishes no event and implements two subscribers. ADR-0057 fixes the useful effects; ADR-0046 fixes their stable identities and transport-owned inbox. P02b-3 Step 5 activates both real subscriptions together over durable acceptance and the semantic Seeder handoff. Each effect owns one root, generation change, inbox outcome and MUST audit. Customization mints UUIDv7 IDs and resolves semantic key/version identity; Tenancy carries neither schemas nor Customization IDs.
| Consumed topic | Event | Stable subscription | Independent effect | Owner |
|---|---|---|---|---|
learnstack.tenancy.tenant |
TenantCreatedIntegrationEventV1 |
customization.initialize-content-type-default.v1 |
Create untouched card@1 content type (BuiltInCustomizations.Card) |
P02b-3 Step 5; Active |
learnstack.tenancy.tenant |
TenantCreatedIntegrationEventV1 |
customization.initialize-taxonomy-default.v1 |
Create untouched plain@1 taxonomy (BuiltInCustomizations.Plain) |
P02b-3 Step 5; Active |
Initialization preserves Draft, Active, Deprecated, successor and deliberately deleted definitions; it never overwrites, republishes or resurrects them. Each parameterless initializer takes its tenant from trusted context, acquires the transaction-owned semantic lock shared by ordinary registration, then checks all revision/status/deletion history under RLS. A new root is validated and Active before one save, generation bump and MUST audit; a preserved attempt changes no root or generation. The coordinating handlers are explicitly Off and send one classified nested command, refusing a failed result rather than completing inbox.
Seeder discovers built-ins by key/version and verifies their exact Active shape, retaining warm/generated IDs. Authored curriculum identities/pins stay exact. The Step 5 record owns initialization, race and Seeder validation/review status.
No other module reacts to a customization change through events today. Public readers use the delivered generation-keyed cache with fresh generation probes; a cache key that changes is a cheaper invalidation than an event every pod has to receive. An event becomes owed when a second module needs to act on a change rather than merely notice it; Phase 04 is where that is decided, because it brings the content rows a schema change would have to be reconciled against.
In permissions.md, the file Permission Standards names.
In audit.md, the file Audit Coverage names.
| Path | Budget | Why this number |
|---|---|---|
| Resolve batched definitions (warm) | < 1 ms for in-memory resolution, excluding SQL probe | One fresh generation SELECT; no definition query. End-to-end timing measured separately in P02d-3 |
| Resolve batched definitions (cold/partial/fault) | < 20 ms production p95 target, not proven by the local sample | At most two SELECTs: probe plus coherent generation/rows snapshot; seeded measurements report rows/bytes and query plans, not production p95 |
| Admit a tenant-authored schema (four gates) | < 50 ms p95 | Interactive, on save, and rare |
| Validate one instance at the § 8.4 caps | 742 ms, 1.6 GB | Measured worst case, not a budget — see below |
| Publish a successor (2 reads, 2 updates, 1 upsert) | < 100 ms p95 | Interactive but rare |
The instance-validation number is the one to read carefully: it is a measured worst case at § 8.4's declared caps — 100 properties over a 1 MiB instance — not a target. It is why the caps exist, and why the compiled schema does not outlive a call: compiling is measured cheaper than evaluating, so a cache would have added a hit-rate problem to solve none.
- Which lifecycle stage a
schema_revisionbump belongs to is open. ADR-0043 § 6 deliberately leaves it open, having removed the cache that would have had to key on it. TodayAddItemandRemoveItemeach count as one additive edit, so a taxonomy registered with three bands is at revision 3 — consistent with the aggregate's own rule and unconstrained by anything else. Phase 04 § Customization Key Shape owns the answer. - No permission check gates these commands. Reachability stands in for authorization exactly as it does in Tenancy, and for the same reason: there is no HTTP endpoint. Phase 03 brings the registry, and permissions.md is the forward declaration.
- Audit rows ride the pipeline. Since Packet 9 the four shipped commands write
MUST rows through
AuditLogBehaviorandTransactionBehavior(ADR-0033); audit.md classifies them and the eight(planned)operations. - The additive claim on a revision is unchecked.
ReviseSchemaenforces the half an aggregate can — that the body is still a draft — and leaves the diff that decides whether a change only adds to the editor, Phase 04. x-languageis admitted without resolving.x-rendererresolves against the twelve generic primitives andx-taxonomyagainst the tenant's own taxonomies, both on save; the set of languages acodefield may declare is decided by nothing in the corpus and belongs to Phase 04 with the field type that carries one. § 8.1 records the gap rather than leaving the invariant reading as though it were closed.- Publishing an older revision silently demotes a newer live one.
Publishretires whatever isActivefor the key without comparingschema_version, so aDraftleft over at v1 makes v1 live again and deprecates v3. That is coherent with the model — an entry pins the version it was written against, and ADR-0013 keeps the deprecated revision resolvable — so it is a rollback rather than a corruption, and refusing it would forbid one. What is missing is the editor that makes the choice deliberate: Phase 04 owns the diff that decides additive from breaking, and this question with it. - The lifecycle guards do not mention soft delete, and nothing can reach them.
PublishandReviseSchemado not ask whether the row is deleted. They cannot be handed one: there is no delete command, and both store reads excludedeleted_at IS NOT NULL. The guard is owed by whichever phase ships the delete command — Phase 04 for content types, per audit.md'ssoft deleterow — and writing it now would be a branch no test could kill. - A tenant can strand its own content. Deprecating the only live revision of a key leaves content rows pinned to a version nothing publishes. The schema permits it and no command refuses it, because "is this key still needed?" is a question only the module that owns the content rows can answer — Phase 04 again.
IExactCustomizationDefinitionReader now returns immutable exact-revision DTOs
through the ambient tenant-filtered context, without a cache or live-key
substitution. New bindings require Active; existing pins admit Active/Deprecated.
Draft, deleted and invisible definitions share the bounded validation refusal.
ADR-0051's optional root x-fields is admitted by the generic schema profile and
semantically resolved by Customization after all four gates, before persistence.
Descriptors cover every direct string property once, preserve array order and
carry validated LocalizedText labels; only default-card is compatible. Legacy
schemas without the extension retain their original admission and empty presentation.
The exact reader returns the same resolved descriptors; a new rendering consumer
still belongs to P02d-6, rather than being implied by valid schema storage.
The contextual content-type/taxonomy queries supply exact seed verification DTOs and are classified Off. Their adapters and the exact reader are registered in both API and Seeder composition roots.