Mosaic one model, many lenses

ADR 0016: parts/blocks inventory + configurability on all four levels

  • Status: accepted
  • Date: 2026-10-01
  • Supersedes: nothing (activates the dormant PartDecl.config/slots AST surface; extends the app.parts / app.instances constructs)

Context

A tessera composes an app from parts (a.k.a. blocks — the user's "BBs"): parts: declarations (name, group, about, tags, depends, audience, contributions, nested domain decls) that contribute to slots (rest.endpoints, cli.commands, mcp.tools, gateway.routes, ui.scenes, ui.pages), plus the app's enablement (app.parts: [names]) and instances (app.instances: {name: {part, realm, config}}).

The requirement (in order): an inventory of groups of parts and the parts themselves, and configurability on all levels — app → part instance → part slot/port → contribution — supported to 100%, with good software patterns.

Current state (the gap):

  • No inventory surface. PartDecl.group/audience/tags are parsed and carried into PartPlan, but rendered nowhere: no page, no REST route, no MCP tool. An operator cannot see what an app is built from.
  • The config machinery is dormant. ConfigField (name, ty, default, env, feature_gate, required) and PartPlan.config/ConfigFieldPlan exist, and the plan layer consumes them (env bindings, api_key_env, base_path) — but no YAML key ever populates part.config (YamlPart has no config), and no platform part declares one. The consumption code is dead: AppPlan.env is never read by the renderer, api_key_env is always None, part_configs (instance → field → value) is only looked up inside the dead config loop.
  • Slots are implicit. The vestigial PartDecl.slots (SlotDecl {about, multi, handler}) is never declarable or validated; a contribution's dotted slot path is matched by if/else in the planner, and a typo or an undeclared slot is silently ignored (_ => {}).
  • Enablement doesn't gate contributions. rest.endpoints/ cli.commands/mcp.tools/ui.scenes/ui.pages are collected from all declared parts, while gateway.routes is collected from enabled parts only — so "app-level configurability" (enabling/disabling parts) is a no-op for most slots.
  • Contribution items have no on/off. A part's items are always generated; there is no declared way to keep a slot contribution present but disabled.

Decision

Four levels, in the required order (app → instance → slot → contribution), plus the inventory surface that makes all of them visible:

L1 — app level: enablement gates everything

app.parts: [names] is the single source of truth for which part types are on. Fix: every slot collection (rest/cli/mcp/ui, not just gateway) now iterates the enabled part types only. A declared part not in app.parts contributes nothing (still visible in the inventory, marked disabled). Unknown part types keep the existing lenient posture (warning: platform parts are provided at build time by the compiler — rest, cli, mcp, gateway, ui, db, cqrs, scheduler, …).

L2 — instance level: validated config

parts[].config becomes declarable in YAML (activating the dormant AST):

parts:
  - name: orders
    config:
      - name: base_url
        type: string
        default: "https://orders.example.com"
        env: { var: MOAIC_ORDERS_BASE, secret: false }
      - name: token
        type: string
        env: { var: MOAIC_ORDERS_TOKEN, secret: true }
        required: true

app.instances.<name>.config (existing part_configs) is now validated against that schema, fail-closed with named errors: unknown key → part-config-unknown; missing required field with no default/env → part-config-missing; value type vs type (string/number/bool/enum) mismatch → part-config-type; a secret field with a literal value → part-config-secret (secrets are never inlined, the existing Z7/ADR 0014 posture); a secret: true field without an env binding → secret-not-env (the schema rule: secrets live in env vars, never in the tessera). secret is a standalone YAML flag (an env binding can also carry secret: true; the two are OR-ed). Resolution order per field: instance literal → declared default → env binding (the generated app reads the env var at runtime; MOAIC_* convention unchanged).

The resolved config reaches the generated app as two typed consts (the OOP shape: a part instance receives its config object, secrets by reference):

  • PART_CONFIG: &[(&str instance, &str field, &str value)] — non-secret effective values (literal or default);
  • PART_ENV: &[(&str instance, &str field, &str var, bool secret)] — env bindings; the app reads var at boot and the value shows in the inventory as set/unset (never the value).

This also activates the existing consumption paths (AppPlan.env, api_key_env, base_path) with real data.

L3 — slot/port level: declared slots, fail-closed

parts[].slots becomes declarable (activating the vestigial SlotDecl):

parts:
  - name: orders
    slots:
      actions:
        about: "Order actions this part exposes."
        multi: true

Slot paths are <part>.<slot> (the existing two-segment convention — rest.endpoints = part rest, slot endpoints). Validation, fail-closed:

  • a contribution targeting a declared user part must name one of its declared slots → otherwise part-slot-undeclared (a typo can no longer be silently ignored);
  • platform-part slots (rest.endpoints, cli.commands, mcp.tools, gateway.routes, ui.scenes, ui.pages) stay built-in — no declaration needed;
  • multi: false (default): the same item name in two contributions of one slot → part-slot-duplicate; multi: true allows it.

SlotDecl.handler stays out of scope (no runtime dispatch to register; slots are compile-time composition points).

L4 — contribution level: per-item on/off

A contribution item may declare enabled: false — the item stays in the inventory (visible, documented as disabled) but is excluded from code generation (no endpoint/route/tool/command/scene/page). Default true. This is the declared, deterministic off-switch: flipping it is a declaration change, rendered output changes accordingly (conformance re-pin).

Inventory surface (declared facts — the ADR 0008 projection)

The plan gains the complete inventory (the extended PartPlan: tags, audience, slots with their items + enabled state, instances with resolved config, enabled flag), projected to three surfaces:

  • REST (app lens): GET /api/parts (and POST, for the MCP/CLI bridges — one core, many bridges) → {groups: [{group, parts: [{name, about, audience, tags, enabled, depends, config: [{name, env, secret, required, default}], instances: [{name, realm, config: {field: value|"***"|null}}]}], slots: [{part, slot, about, multi, platform, items: [{name, enabled}]}]} (secrets masked; env-bound non-secret fields show their build-time value or null). The plan carries the inventory as ws.parts (extended PartPlan: tags/audience/enabled) + ws.slot_catalog (the six platform slots always present, declared slots alongside). The generated app emits PART_CONFIG/PART_ENV consts and PARTS_INVENTORY_JSON (a &str const — serde_json::json! is not const-evaluable) parsed once into a OnceLock by crate::ws::parts_inventory().
  • MCP (app lens): list_parts tool (the stdio server's built-in inventory entry, mounting /api/parts) — the same inventory, for agents.
  • Site (zola + html backends): build-time /parts/ page (nav entry, the ADR 0008 projection of declared facts — like /kb/ for knowledge): groups → parts table (name, about, audience, tags, slots with item counts, instances with their effective config, secrets masked).

The web lens (csr/full) gets no page in this ADR — the site page + REST + MCP cover the inventory requirement (the web lens is a per-app console, not the reference projection).

Proof

  • Unit tests (inline YAML, tessera_yaml + plan layer):
    • part.config + part.slots parse (YAML → decl);
    • L1: a disabled declared part's contributions are absent from the plan; an enabled part's are present;
    • L2: unknown instance config key / missing required / type mismatch / secret-with-literal each produce the named error; literal + default + env resolution order holds; PART_CONFIG/PART_ENV consts emitted (codegen test);
    • L3: contribution to an undeclared slot of a user part → named error; platform slots still work; duplicate item name on a non-multi slot → named error;
    • L4: enabled: false item absent from endpoints, present in the inventory as disabled;
    • GET /api/parts + list_parts + /parts/ site page emit (codegen tests) with secrets masked.
  • e2e (full-stack): GET /api/parts → 200 (the full-stack part gains a config schema + a declared slot + an enabled: false item + instance config; the new scenario asserts the inventory endpoint).
  • Conformance goldens re-pinned (new consts + route + MCP tool + site page); workspace tests + clippy -D warnings + fmt clean.

Consequences

  • An operator can see exactly what an app is built from (groups → parts → slots → items → instances) on three surfaces, and configure it on every level: enable/disable part types (app), set instance config (instance, validated), declare + reference slots (slot, fail-closed), switch individual items off (contribution).
  • The dormant config machinery (ConfigField → plan → api_key_env/ base_path/env) becomes live, fail-closed, and typed — no new AST types, only YAML keys and validation.
  • Generated apps grow two consts + one route + one MCP tool + a site page; zero behavior change for apps without config/slots (all defaults: config empty, slots = platform-only, items enabled).
  • Next (ADR 0017): the model registry — models: declarations for chat/embed/rerank (provider + model, secrets env-only), wiring the dead vector_dbs[].model, and runtime configurability (effective config
    • provider "available models" listing) — reusing the declared-floor / runtime-overlay / env-for-secrets posture established here.