Documentation source
Customization Map
Interactive rendering cockpit — inventory every entity-type surface, see which resolver tier wins, preview the result, and act on stale bindings.
# Customization Map
`/admin/customization-map` is the **rendering cockpit** for an Amble tenant.
It shows every entity type, every surface (card / list / detail / form / share)
and the full resolver-tier picture for each cell: code, plugin, config, and
default — exactly what `resolveSlot()` would pick in production.
## Why it exists
ADR-0018 codified the four-tier resolver: `code > plugin > config > default`.
ADR-0020 added agent-built `manageComponent` artifacts in the plugin tier.
Before the cockpit, admins could see only a boolean "is this customized" chip
on the legacy page and had to read source files to discover which tier won.
The cockpit consolidates that picture into a single hover-card per cell.
## How tier resolution works
```
┌──────────────────────────────────────────────────────────────────┐
│ resolveSlot(kind, key, ctx) — async, plugin-aware │
│ │
│ Tier order varies by override policy: │
│ │
│ "allow-db-override" (default for read surfaces): │
│ 1. plugin (workspace DB) │
│ 2. plugin (tenant DB) │
│ 3. code (tenant module) │
│ 4. code (platform) │
│ 5. config (entity_types.json_schema) │
│ 6. default (schema-derived | missing) │
│ │
│ "code-locked" (entity-detail, entity-share): │
│ 1. code (tenant) → 2. code (platform) │
│ 3. plugin (workspace DB) → 4. plugin (tenant DB) [fallback] │
│ 5. config → 6. default │
│ │
│ "code-only" (entity-form — write surfaces): │
│ 1. code (tenant) → 2. code (platform) │
│ 3. config → 4. default │
│ DB plugin tier NEVER consulted (compliance boundary). │
└──────────────────────────────────────────────────────────────────┘
```
The cockpit calls `inventoryAllSurfaces()` once per page render — one bulk
SQL query for every plugin binding in the tenant + the in-memory slot
registry — then `pickWinner()` (pure synchronous mirror of `resolveSlot()`,
parity-tested) per cell.
## Cell semantics
Each cell shows a **winner badge** + a hover card with:
- **Tier list** in priority order, winner first, with a "wins · \{stage\}" pill.
Disabled bindings render muted with a "Disabled" pill so admins can spot
cleanup affordances.
- **Code-locked orphan-binding alert** when `policy="code-only"` and the
plugin tier has any enabled bindings (those bindings are dead weight on a
surface that refuses DB resolution).
- **Live preview** — pre-rendered server-side via `<EntitySlot variant="preview">`
on the same mount production uses. `variant="preview"` skips the DB plugin
tier for deterministic + side-effect-free previews.
- **Action footer** — deep-links to the relevant admin surface:
- Code-tier winner: "Code-defined — edit in source" (no admin action)
- Plugin-tier winner: **Open artifact** → `/admin/artifacts/<artifactId>`
- Default-missing: "Add via `manageComponent` in chat"
- Default-schema: **Edit schema** → `/admin/data-types/<typeSlug>`
## Disable affordance
The **Disable** button on each enabled plugin row calls the
`disableSlotBinding(bindingId)` server action. It enforces the full
[ADR-0024](/docs/adr/0024) invariant chain:
1. `requireAdminScope()` zero-arg — identity from URL, never caller args.
2. Pre-fetch the binding row with `tenant_id = $ctx.tenantId` — closes IDOR.
3. Route auth by the BINDING's `workspace_id`:
- Tenant-wide binding → caller is tenant admin (or `isAdminRole`).
- Workspace binding → tenant admin OR workspace-match.
4. UPDATE with `.select()` array pattern + `WHERE enabled = true` (never
`.single()` on UPDATE per `.claude/rules/database.md`).
5. Revalidate `customizationMapTag(tenantId)` AND
`customizationMapWorkspaceTag(tenantId, workspaceId)`.
6. Emit `admin.customization-map.binding-disabled` analytics event.
## Workspace scope
The cockpit reads `?workspace=<slug>` from the URL — **URL is the sole source
of truth** ([ADR-0003](/docs/adr/0003)). No cookies, no JWT claims. Selecting
a workspace in the `<Select>` navigates to the same route with the new query
param. Tenant admins see every workspace they can access; workspace admins
see only their own. The server page resolves the slug to a workspace_id and
threads it into `getCustomizationMapV2(tenantId, workspaceId)`.
## Architecture
| Layer | File | Purpose |
| --- | --- | --- |
| Resolver | `lib/ui-registry/resolve-slot.ts` | Production async resolver (single source of truth for precedence). |
| Sync mirror | `lib/ui-registry/pick-winner.ts` | Pure sync `pickWinner()` — parity-tested against shared fixtures. |
| Inventory | `lib/ui-registry/inventory.ts` | `inventoryAllSurfaces()` — 1 bulk artifact-bindings query + slot-registry read. |
| Types | `lib/ui-registry/inventory-types.ts` | `SurfaceTierInventory`, `PluginBindingSummary`, `WinnerStage`. |
| V2 server | `features/admin/server/customization-map.ts` | `getCustomizationMapV2(tenantId, workspaceId)` + V1 adapter. |
| Disable action | `lib/ui-registry/server/disable-binding.ts` | ADR-0024 invariant chain. |
| Server page | `app/(app)/admin/customization-map/page.tsx` | Pre-renders `<EntitySlot variant="preview">` per cell. |
| Cockpit UI | `app/(app)/admin/customization-map/customization-map-client.tsx` | Filter + search + table + cell + hover card. |
## Caching
`getCustomizationMapV2` is wrapped in `unstable_cache` keyed by `(tenantId,
workspaceId)`. Invalidation hooks fire on every artifact-lifecycle mutation:
- `manageComponent.compile` → after upsert
- `manageComponent.publish` / `archive` → after status flip
- `manageComponent.save` → after save (spec-kind artifacts)
- `disableSlotBinding` → after `enabled = false`
When `workspaceId` is provided, `invalidateCustomizationMap` revalidates both
`customizationMapTag(tenantId)` and
`customizationMapWorkspaceTag(tenantId, workspaceId)`.
## Telemetry
Three PostHog events fire from the cockpit (Zod-validated):
- `admin.customization-map.viewed` — once per server render.
- `admin.customization-map.surface-inspected` — when a hover card opens.
- `admin.customization-map.binding-disabled` — successful disable action.
## For agents
When asking "what would render here?", call `pickWinner(tiers, policy)` —
NOT `resolveSlot()` — to avoid 350 sequential awaits when scanning a tenant.
Pre-fetch bindings via `inventoryAllSurfaces()`. The parity test
(`pick-winner.test.ts`) guarantees the two functions never drift.
When publishing a new agent-built component:
1. `manageComponent.compile` with `slotKind` + `slotName` upserts the
binding and invalidates the cockpit cache.
2. The cockpit refreshes on next render; admins see the new tier picture
immediately.
## Related ADRs
- [ADR-0006](/docs/adr/0006) — FormSpec v2
- [ADR-0018](/docs/adr/0018) — UI resolver tiers
- [ADR-0020](/docs/adr/0020) — Agent-built components
- [ADR-0024](/docs/adr/0024) — Cross-scope authorization invariants