|
| 1 | +# ADR-0051: Inline metadata views in docs — the ` ```metadata ` fenced block |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-16) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0046](./0046-package-docs-as-metadata.md) (`doc`/`book` as metadata — §3.4 syntax boundary, §3.5 *derived content is rendered, never written*, §6.7 anonymous live-embed guardrail), [ADR-0020](./0020-state-machine-converge-and-enforce.md) (a record state machine is a `state_machine` validation rule on an object, not a standalone type), [ADR-0047](./0047-object-ui-run-modes.md) (ObjectUI is the server-driven UI runtime), [ADR-0048](./0048-cross-package-metadata-collision.md) (package-scoped single-item resolution), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove posture for governed properties), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI as primary author) |
| 6 | +**Consumers**: `@objectstack/spec` (`DocSchema` unchanged; new `element:metadata_viewer` entry in `ComponentPropsMap`; the fenced-block body schema), `@objectstack/cli` (publish lint: parse + validate body + reference-liveness), `@objectstack/console` (the SDUI doc renderer lifts ` ```metadata ` fences into the component), `apps/docs` portal (anonymous degradation per ADR-0046 §6.7), `@objectstack/core` (kernel **unchanged** — `doc` stays inert) |
| 7 | +**Pilot**: `os-tianshun-mtc` |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +ADR-0046 §3.5 already ruled that anything reconstructible from the manifest — |
| 14 | +field dictionaries, option lists, validation conditions, **permission |
| 15 | +matrices** — is a *metadata view the platform renders live*, never authored |
| 16 | +prose, and §6.7 named the same thing "inline metadata views / live metadata |
| 17 | +embed" when it wrote the anonymous guardrail. Neither specified how an author |
| 18 | +**places** such a view inside a doc. This ADR specifies exactly that, and |
| 19 | +nothing more: |
| 20 | + |
| 21 | +- **One syntax: a ` ```metadata ` fenced code block.** Its body is a small, |
| 22 | + declarative reference — *which* metadata to render and *how* — not code: |
| 23 | + |
| 24 | + ````md |
| 25 | + ```metadata |
| 26 | + type: state_machine |
| 27 | + object: order |
| 28 | + name: order_lifecycle |
| 29 | + mode: diagram |
| 30 | + detail: business |
| 31 | + ``` |
| 32 | + ```` |
| 33 | + |
| 34 | +- **It compiles to one SDUI component, `element:metadata_viewer`** — a new, |
| 35 | + read-only, `embeddableInDoc` entry in `ComponentPropsMap`. The doc renderer |
| 36 | + is an ObjectUI surface; a metadata embed is an ObjectUI node like any other. |
| 37 | + **One runtime, one schema, one permission path** — never a parallel renderer. |
| 38 | +- **The block is data, not code** — a reference the *platform's own* trusted |
| 39 | + component resolves. That is what keeps it on the safe side of the ADR-0025 |
| 40 | + trust boundary that §3.4 drew to ban MDX; this ADR does **not** reopen that |
| 41 | + ban. |
| 42 | +- **Live transclusion**: the view is rendered from *current* metadata at read |
| 43 | + time. Change the flow, the doc's diagram changes. A copy can go stale; a |
| 44 | + reference cannot — that is the entire reason to build this rather than tell |
| 45 | + authors to paste a screenshot. |
| 46 | +- **Scope is metadata-only, and phased**: `state_machine` first, `flow` second, |
| 47 | + `permission` last (it gates on render-time projection). `object` is deferred |
| 48 | + (§5). A general "embed any ObjectUI component" fence (` ```objectui `) is an |
| 49 | + explicit non-goal here, kept as a future escape hatch (§4). |
| 50 | + |
| 51 | +The kernel is untouched: `doc.content` stays an inert Markdown string (ADR-0046 |
| 52 | +§3.1). Resolution is a **render-time** concern, invisible to the load path. |
| 53 | + |
| 54 | +## 1. Context |
| 55 | + |
| 56 | +- **The reader is the deciding variable, not the metadata.** A `flow` is "open |
| 57 | + the Studio designer" for a developer and "read the doc" for a business user, |
| 58 | + auditor, or PM who has no Studio and never will. Documentation earns its place |
| 59 | + only for the reader who cannot get the answer by *using the running system*: |
| 60 | + before they have access, before a feature ships, to see the *why*, or to see a |
| 61 | + cross-cutting whole that **no single live screen presents** (the full legal |
| 62 | + transition space of a record, every role's access to an object). Live, |
| 63 | + interactive artifacts a reader *can* reach — a `dashboard`, a `report`, a list |
| 64 | + `view` — are best experienced in the system; docs should **deep-link** to |
| 65 | + those, never embed a frozen copy. That deep-link case is out of scope here. |
| 66 | +- **What is left needs a synthesized, read-only view that the live UI never |
| 67 | + shows as one picture**: a state machine's whole transition graph, a flow's |
| 68 | + process shape and approval policy, an object's permission matrix. ADR-0046 |
| 69 | + §3.5 already classified these as *rendered, never written*, and §6.7 reserved |
| 70 | + "inline metadata views" in its security guardrail. The concept is blessed; the |
| 71 | + **syntax and resolution mechanism are unspecified**. This ADR supplies them. |
| 72 | +- **The frontend is server-driven UI (ADR-0047).** ObjectUI renders a |
| 73 | + component tree (`ComponentPropsMap`, `record:*` / `element:* `/ `ai:*`) from |
| 74 | + metadata. A metadata embed must therefore be an **ObjectUI component**, not a |
| 75 | + Markdown-only side-channel — otherwise we build a second rendering path that |
| 76 | + ObjectUI's evolution can never extend. Conversely, the doc must stay portable |
| 77 | + inert Markdown (ADR-0046's whole thesis): the seam between portable prose and |
| 78 | + a live view is the one thing this ADR designs. |
| 79 | +- **AI writes most docs (ADR-0033).** The authored surface must be flat, fixed, |
| 80 | + and lintable so an LLM author cannot subtly corrupt it, and so a typo on a |
| 81 | + metadata name is caught the same way field-ref typos already are. |
| 82 | + |
| 83 | +## 2. Goals & Non-goals |
| 84 | + |
| 85 | +### Goals |
| 86 | + |
| 87 | +- A single authored syntax — the ` ```metadata ` fence — with a typed, |
| 88 | + lintable body schema. |
| 89 | +- A new read-only SDUI component, `element:metadata_viewer`, that the fence |
| 90 | + compiles to; the doc renderer reuses the ObjectUI runtime to render it. |
| 91 | +- Render-time semantics: live transclusion, package-scoped resolution |
| 92 | + (ADR-0048), two distinct projections (authoring *detail* vs automatic |
| 93 | + *permission*), and graceful degradation (anonymous, missing-ref). |
| 94 | +- Publish lint: parse fences, validate bodies, and check that every referenced |
| 95 | + metadata item is live (the enforce-or-remove posture of ADR-0049). |
| 96 | + |
| 97 | +### Non-goals |
| 98 | + |
| 99 | +- **A general component-embed fence (` ```objectui `).** Embedding *arbitrary* |
| 100 | + ObjectUI nodes (charts, AI panels, interactive forms) is deferred to a later |
| 101 | + escape hatch (§4). v1 embeds *metadata views only*. |
| 102 | +- **Deep-linking to live surfaces** (`dashboard` / `report` / `view`). Those |
| 103 | + are reached in the system; a plain Markdown link suffices. Out of scope. |
| 104 | +- **The inline `object://` semantic link** (ADR-0046 §3.3) stays the *inline* |
| 105 | + form ("mention"); the ` ```metadata ` fence is the *block* form ("embed"). |
| 106 | + Both are the same intent at different altitudes; the inline form remains |
| 107 | + deferred and only required to parse. |
| 108 | +- **Any kernel change.** `doc` stays inert (ADR-0046 §3.1); `content` is never |
| 109 | + parsed on the load path. |
| 110 | + |
| 111 | +### Explicitly deferred |
| 112 | + |
| 113 | +`object` embeds (wrong altitude for their reader — a hand-authored conceptual |
| 114 | +model beats an auto-rendered schema that dumps every system field; §5); the |
| 115 | +` ```objectui ` general fence; cross-package metadata embeds beyond the |
| 116 | +dependency set ADR-0046 §3.3 already resolves; an `object://` inline preview. |
| 117 | + |
| 118 | +## 3. Design |
| 119 | + |
| 120 | +### 3.1 The syntax — a fenced code block, body is a declarative reference |
| 121 | + |
| 122 | +A ` ```metadata ` fenced block names *what* to render and *how*. The body is |
| 123 | +parsed as YAML and validated against the schema below (illustrative; exact field |
| 124 | +names settle in the spec PR): |
| 125 | + |
| 126 | +```ts |
| 127 | +// The fence body. Compiles 1:1 to element:metadata_viewer props (§3.2). |
| 128 | +const MetadataEmbedSchema = z.object({ |
| 129 | + type: z.enum(['state_machine', 'flow', 'permission']), // 'object' deferred (§5) |
| 130 | + name: z.string(), // target item; resolved package-scoped (ADR-0048) |
| 131 | + object: z.string().optional(), // REQUIRED for object-scoped kinds |
| 132 | + // (state_machine is a validation rule ON an object — ADR-0020; |
| 133 | + // permission renders a matrix FOR an object) |
| 134 | + mode: z.enum(['diagram', 'matrix', 'summary']).optional(), // default per type |
| 135 | + detail: z.enum(['business', 'technical']).optional().default('business'), // authoring projection (§3.5) |
| 136 | +}); |
| 137 | +``` |
| 138 | + |
| 139 | +Choosing a **fenced code block** over directives/MDX/shortcodes is deliberate: |
| 140 | + |
| 141 | +- It is **already valid CommonMark + GFM** — the exact grammar ADR-0046 §3.4 |
| 142 | + permits. We adopt a primitive that was always legal; we do **not** relax the |
| 143 | + "no MDX / no directives / no shortcodes" boundary. |
| 144 | +- It **degrades to meaningful text**: a renderer that does not know `metadata` |
| 145 | + shows a code block reading `type: state_machine / name: order_lifecycle` — a |
| 146 | + human still learns "a state-machine view named order_lifecycle belongs here," |
| 147 | + unlike a broken `<Component/>` or a stray `:::`. |
| 148 | +- It is **block-level by nature** (these are block diagrams) and **trivially |
| 149 | + lintable** (scan fences whose info string is `metadata`, validate one object). |
| 150 | +- It is the industry-blessed convention for embedded views (Mermaid, PlantUML, |
| 151 | + math) — not a bespoke dialect. |
| 152 | + |
| 153 | +The info string is the bare token `metadata`. The doc renderer owns its |
| 154 | +interpretation, so the genericity of the word carries no cross-tool collision |
| 155 | +risk inside ObjectStack; should cross-tool rendering ever matter, namespacing to |
| 156 | +`os:metadata` is a non-breaking later move. |
| 157 | + |
| 158 | +### 3.2 It compiles to one SDUI component — one runtime, never a parallel path |
| 159 | + |
| 160 | +The fence is **sugar over a single ObjectUI node**. The doc renderer lifts each |
| 161 | +` ```metadata ` block into: |
| 162 | + |
| 163 | +```ts |
| 164 | +// packages/spec/src/ui/component.zod.ts — new ComponentPropsMap entry |
| 165 | +'element:metadata_viewer': MetadataEmbedSchema, // read-only; embeddableInDoc: true |
| 166 | +``` |
| 167 | + |
| 168 | +`element:metadata_viewer` resolves `type` + `name` (+ `object`) against the live |
| 169 | +metadata registry and dispatches to the registered viewer for that type — the |
| 170 | +existing `MetadataViewerContribution` mechanism (preview mode) in |
| 171 | +`packages/spec/src/studio/plugin.zod.ts`. `flow` reuses the read-only render |
| 172 | +descriptors already specified in `packages/spec/src/studio/flow-builder.zod.ts`; |
| 173 | +`state_machine` renders the transition graph of the rule in |
| 174 | +`packages/spec/src/automation/state-machine.zod.ts`; `permission` renders an |
| 175 | +object's matrix from `packages/spec/src/security/permission.zod.ts`. |
| 176 | + |
| 177 | +This is the load-bearing decision: **rendering a doc is rendering an ObjectUI |
| 178 | +page** that is mostly `element:markdown` prose with metadata-viewer islands. |
| 179 | +There is one component model, one props schema, one render-time permission path |
| 180 | +— the same one pages use. The earlier rejected `osembed`-with-bespoke-YAML |
| 181 | +design (§4) failed precisely by introducing a second schema and renderer; this |
| 182 | +does not, because the body *is* a component's props. |
| 183 | + |
| 184 | +> **Why a narrow `metadata` fence and not the general `objectui` fence — |
| 185 | +> trust boundary.** ADR-0046 §3.4 banned MDX because "MDX is code, and rendering |
| 186 | +> publisher-supplied code crosses the ADR-0025 trust boundary; Markdown is |
| 187 | +> data." A ` ```metadata ` body is **pure declarative data**: a reference plus |
| 188 | +> two enums, no expressions, no actions, sanitizable. The view it produces is |
| 189 | +> drawn by the *platform's own trusted component*, from the *package's own |
| 190 | +> already-governed metadata* — the publisher supplies a reference, the platform |
| 191 | +> supplies the renderer. A general ` ```objectui ` body, by contrast, could |
| 192 | +> carry CEL expressions and action bindings (`element:form.onSubmit`, |
| 193 | +> `element:button`) — i.e. code — and would reopen the boundary §3.4 closed. |
| 194 | +> The metadata-only scope is therefore not just smaller; it is the form that |
| 195 | +> stays on the *data* side of the line. |
| 196 | +
|
| 197 | +### 3.3 Live transclusion — a reference, never a copy |
| 198 | + |
| 199 | +The view is rendered from **current** metadata every time the doc is read. This |
| 200 | +is the whole justification for the feature: a pasted screenshot or hand-drawn |
| 201 | +diagram rots the moment the flow changes; a reference is always correct. Three |
| 202 | +properties follow and are mandatory — drop any one and the embed degrades into a |
| 203 | +stale, wrong-altitude copy of the Studio designer (the failure ADR-0046 §3.5 |
| 204 | +forbids): |
| 205 | + |
| 206 | +1. **Live-derived** — resolved at render time, never materialized into |
| 207 | + `content`. |
| 208 | +2. **Projected** — to the authored *detail* and the reader's *permissions* |
| 209 | + (§3.5). |
| 210 | +3. **Narrative-owned** — the human writes the *why*; the system fills the |
| 211 | + *what*. The author places a reference, not a rendering. |
| 212 | + |
| 213 | +Resolution is **package-scoped** (ADR-0048): `name` resolves within the doc's |
| 214 | +own package, then its dependencies (the same set ADR-0046 §3.3 resolves |
| 215 | +cross-package doc links against). A reference to a metadata item the reader's |
| 216 | +grant does not cover, or that does not exist, **degrades to a placeholder** — it |
| 217 | +never blocks render (mirroring §3.3's broken-link posture). |
| 218 | + |
| 219 | +### 3.4 Two projections — keep them separate |
| 220 | + |
| 221 | +| projection | when | who controls | what it does | |
| 222 | +|:--|:--|:--|:--| |
| 223 | +| **detail** | authoring | the author (`detail:` field) | altitude: `business` collapses technical flow nodes (HTTP, script, error boundaries) to the business-meaningful steps + approvals; `technical` shows the full graph | |
| 224 | +| **permission** | render time | **the platform, automatically, per reader** | strips what the reader may not see (a fraud-check branch, a permission row for a role they cannot view) before drawing | |
| 225 | + |
| 226 | +These are orthogonal and must not be conflated. `detail` is an authored |
| 227 | +intent; **permission projection is an automatic security behavior**, never |
| 228 | +author-controlled. Note the name: this is *not* `book.audience` (ADR-0046 §6.7), |
| 229 | +which is an *access tier* on the publication unit. To avoid that collision the |
| 230 | +authoring knob is `detail`, not `audience`. |
| 231 | + |
| 232 | +### 3.5 Security — inherits the §6.7 guardrail, adds nothing the kernel must parse |
| 233 | + |
| 234 | +- **Anonymous degradation is inherited verbatim.** ADR-0046 §6.7 already |
| 235 | + mandates that any live-metadata embed "must not resolve for an anonymous |
| 236 | + request; it degrades to a hidden / 'sign in to view' placeholder." A |
| 237 | + ` ```metadata ` block is exactly such an embed. In the authenticated console |
| 238 | + (ObjectUI runtime present) it resolves, live and permission-projected; in the |
| 239 | + anonymous `apps/docs` portal it degrades. No new rule — the same rule, now |
| 240 | + with a concrete syntax to govern. |
| 241 | +- **Permission projection is enforced at the resolver, not the UI.** Like |
| 242 | + §6.7's gating, the metadata-viewer resolver applies the reader's permissions |
| 243 | + server-side; a client that strips chrome must not be able to surface a hidden |
| 244 | + branch or row. |
| 245 | +- **The kernel stays inert (ADR-0046 §3.1).** `content` is never parsed on the |
| 246 | + load path; the fence is a render-time concern only. `doc` remains |
| 247 | + `supportsOverlay: false`. |
| 248 | + |
| 249 | +### 3.6 Publish lint — parse, validate, check liveness |
| 250 | + |
| 251 | +`os build` / publish lint (the same pass that already enforces §3.4's bans and |
| 252 | +§3.3's link resolution) gains one check on `doc.content`: |
| 253 | + |
| 254 | +1. Extract every fence whose info string is `metadata`; parse the body. |
| 255 | +2. Validate it against `MetadataEmbedSchema`; a malformed body **fails publish** |
| 256 | + (it is authored structure, not prose). A `type` typo yields a did-you-mean |
| 257 | + against the enum — the same affordance flow-condition field typos already get. |
| 258 | +3. Resolve `type` + `name` (+ `object`) against the package and its |
| 259 | + dependencies; an embed pointing at a **dead or renamed** metadata item is |
| 260 | + reported. This rides the existing reference-validation / liveness machinery |
| 261 | + and follows ADR-0049's enforce-or-remove posture: a doc that references a |
| 262 | + non-live item is a broken reference, surfaced at publish, degraded (not |
| 263 | + crashed) at render. |
| 264 | + |
| 265 | +## 4. Alternatives considered |
| 266 | + |
| 267 | +| Alternative | Verdict | |
| 268 | +|:--|:--| |
| 269 | +| **` ```objectui ` general component fence now** (body = any `{type, props}` node) | Rejected *for v1*, kept as a future escape hatch. A general body can carry expressions and action bindings — code — reopening the §3.4 trust boundary. The `metadata` fence stays on the data side. When arbitrary embeds are genuinely needed, add ` ```objectui ` as a power-user form that **shares this runtime** (`metadata` is sugar over `element:metadata_viewer`; `objectui` is the full node) — narrow-sugar + general-form, one renderer, not two systems. | |
| 270 | +| **` ```osembed ` with a bespoke YAML schema** (an earlier turn) | Rejected. A body unrelated to the component model spawns a second schema, a second validation path, a second renderer dispatch — the parallel-system smell. Binding the body to `ComponentPropsMap` (§3.2) avoids it entirely. | |
| 271 | +| **MDX / JSX components** | Banned by ADR-0046 §3.4 (code across the ADR-0025 trust boundary); couples docs to React; destroys "content is inert data." | |
| 272 | +| **remark-directive (` :::metadata{...} `)** | Semantically the cleanest ("typed block with attributes"), but it *is* a directive — the thing §3.4 bans — and needs a new parser. The fenced block buys the same capability with zero boundary change and cleaner degradation. Correct elsewhere; wrong in this repo. | |
| 273 | +| **Obsidian transclusion (`![[name]]`)** | Not CommonMark; no room for `type` / `mode` / `detail`; overloads image syntax. | |
| 274 | +| **HTML-comment sentinels (`<!-- embed:... -->`)** | Valid CommonMark but no first-class fields, easy to mistype, semantics invisible — a hack. | |
| 275 | +| **A pasted screenshot / hand-drawn diagram** | Rots on the next metadata change (ADR-0046 §3.5). Live transclusion exists precisely to kill this. | |
| 276 | + |
| 277 | +## 5. Phasing |
| 278 | + |
| 279 | +Ordered by value ÷ (risk + staleness). Each phase is independently shippable. |
| 280 | + |
| 281 | +| Phase | Scope | Depends on | |
| 282 | +|:--|:--|:--| |
| 283 | +| **P1 — `state_machine`** | the ` ```metadata ` fence grammar; `MetadataEmbedSchema`; `element:metadata_viewer` (state-machine renderer over ADR-0020 rules); publish lint (parse + validate + liveness); console lift + render | this ADR | |
| 284 | +| **P2 — `flow`** | `flow` kind via the read-only flow-builder descriptors; the `detail: business` altitude projection (collapse technical nodes) — the projection is *mandatory* here, or the embed is a worse Studio | P1 | |
| 285 | +| **P3 — `permission`** | `permission` matrix kind; **render-time permission projection** per reader; verified against the §6.7 anonymous guardrail | P2, and a permission-projecting resolver | |
| 286 | +| **Deferred** | `object` (wrong altitude — prefer a hand-authored conceptual model); ` ```objectui ` general fence; `object://` inline preview | — | |
| 287 | + |
| 288 | +`state_machine` leads because its graph is small, naturally at business altitude |
| 289 | +(states *are* business states), and low-sensitivity — the cleanest proof that |
| 290 | +"edit the metadata, the doc's diagram follows." `permission` trails because it is |
| 291 | +the highest-value-to-auditors **and** highest-risk kind: it ships only once the |
| 292 | +render-time projection and the §6.7 gate are both in place. |
0 commit comments