43 — Configurable Attributes — the "LEGO" attribute engine (envisioning)
Envisioning doc — needs confirmation. Governed by
00-ROADMAP.md. Created 2026-06-27 (owner-raised, split out of doc-42 §8). The ask — owner's "LEGO software": entities should be able to carry different attribute sets; the same attribute can live at different hierarchy levels; catalogs are per-workspace definable — so the platform models each customer's metadata, not one hard-coded shape. This is the composable data substrate undermgmt.ctrland the consolidation of every deferred "customization" thread (doc-17 Lego OS, doc-20 §4, doc-41 per-workspace catalog, doc-31 monetization).Three decisions locked at envisioning (2026-06-27, owner):
- Model = typed attribute bag + per-workspace definition catalog ("EAV-lite"). One whitelisted
attributesobject on entities, validated service-side against anAttributeDefinitioncatalog (key / label / type / level / options) — generalizing the existing lifecycle-status pattern (src/shared/lifecycle-status.ts). Not full per-value-row EAV; not dynamic Mongoose schemas.- Scope = global presets + workspace override. Ship industry-standard default catalogs (works day one, no config), let a workspace tweak/extend/retire them; definitions inherit down the hierarchy. Matches doc-41 "fixed defaults now, customizable later."
- Authoring = both — inline quick-add a field from
mgmt.ctrl+ a full Workspace Settings catalog editor (types, levels, options, reorder, retire).Sharpened by doc-54 (2026-07-04): the inline quick-add "+" (the stakeholder's "bubble or plus sign") is THE primary authoring surface (Portals + mgmt.ctrl rows); the catalog editor is the secondary power surface. Default visible set = a minimal preset (
[untitled]'s start-easy ethos, doc-15). Free tier: the "+" picks from the preset catalog (reading/using never gated — the rule below); defining new custom definitions stays the premium catalog-write.Posture: envisioning only. This is a premium / post-MVP layer (doc-31 monetization). The default presets can ship as fixed catalogs early (cheap, like lifecycle-status); the configuration engine is the heavy part and does not block
mgmt.ctrl(which consumes whatever attributes exist).
Why this exists (lineage)
Four docs have each parked a slice of "let the data be configurable." doc-43 unifies them:
| Source | The parked thread | What doc-43 does with it |
|---|---|---|
| doc-17 §1 "Lego OS" | "composable blocks at any hierarchy level" | The data expression of that ethos: attributes as blocks placeable at any level. |
| doc-20 §4 | customization/templates deferred (theming · saved views · role templates) | Attribute catalogs are the schema half of that customization layer. |
| doc-41 | lifecycle statusCode "fixed defaults now, per-workspace custom catalog deferred" (lifecycle-status.ts:6) | statusCode becomes the first instance of the general definition engine — same key/label/catalog shape, generalized. |
| doc-31 | monetization deferred; "pricing around team/roster value" | Configurable attributes = a natural premium tier capability. |
doc-42 mgmt.ctrl | reads/writes "whatever attributes exist"; registry "per-workspace configurable later, same seam" | doc-43 is that "later" — the surface doesn't change, the catalog becomes editable. |
Finding — net-new, no foundation today (verified 2026-06-27)
- All hierarchy schemas are fixed
@Prop(ArtistSpace/Project/Track/File). There is no open attribute mechanism. src/main.tsValidationPipeiswhitelist: true+forbidNonWhitelisted: true— it actively rejects any unknown top-level field on write. A dynamic attribute therefore cannot be a loose top-level prop; it must live in one explicitly-whitelisted bag, validated below the DTO layer.- Only seeds: an unused
File.metadata: any(file.schema.ts:68); freeformtags: string[](+shareTags) on Track/Project/ArtistSpace/Comment;ShareTag {label,color}(per-user); the typed- but-emptyTrack.credits: []. None is a definition engine. - The precedent to generalize:
lifecycle-status.tsalready does "stable key + i18n label, per- entity catalog array,@Prop enum+@IsInvalidation, render labels at display." doc-43 is that pattern, made data-driven and per-workspace instead of hard-coded. - No workspace/tenant container — the hierarchy is
User → ArtistSpace → …. ArtistSpace is the de- facto tenant root (owner+collaborators+ audit). The catalog needs a home (see §Catalog home). - Monetization rails exist:
user.subscription(SubscriptionTypeFree / Explorer / Pro / Diamond) — the gate for premium custom definitions.
The model — typed bag + definition catalog
Two pieces: a catalog of definitions (what attributes exist) and a value bag per entity (what this record's attributes are).
1 · AttributeDefinition (the catalog entry)
Generalizes StatusOption {key,label} into a full field definition:
type AttributeDefinition = {
key: string; // stable, immutable, machine id — e.g. 'mood', 'bpm', 'sync_cleared'
label: string; // i18n display label, resolved at render (doc-41 pattern)
type: 'text' | 'number' | 'boolean' | 'date'
| 'select' | 'multiselect' // options[] required
| 'url' | 'ref'; // ref → another node (artist/project/track)
levels: ('artist'|'project'|'track'|'file')[]; // WHERE this attribute may appear (the "different
// levels" ask) — one definition, many levels
options?: { key: string; label: string }[]; // for select/multiselect (same key+label shape)
required?: boolean; // enforced on the levels it applies to
default?: unknown; // seeded on create
group?: string; // UI grouping (e.g. 'Rights', 'Creative', 'Distribution')
origin: 'preset' | 'workspace'; // preset = shipped default; workspace = customer-defined
retiredAt?: Date; // soft-retire: hidden from authoring, existing values preserved (read-only)
};2 · The value bag on entities
One whitelisted field per hierarchy entity — the only top-level prop the ValidationPipe sees:
@Prop({ type: Object, default: {} })
attributes: Record<string, unknown>; // { mood: 'calm', bpm: 120, sync_cleared: true }DTOs accept attributes?: Record<string, unknown> (whitelisted, @IsObject()); the real validation is service-layer — exactly like track.service.ts already normalizes tags on every update. The service checks each key against the workspace catalog: known key · applies to this level · type matches · option is valid · required present. Unknown/ill-typed → BadRequestException. This preserves the strict forbidNonWhitelisted security posture without loosening the pipe.
Why not full EAV / dynamic schemas? A per-value
AttributeValuecollection (one doc per value) buys cross-cutting query power we don't need yet and costs joins + volume; dynamic per-workspace Mongoose schemas are fragile to migrate. The bag+catalog is Mongo-native, indexable on demand (sparse indexes on hotattributes.<key>), and upgrades to materialized/EAV later only if reporting demands it (doc-14 scale-lever discipline).
3 · Attribute-at-different-levels (the core "LEGO" ask)
A definition's levels[] says where it may live. The same key can apply to artist and track; a value set at a higher level can be inherited as a default and overridden lower down (opt-in per definition: inherit: true). Example: genre defined at ['artist','project','track'] → an artist's genre cascades to its projects/tracks unless a track sets its own. This is the data analogue of doc-20's permission cascade.
Added 2026-07-05 (owner-raised) —
fileis a first-class attribute node.'file'is already inlevels[], but the owner flagged that Files / FileType / file metadata must be LEGO-configurable too (and that filter & search will need them — doc-40 pointer below). Concretely:
File.metadata: any(the seed,file.schema.ts:68) becomes the file attribute bag — the same typed-bag + catalog model as every other node, at thefilelevel.- Built-in file facets ship as presets (like
lifecycle-status, no config): kind (type= audio · video · image · attachment), attachment folder (Lyrics/Stems/Contracts/ custom, doc-45), format (contentType/MIME), size, duration, version, review status (doc-29). These are the always-there file attributes the catalog exposes read-only.- Custom file attributes (workspace overlay) ride the bag: e.g. "stem instrument", "contract status", "delivered?", "language" on a lyrics file — the lazy artist never sees them; a label/distribution studio defines them once (doc-31 gate on catalog-write, as everywhere).
- FileType itself is extensible at the folder level (doc-45's folders already are custom per node); a workspace can define its own attachment folders/types beyond the preset three. This makes the asset cloud (doc-45) a fully-attributed layer — which is exactly what filter/search (doc-40) consumes.
Catalog home & scope — global presets + workspace override
SHIPPED DEFAULT CATALOGS (origin: 'preset') ← works day one, no config, free tier
│ inherited
WORKSPACE CATALOG OVERLAY (origin: 'workspace') ← add / relabel / retire / extend (premium)
│ inherited down the hierarchy
Artist ──▶ Project ──▶ Track ──▶ File
(a definition applies at the levels it declares; values inherit per §3)- Default presets ship as code-level catalogs (like
lifecycle-status.ts) — sensible music-industry fields (e.g. mood, energy, BPM, key, language, explicit, sync-cleared, ISRC/UPC already exist). - Workspace overlay = customer edits. Home for the overlay: since no workspace container exists, scope it to the tenant root. Recommended: a lightweight
WorkspaceSettingsdoc keyed by the owner/account (one catalog spanning all that owner's artists), not per-ArtistSpace (avoids roster- wide duplication). If/when a true Org/Label container lands (doc-17 fantasy), the catalog moves up without reshaping.statusCode(doc-41) becomes the first managed definition in this same overlay. - Resolution at read: effective catalog = presets deep-merged with the workspace overlay (overlay wins by
key;retiredAthides without deleting).
Authoring surfaces (both)
| Surface | Role | What |
|---|---|---|
mgmt.ctrl inline quick-add (doc-42) | Owner/Admin | "[+ add field]" on a row/column → name + type + level, created in the overlay on the spot. Feeds the doc-42 registry so the new field appears as a column/filter immediately. |
| Workspace Settings — Attribute Catalog editor | Owner/Admin | Full management: create/relabel/reorder/group, set levels + options + required + default + inherit, retire (never hard-delete), preview impact. The "schema design" home. |
Both write the same workspace overlay. Role-gated per doc-20; every change stamps audit (doc-41).
How the rest of the platform consumes it
mgmt.ctrl(doc-42): the view/widget/action registry becomes per-workspace configurable through the same seam — custom attributes show up as matrix columns, filter chips, group-by keys, and bulk- editable fields, with zero surface rewrite.- Sort/Filter/Search (doc-40): custom attribute keys become first-class filter/sort/group axes (
filter: attributes.mood = calm,sort: attributes.bpm). Needs sparse indexes on hot keys. - Portals (doc-26): a node's Portal renders its applicable attributes (by level), grouped via
group. Editing there writes the bag through the same validation. - i18n (doc-41): keys are stable/immutable;
label/options[].labelresolve at render peruser.locale. Retiring/renaming never breaks stored values (keyed, not labeled).
Monetization gate (doc-31)
- Free / Explorer: default preset catalogs only (read + use the shipped fields).
- Pro / Diamond: the workspace overlay — define/relabel/retire custom attributes. Gate at the catalog-write service on
user.subscription.name; reading/using existing values is never gated (so a downgrade degrades gracefully — values persist, editing the catalog locks).
Backend shape (name, don't build)
- New
attributemodule:AttributeDefinitiontype + default preset catalogs (shared/), aWorkspaceSettingsschema (overlay, keyed by owner),AttributeCatalogService(resolveEffectiveCatalog(owner)= presets ⊕ overlay) +validateAttributes(bag, catalog, level). Endpoints:GET /attributes/catalog(effective, per locale),PUT /attributes/catalog(premium, overlay CRUD), surfaced in Workspace Settings + the doc-42 quick-add. - Add
attributes: Record<string,unknown>(whitelisted, default{}) to ArtistSpace / Project / Track / File schemas + update DTOs (@IsObject()only — deep validation in the service). - Service-layer hook: on every create/update of a hierarchy entity, run
validateAttributesagainst the effective catalog for the owner + that entity's level — mirror the existingnormalizeTagshook intrack.service.ts. - Indexing: start unindexed (aggregate-on-read, doc-14); add sparse indexes on
attributes.<key>only for keys that become hot filter/sort axes. - Migration safety:
keyimmutable; type changes are a guarded migration (or a new key); retire = soft (retiredAt), values preserved; no destructive catalog edits.
Decisions resolved (2026-06-27, owner)
- Home = a dedicated
WorkspaceSettingsdoc (not a field onUser) — room to grow: doc-20 §4 theming / saved-views / templates share it. Keyed by owner/tenant; moves up cleanly if an Org/Label container ever lands. - Preset fields are reconciled into the catalog, not duplicated — existing fixed
@Props (ISRC/UPC/genre/releaseDate/…) become managed preset definitions rather than parallel fields. And not every field is required for every user/project — presets are available, applied per level and perrequiredflag, so a solo artist never sees label-distribution fields they don't use. - Cross-level inheritance is opt-in per definition (
inherit: true) — off by default, on where it makes sense (e.g.genreartist→track). reftype is supported — attribute values may reference other nodes (e.g. "supervised by → user", "same session as → track"). Coordinate with doc-28 relations so the two don't double-model the same link (refs = lightweight attribute pointers; doc-28 = first-class typed relations).statusCodeandtagsfold in as managed definitions — the lifecycle status + tags become the first preset definitions in this engine (unifying), rather than staying bespoke fields. Migration keeps their existing storage/behavior; they gain catalog management for free.
Sequencing
Envisioning only — no build in this pass. Build order when greenlit (premium layer, after the mgmt.ctrl core + doc-38 backbone): (1) ship default preset catalogs as fixed code (cheap, immediate value, reconcile statusCode/existing metadata); (2) add the attributes bag + service-layer validation; (3) the WorkspaceSettings overlay + catalog endpoints (premium gate); (4) authoring surfaces (Workspace Settings editor + mgmt.ctrl quick-add); (5) doc-40 filter/sort/group on attributes
- sparse indexes.
mgmt.ctrl(doc-42) does not wait on any of this — it consumes the effective catalog, fixed-default today, configurable here.
Status: envisioning. Decisions 1–3 locked 2026-06-27. The default-preset slice is cheap and could ride earlier (like doc-41 lifecycle-status); the configuration engine + authoring are the premium, post-MVP body. Companion to doc-42; consolidates doc-17/20/41/31 customization threads.