ADR 0016: parts/blocks inventory + configurability on all four levels
- Status: accepted
- Date: 2026-10-01
- Supersedes: nothing (activates the dormant
PartDecl.config/slotsAST surface; extends theapp.parts/app.instancesconstructs)
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/tagsare parsed and carried intoPartPlan, 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) andPartPlan.config/ConfigFieldPlanexist, and the plan layer consumes them (env bindings,api_key_env,base_path) — but no YAML key ever populatespart.config(YamlPart has noconfig), and no platform part declares one. The consumption code is dead:AppPlan.envis never read by the renderer,api_key_envis alwaysNone,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 byif/elsein 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.pagesare collected from all declared parts, whilegateway.routesis 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 readsvarat boot and the value shows in the inventory asset/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: trueallows 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(andPOST, 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 asws.parts(extendedPartPlan: tags/audience/enabled) +ws.slot_catalog(the six platform slots always present, declared slots alongside). The generated app emitsPART_CONFIG/PART_ENVconsts andPARTS_INVENTORY_JSON(a&strconst —serde_json::json!is not const-evaluable) parsed once into aOnceLockbycrate::ws::parts_inventory(). - MCP (app lens):
list_partstool (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.slotsparse (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_ENVconsts 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: falseitem 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 + anenabled: falseitem + 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 deadvector_dbs[].model, and runtime configurability (effective config- provider "available models" listing) — reusing the declared-floor / runtime-overlay / env-for-secrets posture established here.