Skip to content

Commit 2f27062

Browse files
authored
Merge pull request #318 from metaobjectsdev/adr-0052-template-direction
feat(metamodel)!: ADR-0052 — a template subtype's axis is DIRECTION (all 5 ports)
2 parents 3427d38 + 3b0f484 commit 2f27062

188 files changed

Lines changed: 7944 additions & 2822 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,85 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### BREAKING — a template subtype's axis is DIRECTION (ADR-0052 / ADR-0053)
11+
12+
`template.output` renders OUTBOUND — a document, an email, an export — and generates
13+
**nothing that reads a model's reply**. The inbound half (the response record, the FR-010
14+
response-format fragment, the parser-on-receipt and the tolerant extractor) belongs to a
15+
`template.prompt` carrying **`@responseRef`**, and the gate is that attribute's PRESENCE,
16+
never a format value. All five ports; the rule lives in one predicate per port
17+
(`FindInbound`) that every inbound generator and every api-docs builder calls through.
18+
19+
**What was wrong.** The old tier had drifted three ways at once, and each way produced
20+
generated code that could not work:
21+
22+
- The **parser** applied NO format filter. An `@format: markdown` document template got a
23+
generated `Schema.parse(JSON.parse(text))` over rendered prose — and this repository
24+
shipped one, in `examples/advanced-modeling`.
25+
- The **fragment emitter** and the **extractor** each applied their own
26+
`@format ∈ {json,xml}` gate — against the OUTBOUND body's syntax, which says nothing
27+
about the reply. A text-bodied prompt asking for a JSON answer, the common case, got a
28+
strict parser and no tolerant extract, and no fragment at all.
29+
- Nothing generated the inbound tier for a `template.prompt`, even though `@responseRef`
30+
has been prompt-only vocabulary since it was introduced.
31+
32+
**ADR-0053 supplies the missing fact:** `@responseFormat` (`json` | `xml`, default `json`)
33+
is the syntax of the REPLY, distinct from `@format`, the syntax of the rendered prompt
34+
BODY. The default reproduces the pre-ADR fallback exactly, so a model that never declares
35+
it keeps its behaviour. The **strict tier is JSON-only**: an XML reply gets the tolerant
36+
extract and nothing strict, because strict all-or-nothing semantics layered over a
37+
REPAIRING parser would raise or accept based on how much repair happened.
38+
39+
**Breaking, and each fails loudly:**
40+
41+
- A `@promptStyle` left on a `template.output` now fails the LOAD
42+
(`ERR_INVALID_TEMPLATE`) — it is prompt-only vocabulary, as is `@responseFormat`.
43+
- A `template.output`'s parser / extractor / fragment files are no longer emitted;
44+
`verify --codegen` names the committed ones as files a fresh regen would not emit.
45+
- Emitted paths follow the direction: `.output.*``.response.*`, `.prompt.*`
46+
`.responseFormat.*`, and (Python) `_output_parser.py``_response_parser.py`,
47+
`_output_prompt.py``_response_format.py`.
48+
- **`@responseRef` now obeys the same target rule as `@payloadRef` in every port.** Only
49+
TypeScript validated it; C#, Java and Python checked `@payloadRef` and never
50+
`@responseRef`, so the same metadata failed one port's load and passed four — and in C#
51+
the consequence was a parser returning a record nobody emitted (CS0246).
52+
53+
Migration: [`docs/features/migrations/template-direction-outbound-vs-inbound.md`](docs/features/migrations/template-direction-outbound-vs-inbound.md).
54+
55+
**The response RECORD differs by port, because the ports do not share a naming
56+
convention.** C# names records after the resolved VALUE OBJECT, so the response record
57+
simply IS that VO's record. Java, Kotlin and Python name them after the TEMPLATE, so a
58+
responding prompt gets a SECOND record, `<Prompt>Response`, beside `<Prompt>Payload`;
59+
Python puts it in its own `<prompt>_response.py`, because the request payload emits
60+
`extra="forbid"` (a mistyped render slot must fail at construction) while a reply record
61+
must tolerate unknown fields, and a value-object reachable from both closures could carry
62+
only one setting. TypeScript needs no new record — its payload types come from
63+
`entityFile()`, which emits per `object.value` regardless of any template.
64+
65+
**Also fixed, found while doing it:**
66+
67+
- **The trace helper was a fifth inbound consumer nobody had listed.** TypeScript and
68+
Python derived a REPLY's parse format from `@format`; Java called the 2-arg
69+
`MetaObjectExtractor.extract` overload, which hardcodes `Format.JSON`, so an XML reply
70+
was inexpressible there rather than merely mis-read. All three now read
71+
`@responseFormat`.
72+
- **A `template.prompt` got no model doc page.** The api-docs surface has always emitted
73+
`api/<lang>/<pkg>/<Prompt>.md` for a top-level prompt, and that page carries a
74+
"Model / metadata" back-link — but `meta docs` wrote the neutral page for
75+
`template.output` alone, so the link pointed at a page nothing generated, in every doc
76+
tree containing a prompt.
77+
- **A prompt's `@payloadRef` record was generated and documented nowhere** (C#, then
78+
Python): api-docs walked `template.output` only.
79+
80+
**The durable lesson is about the corpus, not the code.** `api-docs-cross-port` had
81+
exactly one template, and one `@promptStyle` on it was the whole reason it exercised the
82+
PROMPT and OUTPUT_PARSER paths in every port's api-docs builder. Removing that attribute —
83+
required, since it is prompt-only now — silently deleted the last inbound coverage in the
84+
corpus, and **all five ports stayed green**: a corpus that stops exercising a code path
85+
emits no diagnostic, only assertions that quietly cover less. The corpus now carries a
86+
README naming which case covers which path, so an edit that removes one has to remove its
87+
stated purpose too.
88+
1089
## [0.24.0] — npm `0.24.0` · PyPI `0.24.0` · NuGet `0.24.0` · Maven `7.24.0`
1190

1291
A coordinated **MINOR** across all four registries. It is a MINOR rather than a PATCH
@@ -673,8 +752,6 @@ ADR-0015 makes for schema migrations. Refusing warns rather than failing the rea
673752
because failing a Maven build over a file the user chose to own would punish exactly the
674753
person the guard protects.
675754

676-
||||||| constructed merge base
677-
678755
### Added — Python port serves the shipped `ai` library (loader `libraries=[...]`)
679756

680757
**No new vocabulary — this is port parity.** No type, subtype, or attribute is added, so

agent-context/skills/metaobjects-prompts/SKILL.md

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ language lives in a reference fragment (pointed to at the bottom).
1919
A **template** is a typed pair: a logical reference to external text + a payload
2020
value-object declaring exactly what data the text expects.
2121

22-
| Subtype | Use | Extra attrs |
23-
|---|---|---|
24-
| `template.prompt` | LLM-targeted | `@maxTokens`, `@requiredSlots`, `@requiredTags`, `@model`, `@responseRef` |
25-
| `template.output` | email / docs / config / export | `@kind: document \| email` (default `document`), `@promptStyle`, `@requiredTags`; `@kind: email` adds `@subjectRef` / `@htmlBodyRef` / `@textBodyRef` |
22+
A template subtype's axis is **DIRECTION** — which way the text travels, not what it
23+
is about (ADR-0052).
24+
25+
| Subtype | Direction | Use | Extra attrs |
26+
|---|---|---|---|
27+
| `template.prompt` | outbound, and optionally inbound | LLM-targeted | `@maxTokens`, `@requiredSlots`, `@requiredTags`, `@model`, `@responseRef`, `@responseFormat`, `@promptStyle` |
28+
| `template.output` | outbound ONLY | email / docs / config / export | `@kind: document \| email` (default `document`), `@requiredTags`; `@kind: email` adds `@subjectRef` / `@htmlBodyRef` / `@textBodyRef` |
2629

2730
Both carry the generic attrs:
2831

@@ -36,13 +39,22 @@ Both carry the generic attrs:
3639
`template.output @kind: email` renders a structured `EmailDocument` (subject + HTML
3740
body + optional plain-text body) instead of one string — the TS render helper emits
3841
an `EmailDocument`-returning function for it (see the `render-example-email`
39-
conformance fixture). `@promptStyle` (`guide` / `inline` / `exampleOnly`, FR-010)
40-
selects how the output-format prompt fragment presents the payload shape to an LLM
41-
(see "the output-format prompt fragment" below); `@requiredTags` names output tags
42-
the rendered text must contain (`verify` checks it) on both subtypes.
43-
`template.prompt` additionally carries `@responseRef` — naming the response
44-
shape (an `object.value` or sourceless `object.projection`, #210) the prompt
45-
expects, for typed LLM-call trace derivation.
42+
conformance fixture). `@requiredTags` names output tags the rendered text must contain
43+
(`verify` checks it) on both subtypes.
44+
45+
**The INBOUND half belongs to `template.prompt` alone.** `@responseRef` names the
46+
response shape (an `object.value` or sourceless `object.projection`, #210) a model's
47+
reply is parsed into, and its PRESENCE is what asks for the whole inbound tier: the
48+
response record, the response-format fragment, the parser-on-receipt and the tolerant
49+
extractor. `@responseFormat` (`json` default / `xml`, ADR-0053) is the syntax of that
50+
REPLY; `@promptStyle` (`guide` / `inline` / `exampleOnly`, FR-010) selects how the
51+
fragment presents the shape.
52+
53+
> **`@format` and `@responseFormat` are different facts.** `@format` is the syntax of
54+
> the BODY you render; `@responseFormat` is the syntax of the answer you expect. A
55+
> plain-text prompt asking for a JSON object is the common case. Putting `@promptStyle`
56+
> or `@responseFormat` on a `template.output` is a LOAD ERROR — an output renders a
57+
> document and nothing reads a reply to it.
4658
4759
A third, structurally different subtype is also registered core vocabulary:
4860
**`template.toolcall`** (`@toolName` + `@payloadRef`, ADR-0011) — a vendor-agnostic
@@ -192,33 +204,42 @@ For every template, the verify step resolves the text, parses each `{{...}}`
192204
reference, and checks it exists on the payload VO. If the text references
193205
`{{authorName}}` but the payload only has `displayName`, **the build fails.** This
194206
is the prompt-vs-payload drift gate — run it in CI. It walks both `template.prompt`
195-
and `template.output` nodes the same way.
207+
and `template.output` nodes the same way (both RENDER; only the direction of what comes
208+
back differs).
209+
210+
## A RESPONDING `template.prompt` generates a parser-on-receipt
211+
212+
For every `template.prompt` declaring `@responseRef`, codegen emits a **typed parser**
213+
that turns a model's reply back into that shape. It binds `@responseRef`, never
214+
`@payloadRef``@payloadRef` types the request the prompt renders outbound, and the
215+
question and the answer are usually different shapes. Each port emits the parser
216+
idiomatically: a throw-on-invalid parse plus, where the language has the precedent, a
217+
Result-style "safe" variant that doesn't throw.
196218

197-
## `template.output` also generates a parser-on-receipt
219+
**A `template.output` gets no parser, ever.** Nothing reads a reply to a document. (Before
220+
ADR-0052 it did, with no format filter at all — so an `@format: markdown` document got a
221+
generated `JSON.parse` over rendered prose.)
198222

199-
For every `template.output`, codegen emits a **typed parser** that turns an LLM/raw
200-
response back into the `@payloadRef` value-object — the reverse direction, reusing
201-
the same payload VO (no new authoring). Each port emits it idiomatically: a
202-
throw-on-invalid parse plus, where the language has the precedent, a Result-style
203-
"safe" variant that doesn't throw. The parser file is a companion to the payload-VO
204-
file; `verify` catches payload-VO ↔ parser drift at build time too.
223+
The strict tier is JSON-only: an `@responseFormat: xml` reply gets the tolerant extract
224+
and nothing strict, because strict all-or-nothing semantics layered over a REPAIRING XML
225+
reader would raise or accept based on how much repair happened.
205226

206227
The three-step consumer pattern is identical everywhere: render the prompt → call
207-
your LLM client → parse the response with the generated parser.
228+
your LLM client → parse the reply with the generated parser.
208229

209-
## `template.output` also generates the output-format prompt fragment (FR-010)
230+
## A RESPONDING `template.prompt` generates the response-format fragment (FR-010)
210231

211-
For every **json/xml-format** `template.output` whose `@payloadRef` resolves to a
212-
value-object, codegen additionally emits an `output-prompt` artifact: a
213-
`render<Name>Format(...)`-shaped function backed by the render engine's
214-
output-format renderer — the "produce your answer like this" instruction fragment
215-
you splice into the prompt text so the model returns exactly the shape the parser
216-
above expects. It's generated only for `json`/`xml` outputs (`text`/`html`/`csv`/
217-
`markdown`/`spreadsheet` don't get a fragment) and skipped under the same
218-
unresolved-`@payloadRef` rule as the parser; the fragment and the parser's
219-
`extract()` codegen agree on the same root name.
232+
For every `template.prompt` whose `@responseRef` resolves, codegen additionally emits a
233+
`render<Name>Format(...)`-shaped function backed by the render engine's output-format
234+
renderer — the "produce your answer like this" instruction fragment you splice into the
235+
prompt text so the model returns exactly the shape the parser above expects. The gate is
236+
`@responseRef` PRESENCE, not a format value: the old `@format ∈ {json,xml}` gate read the
237+
syntax of the OUTBOUND body to decide whether to instruct the model about the syntax of
238+
its REPLY, so a text-bodied prompt asking for a JSON answer got no fragment at all.
239+
`@responseFormat` selects which syntax the fragment teaches; the fragment and the
240+
extractor agree on the same root name.
220241

221-
`@promptStyle` on the `template.output` controls the fragment's presentation
242+
`@promptStyle` on the `template.prompt` controls the fragment's presentation
222243
(default `guide`):
223244

224245
| `@promptStyle` | Presentation |

agent-context/skills/metaobjects-prompts/references/csharp.md

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
# C# parser-on-receipt
22

3-
For every `template.output`, `MetaObjects.Codegen`'s `OutputParserGenerator` emits a
4-
**typed parser** that validates an LLM/raw response against the template's
5-
`@payloadRef` payload record. This is the receive side only — codegen emits **no**
6-
provider/LLM-call layer; you compose the call yourself. The payload record comes from
7-
the payload generator, so the parser and the payload VO can't silently drift.
3+
For every RESPONDING `template.prompt` — one declaring `@responseRef`
4+
`MetaObjects.Codegen`'s `OutputParserGenerator` emits a **typed parser** that validates
5+
a model's reply against that shape. ADR-0052: the tier binds `@responseRef`, never
6+
`@payloadRef` (which types the request the prompt renders outbound), and a
7+
`template.output` gets no parser at all. This is the receive side only — codegen emits
8+
**no** provider/LLM-call layer; you compose the call yourself. C# names records after the
9+
resolved VALUE OBJECT, so the response record simply IS that VO's record — no second
10+
naming convention, and the parser and the record can't silently drift.
811

912
## Contents
1013
- Wire the generator
1114
- What it emits
12-
- The output-format prompt fragment (FR-010)
15+
- The response-format prompt fragment (FR-010)
1316
- The three-step consumer pattern
1417
- Recommended LLM caller (bring-your-own)
1518
- Consumer dependency
@@ -27,12 +30,14 @@ dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog
2730

2831
## What it emits
2932

30-
Per `template.output`, `dotnet meta gen` writes one `<TemplateName>.output.cs` with a
31-
static `<TemplateName>Parser` following the .NET BCL `Parse`/`TryParse` dual API —
33+
Per responding `template.prompt`, `dotnet meta gen` writes one
34+
`<PromptName>.response.cs` with a static `<PromptName>Parser` following the .NET BCL
35+
`Parse`/`TryParse` dual API. The strict tier is JSON-only — an `@responseFormat: xml`
36+
reply gets the tolerant extract and neither `Parse` nor `TryParse`
3237
`Parse` throws, `TryParse` returns a bool plus an out-error:
3338

3439
```csharp
35-
// generated <TemplateName>.output.cs (shape)
40+
// generated <PromptName>.response.cs (shape)
3641
public static class NpcResponseParser
3742
{
3843
/// <exception cref="JsonException">malformed JSON or schema mismatch.</exception>
@@ -53,25 +58,25 @@ generator also emits a tolerant `Extract(string[, ExtractOptions])` (self-contai
5358
components) returning an `ExtractionResult` with a nullable `<Payload>Extracted` mirror
5459
— a classified per-field report rather than a throw.
5560

56-
## The output-format prompt fragment (FR-010)
61+
## The response-format prompt fragment (FR-010)
5762

58-
For every json/xml-format `template.output`, `MetaObjects.Codegen`'s
59-
`OutputPromptGenerator` (stable name `output-prompt-generator`) emits a
60-
`<TemplateName>.prompt.cs` declaring a static `<TemplateName>Prompt` class with a
61-
`RenderFormat()` / `RenderFormat(PromptOverrides)` pair, backed by the render
62-
engine's `OutputFormatRenderer` — the "produce your answer like this" fragment for
63-
the model. It runs as part of the same `dotnet meta gen` invocation as the payload
63+
For every responding `template.prompt`, `MetaObjects.Codegen`'s `OutputPromptGenerator`
64+
(stable name `output-prompt-generator`) emits a `<PromptName>.responseFormat.cs`
65+
declaring a static `<PromptName>ResponseFormat` class with a `RenderFormat()` /
66+
`RenderFormat(PromptOverrides)` pair, backed by the render engine's
67+
`OutputFormatRenderer` — the "produce your answer like this" fragment for the model. It runs as part of the same `dotnet meta gen` invocation as the payload
6468
and parser generators:
6569

6670
```bash
6771
dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog
6872
```
6973

70-
`@promptStyle` on the `template.output` (`guide` default / `inline` / `exampleOnly`)
71-
controls the fragment's presentation; guidance is never emitted as comments. Skipped
72-
for `template.prompt` nodes, non-json/xml `@format`, and an unresolved
73-
`@payloadRef` — the same skip contract as the parser generator. The baked spec's
74-
root name is the payload class name, agreeing with the parser's root.
74+
`@promptStyle` on the `template.prompt` (`guide` default / `inline` / `exampleOnly`)
75+
controls the fragment's presentation; guidance is never emitted as comments. Skipped for
76+
`template.output` nodes and an unresolved `@responseRef` — the same skip contract as the
77+
parser generator. There is NO format gate: the old `@format ∈ {json,xml}` test read the
78+
syntax of the outbound body to decide whether to describe the reply. The baked spec's
79+
root name is the response record's, agreeing with the parser's root.
7580

7681
## The three-step consumer pattern
7782

0 commit comments

Comments
 (0)