Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,5 +552,4 @@ See `spec/roadmap.md` for current and planned library work. (Consumer-adoption v

- [TECHNICAL] Field-type → Drizzle-column-type mapping table (needed for complete TS codegen coverage).
- [TECHNICAL] ObjectManagerDB further modernization. FR-003 Plan 4 (2026-05-27) closed the three engine-debt anti-patterns. The Spring Boot 3 starter + OMDB autoconfiguration + virtual-thread audit shipped 2026-05-30 (`metaobjects-spring-boot-starter`). **jOOQ migration is a closed non-goal**: jOOQ's OSS edition excludes Oracle/SQL Server/DB2 (commercial license required), which would paywall OMDB's commercial-DB drivers in a public OSS project, and jOOQ generates code *from* a schema — the inverse of MetaObjects' metadata-is-the-spine model.
- [TECHNICAL] Payload `origin.*` resolution in `codegen-spring` (Day-1 deferral — see `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md`). Kotlin's `KotlinPayloadGenerator` is the cross-port reference.
- [TECHNICAL] WARN envelope-shape assertion on cross-port `expected-warnings.json` (closed 2026-05-27 — runners now assert envelope shape on warnings; legacy string-list path retired).
48 changes: 20 additions & 28 deletions agent-context/skills/metaobjects-prompts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,22 @@ LLM tool-call envelope with no renderable text body (the body IS the
`@format` attrs above). The vocabulary exists today; MCP exposure of declared
prompts/tools is roadmap, not shipped — don't promise it.

## The payload is an `object.value` projection

The payload is **not** an entity — it's an `object.value` whose every field carries
an `origin.*` child saying where its value comes from. Three origin subtypes:

| Origin | Behavior |
|---|---|
| `origin.passthrough @from "Entity.field"` | payload property matches the source field |
| `origin.aggregate @agg <count\|sum\|avg\|min\|max>` | `count`→long, `avg`→double, others match source |
| `origin.collection @via "Parent.rel"` | a list of a nested payload, assembled from a relationship |

These are the **payload-assembly** origins — the vocabulary this skill covers.
**Projection** read models (`object.projection` over an entity) carry a fuller origin
vocabulary — the `@agg` predicate quantifiers `any`/`all`, the `collect` array rollup,
plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (an argmax-style
pick) — those live in the `metaobjects-authoring` skill and
`docs/features/source-kinds.md`, not here: don't reach for them on a payload VO.

Declaring the payload as a projection is what makes payload bloat visible: adding a
field to the prompt is a diff on the `object.value`, and a renamed source field
breaks the build instead of silently degrading the prompt.
## The payload is an `object.value` you declare

The payload is **not** an entity — it's an `object.value` whose DECLARED fields ARE
the prompt's typed shape. Every port's payload codegen is
**declared-type-authoritative (#270)**: a field's generated type comes only from its
declared `field.<subType>` + `isArray` + `@objectRef`, and a nested payload is a
declared `field.object @objectRef` to another `object.value` (`isArray: true` for a
list). The caller supplies the field values at render time. An `origin.*` child on a
payload field is IGNORED for typing — never author assembly origins (`aggregate` /
`collection` / `computed` / `first`) on a payload VO. Derivation belongs to
**projection** read models (`object.projection` over an entity), covered by the
`metaobjects-authoring` skill and `docs/features/source-kinds.md`, not here.

Declaring the payload shape is what makes payload bloat visible: adding a field to
the prompt is a diff on the `object.value`, and `verify` catches template/payload
drift at build time instead of letting a prompt silently degrade.

```json
{
Expand All @@ -81,21 +76,18 @@ breaks the build instead of silently degrading the prompt.
"object.value": {
"name": "WelcomePayload",
"children": [
{ "field.string": { "name": "displayName",
"children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } },
{ "field.long": { "name": "postCount",
"children": [ { "origin.aggregate": { "@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } },
{ "field.string": { "name": "displayName" } },
{ "field.long": { "name": "postCount" } },
{ "field.object": { "name": "posts", "@objectRef": "PostSummary",
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
"isArray": true } }
]
}
},
{
"object.value": {
"name": "PostSummary",
"children": [
{ "field.string": { "name": "title",
"children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } }
{ "field.string": { "name": "title" } }
]
}
},
Expand Down
121 changes: 61 additions & 60 deletions docs/features/templates-and-payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ This buys four guarantees:
4. **Cross-language conformance** — a Python eval renders exactly what the Java
production server sends.

The vocabulary is `template.*` (the renderable unit) + `origin.*` (the projection
fields that build the payload VO). Mustache is the chosen template engine — it has
The vocabulary is `template.*` (the renderable unit) over a declared
`object.value` payload shape. Mustache is the chosen template engine — it has
the only published cross-language spec + conformance suite.

## Two template subtypes
Expand All @@ -41,21 +41,23 @@ Both carry the same generic attributes:
| `@owner` | no | Governance attribute |
| `@since` | no | Governance attribute |

## Payload origins
## Payload fields are declared

A payload is an `object.value` view-object whose fields each declare an `origin.*`
child. Three origin subtypes:
A payload is an `object.value` view-object whose fields DECLARE the payload's
shape — a prompt's payload is a typed projection you author, so payload bloat
shows up as a diff. Every port's payload codegen is **declared-type-authoritative
(#270)**: a field's generated type comes only from its declared `field.<subType>`
+ `isArray` + `@objectRef`, and a nested payload is a declared `field.object
@objectRef` to another `object.value` (`isArray: true` for a list). An `origin.*`
child on a payload field is **ignored for typing** — it never changes the
generated type, nullability, or the nested-payload set. The caller supplies the
field values at render time.

| Origin | Behavior |
|---|---|
| `origin.passthrough @from "Entity.field"` | Payload property type matches the source field |
| `origin.aggregate @agg <count\|sum\|avg\|min\|max>` | `count` → `Long`; `avg` → `Double`; others match source field |
| `origin.collection @via "Parent.rel"` | `List<NestedPayload>` — assembled from a relationship |

These are the payload-assembly origins. **Projection** read models (`object.projection` over an
entity) carry a fuller origin vocabulary — the `@agg` quantifiers `any` / `all` and the `collect`
array rollup, plus `origin.computed` (a closed `@expr` grammar) and `origin.first` (#195) — see
[source-kinds.md](source-kinds.md).
Derivation and assembly belong to **projection** read models (`object.projection`
over an entity), which carry the origin vocabulary — `origin.passthrough`,
`origin.aggregate` (incl. the `any` / `all` quantifiers and the `collect` array
rollup), `origin.computed` (a closed `@expr` grammar) and `origin.first` (#195) —
see [source-kinds.md](source-kinds.md).

## Authoring

Expand All @@ -73,22 +75,18 @@ their post count + the first 3 post titles.
"object.value": {
"name": "WelcomePayload",
"children": [
{ "field.string": { "name": "displayName",
"children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } },
{ "field.long": { "name": "postCount",
"children": [ { "origin.aggregate": {
"@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } },
{ "field.string": { "name": "displayName" } },
{ "field.long": { "name": "postCount" } },
{ "field.object": { "name": "posts", "@objectRef": "PostSummary",
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
"isArray": true } }
]
}
},
{
"object.value": {
"name": "PostSummary",
"children": [
{ "field.string": { "name": "title",
"children": [ { "origin.passthrough": { "@from": "Post.title" } } ] } }
{ "field.string": { "name": "title" } }
]
}
},
Expand Down Expand Up @@ -117,28 +115,18 @@ metadata:
children:
- field.string:
name: displayName
children:
- origin.passthrough: { from: Author.name }
- field.long:
name: postCount
children:
- origin.aggregate:
agg: count
of: Post.id
via: Author.posts
- field.object:
name: posts
objectRef: PostSummary
children:
- origin.collection: { via: Author.posts }
isArray: true

- object.value:
name: PostSummary
children:
- field.string:
name: title
children:
- origin.passthrough: { from: Post.title }

- template.prompt:
name: WelcomePrompt
Expand Down Expand Up @@ -214,8 +202,8 @@ const out: string = await render({

`metaobjects-render` ships `Renderer` + `Provider` (Classpath, Filesystem,
InMemory) + `Verify`. `SpringPayloadGenerator` (in `metaobjects-codegen-spring`)
emits a Java 21 `record` payload per template, resolving all three origin
subtypes (matches the Kotlin reference). Host code may also pass a
emits a Java 21 `record` payload per template, typing every component from its
declared field (#270; matches the Kotlin reference). Host code may also pass a
`Map<String,Object>` to the renderer if it doesn't want the generated type.

```java
Expand All @@ -224,7 +212,7 @@ import com.metaobjects.render.*;
Provider provider = new FilesystemProvider(Path.of("./prompts"));
String out = Renderer.render(RenderRequest.builder()
.ref("lobby/welcome")
.payload(new WelcomePayload("Ada", 12L, List.of(new PostSummary("Hello"))))
.payload(new WelcomePromptPayload("Ada", 12L, List.of(new PostSummaryPayload("Hello"))))
.provider(provider)
.format("xml")
.build());
Expand All @@ -235,17 +223,18 @@ String out = Renderer.render(RenderRequest.builder()
public record WelcomePromptPayload(
String displayName,
Long postCount,
java.util.List<PostSummary> posts
java.util.List<PostSummaryPayload> posts
) {}

public record PostSummary(String title) {}
// generated/acme/blog/prompts/PostSummaryPayload.java
public record PostSummaryPayload(String title) {}
```

### Kotlin

`metaobjects-metadata-ktx` wraps `Renderer` in an idiomatic Kotlin builder.
`KotlinPayloadGenerator` (in `codegen-kotlin`) emits a `@Serializable` payload data
class per template, resolving all three origin subtypes.
class per template, typing every property from its declared field (#270).

```kotlin
import com.metaobjects.metadata.ktx.render
Expand All @@ -254,55 +243,66 @@ import java.nio.file.Path

val out = render {
ref = "lobby/welcome"
payload = WelcomePayload(
payload = WelcomePromptPayload(
displayName = "Ada",
postCount = 12,
posts = listOf(PostSummary("Hello")),
posts = listOf(PostSummaryPayload("Hello")),
)
provider = FilesystemProvider(Path.of("./prompts"))
format = "xml"
}
```

```kotlin
// generated/acme/blog/WelcomePromptPayload.kt
// generated/acme/blog/prompts/WelcomePromptPayload.kt
@Serializable
data class WelcomePayload(
data class WelcomePromptPayload(
val displayName: String,
val postCount: Long,
val posts: List<PostSummary>,
val posts: List<PostSummaryPayload>,
)

// generated/acme/blog/prompts/PostSummaryPayload.kt
@Serializable
data class PostSummary(val title: String)
data class PostSummaryPayload(val title: String)
```

### C#

`MetaObjects.Render` ships the render engine + verify. `MetaObjects.Codegen`
ships payload-VO codegen.
ships payload-VO codegen for **`template.output`** parse targets — the strict
record is named after the **value object**, not the template (for this model:
`record WelcomePayload` / `record PostSummary`), with `required` init-only
properties named verbatim after the metadata fields (`displayName`, `postCount`,
`posts`). Nothing is emitted for a `template.prompt`; its render payload is a
plain object/array graph you supply:

```csharp
using MetaObjects.Render;

var provider = new FilesystemProvider("./prompts");
var payload = new WelcomePayload(
DisplayName: "Ada",
PostCount: 12,
Posts: new[] { new PostSummary("Hello") });

string output = Renderer.Render(new RenderRequest(
Ref: "lobby/welcome",
Payload: payload,
Provider: provider,
Format: "xml"));
var payload = new Dictionary<string, object?>
{
["displayName"] = "Ada",
["postCount"] = 12,
["posts"] = new[] { new Dictionary<string, object?> { ["title"] = "Hello" } },
};

string output = Renderer.Render(new RenderRequest
{
Ref = "lobby/welcome",
Payload = payload,
Provider = provider,
Format = "xml",
});
```

### Python

`metaobjects.render` ships the Mustache engine + `Verify`. The Python loader
recognizes `template.*` + `origin.*`. Payload-VO codegen **is** emitted (the
`payload` generator emits a Pydantic `BaseModel` per template, origin-aware — see
`payload` generator emits a Pydantic `BaseModel` per template, typed from the
declared fields (#270) — see
[Output parsing (FR-006)](#output-parsing-fr-006)), so a consumer can render from
the generated payload type or from a plain `dict`.

Expand Down Expand Up @@ -491,13 +491,14 @@ The following conformance fixtures gate this feature's behavior across ports:
- [`fixtures/conformance/error-template-prompt-missing-payload-ref/`](../../fixtures/conformance/error-template-prompt-missing-payload-ref/) — `template.prompt` requires `@payloadRef`
- [`fixtures/conformance/error-template-required-slot-missing/`](../../fixtures/conformance/error-template-required-slot-missing/) — required slot declarations are checked

**Payload origins (`origin.*`)**
**Origins (`origin.*`) — loader vocabulary** (declares derivation lineage; ignored
for payload typing per #270)

- [`fixtures/conformance/origin-passthrough-simple/`](../../fixtures/conformance/origin-passthrough-simple/) — `origin.passthrough` cross-entity field reference
- [`fixtures/conformance/origin-aggregate-count/`](../../fixtures/conformance/origin-aggregate-count/) — `origin.aggregate @agg=count`
- [`fixtures/conformance/origin-aggregate-sum/`](../../fixtures/conformance/origin-aggregate-sum/) — `origin.aggregate @agg=sum`
- [`fixtures/conformance/origin-multi-level-via/`](../../fixtures/conformance/origin-multi-level-via/) — dotted-path `@via` traversal across hops
- [`fixtures/conformance/origin-collection-simple/`](../../fixtures/conformance/origin-collection-simple/) — `origin.collection` for repeated-row payloads
- [`fixtures/conformance/origin-collection-simple/`](../../fixtures/conformance/origin-collection-simple/) — `origin.collection` loads on a repeated-row shape
- [`fixtures/conformance/error-origin-bad-via-path/`](../../fixtures/conformance/error-origin-bad-via-path/) — unresolvable `@via` rejected
- [`fixtures/conformance/error-origin-bad-aggregate-fn/`](../../fixtures/conformance/error-origin-bad-aggregate-fn/) — unknown `@agg` rejected
- [`fixtures/conformance/error-origin-passthrough-type-mismatch/`](../../fixtures/conformance/error-origin-passthrough-type-mismatch/) — a `passthrough` field whose `field.<subType>` differs from its `@from` source fails with `ERR_PASSTHROUGH_TYPE_MISMATCH`
Expand Down
12 changes: 7 additions & 5 deletions docs/ports/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,10 @@ Two generators ship together for the full prompt+parse story:

- `payload_vo_generator` emits one `<template_name>_payload.py` per declared
`template.*` (prompt / output / toolcall) — a Pydantic v2 `<TemplateName>Payload`
`BaseModel` resolving all three origin subtypes (`passthrough` / `aggregate` /
`collection`). Mirrors the Kotlin reference shape.
`BaseModel` typed from the DECLARED fields only (#270 — any `origin.*` child a
payload field carries is ignored for typing; a nested payload is a declared
`field.object @objectRef` to another `object.value`). Mirrors the Kotlin
reference shape.
- `output_parser_generator` emits one `<template_name>_output_parser.py` per
`template.output`, importing the payload class from the sibling payload module.

Expand Down Expand Up @@ -326,8 +328,8 @@ design is at [ADR-0010](../../spec/decisions/ADR-0010-template-output-parser-cod
the feature reference is at
[`features/templates-and-payloads.md`](../features/templates-and-payloads.md#output-parsing-fr-006).

**Per-file dedupe note.** When `origin.collection` references the same nested
target across two templates, each template's payload file contains its own
**Per-file dedupe note.** When two templates' payloads reference the same nested
`field.object @objectRef` target, each template's payload file contains its own
copy of the nested class (per-file, not per-run dedupe). This differs from
Kotlin's cross-run dedupe (KotlinPoet → one class per `.kt` file). The Python
choice keeps each generated payload module self-contained — see the
Expand All @@ -351,7 +353,7 @@ import lines are stable.
| Source kinds (table / view / storedProc) | Loader-level yes; codegen for non-`table` kinds is in progress |
| `field.currency` / `field.enum` / `field.object` + `@storage` | Loader-level yes; codegen for `field.object` `flattened` storage is in progress |
| Templates + render (FR-004) | Yes (`metaobjects.render`) |
| Payload-VO codegen | Yes (`payload_vo_generator` — Pydantic v2 `BaseModel` per template, origin-aware) |
| Payload-VO codegen | Yes (`payload_vo_generator` — Pydantic v2 `BaseModel` per template, declared-type-authoritative per #270) |
| Output parser codegen (FR-006) | Yes (`output_parser_generator` — Pydantic throw-only; imports the payload class from the sibling payload module) |
| Declarative template-codegen | Yes — `metaobjects gen --template-spec` (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with C#) |
| Migrations | TS-only by design (ADR-0015) — no Python `migrate` command; consume the canonical `schema.postgres.sql` |
Expand Down
Loading
Loading