|
| 1 | +# FR-004 Plan #3 — typed payload VO + typed render handle + `verify` (the drift guarantee) |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. TDD throughout. Execute in an isolated worktree off the **latest** `main`. Authoritative design: the *Design revision — 2026-05-23* section of the FR-004 spec (R1–R10) + the verify discussion captured below. |
| 4 | +
|
| 5 | +**Goal:** Turn the render slice from "Mustache works" into the actual pillar value — *typed, drift-checked prompts* — proven by a single demo that **fails to compile** on a wrong-shaped payload **and fails `verify`** on a template that references a field the payload doesn't have. |
| 6 | + |
| 7 | +**The load-bearing fact this plan is built on:** a Mustache template is an opaque runtime **string** — the TS compiler cannot see into `{{npcName}}`. So passing a typed VO into Mustache buys nothing by itself. Enforcement comes from **two** distinct mechanisms, neither of which is "VO into Mustache": |
| 8 | +1. **Caller side (compile-time):** a payload VO **type** codegen'd *from the projection metadata* + a typed render handle, so the call site can't pass a payload that doesn't match the declared shape. |
| 9 | +2. **Template side (build-time):** `verify` — parse the template's tokens and cross-check them against the VO's metadata-declared fields. This is the only way to check the template, *because* Mustache is a string. |
| 10 | + |
| 11 | +Together they close the loop. Plain JSON → raw Mustache (Plan #2) demonstrates neither; Plan #2 was the engine substrate. **This plan is the value.** |
| 12 | + |
| 13 | +**Verify runs at the last fixed point before serve, never on the request path:** load-time for metadata-internal checks (Phase A), build-time `meta verify` for the template-text check (Phase C), and an optional engine `verify`-on-resolve guard (fail-closed) for dynamic providers. |
| 14 | + |
| 15 | +**Tech Stack:** TypeScript, `@metaobjectsdev/codegen-ts` (VO + handle generators), `mustache` (`Mustache.parse` for token extraction), `@metaobjectsdev/cli` (`meta verify`). |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Phase A — loader: `origin.collection` + load-time validation (metadata-internal) |
| 20 | + |
| 21 | +- [ ] **T1 · `origin.collection` subtype.** Add the nested-array origin (mirrors `origin.passthrough`/`aggregate`): `@via` (a relationship dotted-path) + optional wildcard selector for package-spanning collections. Loader recognition + schema + 1 conformance fixture + C# expected-failures gate. This lets an `object.value` view-object declare `posts: PostBrief[]` from `User.posts`. |
| 22 | + |
| 23 | +- [ ] **T2 · Load-time validation pass** (`MetaData.validate()` / a loader validation pass — same phase as the existing `origin.passthrough @from` resolution): |
| 24 | + - `@payloadRef` resolves to a real `object.value` in the loaded model → else `ERR_PAYLOAD_REF_UNRESOLVED`. |
| 25 | + - every `@requiredSlots` entry is a real field on that view-object → else `ERR_REQUIRED_SLOT_MISSING`. |
| 26 | + These are pure metadata cross-references (no provider, no I/O) — they run on every load, including loud at app startup. Conformance **error** fixtures for each. (Template-text checks are NOT here — they need the provider; Phase C.) |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Phase B — codegen: typed payload VO + typed render handle (caller-side compile-time) |
| 31 | + |
| 32 | +- [ ] **T3 · Payload-VO type generator** (a `codegen-ts` generator). From an `object.value` view-object emit a TS type: passthrough/aggregate fields → scalars; `origin.collection` fields → arrays of nested VO types. Example output for the `AuthorBrief` projection over `trainerWebsite`: |
| 33 | + |
| 34 | +```ts |
| 35 | +// generated |
| 36 | +export interface AuthorBrief { |
| 37 | + displayName: string; |
| 38 | + postCount: number; |
| 39 | + posts: AuthorBriefPost[]; |
| 40 | +} |
| 41 | +export interface AuthorBriefPost { |
| 42 | + title: string; |
| 43 | + tags: { name: string }[]; |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +- [ ] **T4 · Typed render-handle generator.** Per `template.*` node, emit a handle that binds the template's `@textRef` + `@format` and types its payload to the `@payloadRef` VO: |
| 48 | + |
| 49 | +```ts |
| 50 | +// generated |
| 51 | +import { render, type Provider } from "@metaobjectsdev/render"; |
| 52 | +import type { AuthorBrief } from "./payloads.js"; |
| 53 | +export function renderContentStrategyPrompt(payload: AuthorBrief, provider: Provider): string { |
| 54 | + return render({ ref: "prompt/content-strategy", payload, provider, format: "xml" }); |
| 55 | +} |
| 56 | +``` |
| 57 | +The generated artifact has **zero MetaObjects runtime dep beyond `@metaobjectsdev/render`** (per the framework philosophy). The caller now gets a compile error if it passes the wrong payload shape. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## Phase C — `verify` (template-side, build-time) + the demo |
| 62 | + |
| 63 | +- [ ] **T5 · `verify(templateText, voFields)` (in `@metaobjectsdev/render`).** Use `Mustache.parse(template)` to walk tokens; collect interpolations (`name`/`&`/`{`), sections (`#`/`^`), and partials (`>`). Check each variable path against the VO field tree **context-sensitively** — a `{{#posts}}{{title}}{{/posts}}` checks `title` against `posts`' element type. Return a list of `{ code, path }` drift errors: |
| 64 | + - `{{var}}` not a field on the (contextual) VO → `ERR_VAR_NOT_ON_PAYLOAD`. |
| 65 | + - `@requiredSlots` slot never referenced → `ERR_REQUIRED_SLOT_UNUSED` (warning). |
| 66 | + - `{{> ref}}` unresolved in the provider → `ERR_PARTIAL_UNRESOLVED`. |
| 67 | + TDD: a matrix of templates × VO field-trees → expected error lists. |
| 68 | + |
| 69 | +- [ ] **T6 · `meta verify` CLI command** (`@metaobjectsdev/cli`). Load metadata + a filesystem provider; for each `template.*` node resolve its text via the provider, derive its `@payloadRef` VO field tree from the loaded metadata, run `verify`, and exit non-zero on any drift (the CI gate). Also wire the **engine `verify`-on-resolve mode** (`render({ verify: voFields })`) as the fail-closed guard for dynamic providers (reject/throw a drifted variant before render). |
| 70 | + |
| 71 | +- [ ] **T7 · THE DEMO — proves both failures.** This is the acceptance criterion for the whole pillar. |
| 72 | + - **(a) compile-time:** a TS test importing the generated handle: |
| 73 | + ```ts |
| 74 | + import { renderContentStrategyPrompt } from "./generated/handles.js"; |
| 75 | + import type { AuthorBrief } from "./generated/payloads.js"; |
| 76 | + const good: AuthorBrief = { displayName: "Ada", postCount: 1, posts: [] }; |
| 77 | + renderContentStrategyPrompt(good, provider); // ✓ type-checks |
| 78 | + // @ts-expect-error — wrong payload shape is a compile error |
| 79 | + renderContentStrategyPrompt({ nope: 1 }, provider); |
| 80 | + ``` |
| 81 | + The build passes **only if** the `@ts-expect-error` line genuinely errors → compile-time enforcement demonstrated. |
| 82 | + - **(b) build-time:** a fixture where the template references a non-field: |
| 83 | + ```ts |
| 84 | + const drift = verify("Hi {{displayName}}, you have {{notARealField}}.", authorBriefFields); |
| 85 | + expect(drift.map(e => e.code)).toContain("ERR_VAR_NOT_ON_PAYLOAD"); |
| 86 | + ``` |
| 87 | + Drift caught at build, loudly → the renamed-field-breaks-a-prompt guarantee demonstrated. |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## Self-review |
| 92 | + |
| 93 | +- **This delivers the value Plan #2 only set up:** caller-side compile-time (T3/T4) + template-side build-time (T5/T6), proven by the two-failure demo (T7). Without it, the slice is "a Mustache wrapper + a metadata lookup." |
| 94 | +- **Verify timing (never runtime-on-request):** Phase A = load-time metadata-internal; Phase C = build-time `meta verify` + a fail-closed engine guard for dynamic text. Matches the "variable text vs fixed VO contract, checked at the last fixed point before serve" model. |
| 95 | +- **Known hard spot:** context-sensitive var checking in T5 (sections change the VO context; dotted paths; `{{.}}` in a scalar array). Build the VO field-tree walker carefully; fixture it heavily. |
| 96 | +- **Scope:** large — execute Phase A → B → C, and split into Plan #3a/#3b/#3c if a phase outgrows a session. Phase C T7 is the definition of done. |
| 97 | +- **Out of scope (later):** the assembler (projection metadata → materialized payload at runtime, host-side/FR-003); Java port. |
0 commit comments