Skip to content

feat(metamodel)!: ADR-0052 — a template subtype's axis is DIRECTION (all 5 ports) - #318

Merged
dmealing merged 30 commits into
mainfrom
adr-0052-template-direction
Aug 20, 2026
Merged

feat(metamodel)!: ADR-0052 — a template subtype's axis is DIRECTION (all 5 ports)#318
dmealing merged 30 commits into
mainfrom
adr-0052-template-direction

Conversation

@dmealing

@dmealing dmealing commented Aug 20, 2026

Copy link
Copy Markdown
Member

What this is

ADR-0052 makes a template subtype's axis DIRECTION. template.output renders OUTBOUND
and generates nothing that reads a model's reply; the inbound half — the response shape, the
FR-010 response-format fragment, and the parser-on-receipt — moves to a template.prompt
carrying @responseRef. ADR-0053 adds @responseFormat (json|xml, default json) as the
syntax of the REPLY, distinct from @format, which is the syntax of the rendered prompt BODY.

Breaking: a @promptStyle left on a template.output now fails the load.

Status — all five ports, green

Port Suite
TypeScript 6466 pass / 38 skip / 0 fail (full server suite); workspace build + typecheck clean
C# 905 + 363 (1 skip) + 291 + 46, 0 fail
Java + Kotlin full reactor BUILD SUCCESS (Kotlin 316)
Python 1751 pass / 0 fail (was 1733 + 6 fail)

scripts/ci-local.sh --quick green.

The rule lives in one place, per port

FindInbound (TS / C# / Java / Kotlin / Python) is the ADR-0052 direction rule, and every
inbound generator plus each api-docs builder calls through it. The gate is @responseRef
PRESENCE
, never a format value.

The old tier had drifted three ways at once, and each way produced generated code that could
not work: the parser applied no format filter (so an @format: markdown document got a
generated JSON.parse over rendered prose — and this repo shipped one, in
examples/advanced-modeling), while the fragment emitter and the extractor each applied their
own @format ∈ {json,xml} gate — against the OUTBOUND body's syntax, which says nothing about
the reply. A text-bodied prompt asking for a JSON answer, the common case, got a strict parser
and no tolerant extract.

Naming (D4)

Java, Kotlin and Python name records after the TEMPLATE, so a responding prompt needs a second
record: <Short>Response, beside the existing <Short>Payload. C# diverges correctly —
its records are VO-named, so there the response record simply is the VO's record. TypeScript
needs no new record; its payload types come from entityFile().

Python puts its response record in its own module, <template>_response.py, rather than
joining the payload module. Strictness is per-module there: a prompt's REQUEST payload emits
extra="forbid" so a mistyped render slot fails at construction, while a reply record must
tolerate unknown fields — and a value-object reachable from both closures could carry only one
setting. Every existing model's <template>_payload.py stays byte-identical.

The fragment class is <Short>ResponseFormat; emitted paths follow the direction everywhere
(.output.*.response.*, .prompt.*.responseFormat.*).

Five inbound consumers, not three

The plan named three generators. There were five — and the fifth, the trace helper, was
wrong in two different ways:

  • TypeScript and Python read @format — the syntax of the prompt BODY — to decide how to
    parse the model's ANSWER.
  • Java never read @format at all (the plan's premise about it was wrong). It called the
    2-arg MetaObjectExtractor.extract(mo, text) overload, which hardcodes Format.JSON, so an
    XML reply was inexpressible there rather than merely mis-read.

All three now read @responseFormat. The Java gate's three new cases are discriminating by
construction — @format: text + @responseFormat: xml, its mirror, and the absent-default —
so no single attribute read satisfies them all; restoring the old call turns 4 of 7 red.

Two defects found by challenging this branch's own work

A @responseRef naming an object.entity produced C# that could not compile. The
generator resolved it with the any-object resolver while the payload tier resolves value-only,
so the parser emitted public static Answer Parse(string text) and no such record was emitted
— CS0246. Root cause underneath it: only TypeScript validated @responseRef's target. C#,
Java and Python checked @payloadRef and never @responseRef, so the same metadata failed one
port's load and passed four. Fixed in all ports, gated by a new shared fixture.

A prompt's @payloadRef got no record in C# while the other four ports emitted one. The
test asserting that was mine, written on the reasoning that nothing generated binds it — false:
the binding is hand-written by the adopter. Assertion inverted, not deleted.

The corpus lesson

fixtures/conformance/api-docs-cross-port had exactly one template, and one @promptStyle on
it was the whole reason it exercised the PROMPT and OUTPUT_PARSER paths in every port's
api-docs builder. Removing that attribute — required, since it is prompt-only now — silently
deleted the last inbound coverage in the corpus, and all five ports stayed green. A corpus
that stops exercising a code path emits no diagnostic at all: there is no assertion whose
subject has gone missing, only assertions that quietly cover less.

When a shared corpus loses a case's coverage, no test fails. The corpus now carries a
README naming which case covers which path, so an edit that removes one has to remove its
stated purpose too. It gained OrderAdvice, a responding prompt with @format: text +
@responseFormat: json and disjoint request/reply shapes; OrderSummary stays as the outbound
control. The byte-gated manifest was regenerated from the TS oracle in one pass and all five
runners re-run.

Adding it surfaced a broken link that predates ADR-0052: api-docs has always emitted
api/<lang>/<pkg>/<Prompt>.md for a top-level template.prompt, and that page carries a
"Model / metadata" back-link — but meta docs wrote the neutral page for template.output
alone, so the link pointed at a page nothing generated, in every doc tree containing a prompt.

verify needed no change — verified, not assumed

The plan listed verify in three ports as remaining work. Reading all three shows they are
already direction-correct: each keys its render-drift check on @payloadRef, which ADR-0052
did not move, and picks body refs per subtype. A @responseRef shape is parsed rather than
rendered, so there is no render drift to check, and its target rule is enforced one layer
earlier by the loader.

Verification method

Every behavioural claim here was checked by removing the behaviour and confirming the
corresponding test goes red, then restoring it
— the three C# gates, the C# fail-closed
resolver, each port's loader rule, and in this session: binding @payloadRef instead of
@responseRef (3 Python parser tests red, including the discriminating round-trip, where the
request shape is a valid document the parser must REFUSE), forcing the strict tier on (the XML
case red), reading @format in either trace helper (all 3 / 4 of 7 red), and documenting
RENDER for a prompt (the api-docs forward gate red).

Every fixture touched here declares a request shape and a reply shape that share no field name,
so binding the wrong ref fails rather than passing by coincidence.

Documentation

Migration guide at
docs/features/migrations/template-direction-outbound-vs-inbound.md,
plus templates-and-payloads.md, all five port docs, the metaobjects-prompts skill and its
five per-port references (with the five byte-gated agent-context fixture sets regenerated), and
the CHANGELOG. A shipped skill teaching illegal metadata is the worst version of this drift: it
is read by an agent that will then author it.

Release slot

ADR-0035 §1 makes a post-1.0 metamodel-vocabulary break a 2.0 event, so a 1.1 MINOR could
never have carried FR-037's @readOnly retirement or FR-038's @verifiedBy retirement — the
roadmap cells that said so were wrong and are fixed. The roadmap now names what the next
breaking MINOR carries, including that @responseFormat is ADR-0053, so counting the batch
from ADR-0052 alone under-counts it by one.

Still open, and not a code question: whether that breaking MINOR goes before GA or after.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15

dmealing and others added 25 commits August 19, 2026 06:55
…te.prompt

Closes the open question ADR-0052 deferred. The deferral assumed the question
was separable; it is not. Every inbound emitter in all five ports dispatches on
one bit (is the reply JSON or XML) and reads it from `template.output @format`.
Move the inbound tier and that bit has no source.

`template.prompt @format` cannot supply it — it is already the *prompt body's*
syntax, and the two genuinely differ: the docs-site fixture renders a `text`
prompt whose reply is XML (`NpcResponse.reason` carries `@xmlText: true`).
`trace-helper-file.ts:116-143` already reads one `@format` twice, under a
comment naming the collision: "Same @Format attr, two intentionally different
shapes." And because `@format` defaults to `text`, gating the inbound tier on
it would emit no parser at all, silently — the mirror of the absurd artifact
ADR-0052 removes.

Decision: `@responseFormat` on `template.prompt` — optional, closed enum
`json | xml`, default `json`. The enum is two members because two is what every
shipping consumer dispatches on; the other five `@format` members are
reserved-not-registered under ADR-0007 Amd 2's re-entry bar. The default
reproduces the trace helper's existing fallback exactly, so the change is
behaviour-preserving for every JSON responseRef carrier.

Also records the implementation plan, and flags that the ADR index stopped
being maintained after ADR-0030 — 0031-0051 are on disk but unlisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…eFormat

ADR-0052 makes a template subtype's axis DIRECTION. This is the vocabulary half,
landed in all five ports at once because expected-registry.json is byte-matched
by every one of them.

  template.prompt   gains @PromptStyle (moved) + @responseFormat (new, ADR-0053)
  template.output   keeps @kind + the email part-refs; OUTBOUND ONLY

@PromptStyle governs a fragment that instructs an LLM how to format its reply,
so its old home was the subtype whose own registered description reads "every
rendered artifact other than an LLM prompt" — a contradiction visible inside a
single attribute. @responseFormat is the syntax of the REPLY, distinct from
@Format, which is the syntax of the rendered prompt BODY; ADR-0053 records why
one attribute cannot serve both directions.

Closed enum json|xml, default json. Two members because two is what every
shipping consumer dispatches on; the other five @Format members are
reserved-not-registered under ADR-0007 Amd 2's re-entry bar. The default
reproduces the trace helper's existing fallback exactly, so every JSON
@responseRef carrier is unaffected.

BREAKING: a @PromptStyle left on a template.output now fails the load.
That is deliberate — Python's wrong-subtype pass and every port's strict-mode
ERR_UNKNOWN_ATTR both catch it, so the migration surfaces instead of being
silently ignored. Under a non-strict load (the downstream open-attr policy) it
is dropped, which is the documented behaviour for any unregistered attr.

The two inbound conformance fixtures are renamed to stop describing themselves
as outputs, and now declare @Format: text with @responseFormat: json/xml — the
shape the old single-@Format design could not express, so the corpus itself
discriminates the case. flattened-kitchen-sink gains a response VO for the same
reason.

Downstream tiers (parser/extractor/fragment codegen, api-docs, verify) still
key on template.output and are re-pointed in the commits that follow.

Verified: TS metadata 2404 pass · C# 903+354+291+46 pass · Java metadata 1404
pass, BUILD SUCCESS · Python loader+wrong-subtype 23 pass · 10/10 gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
ADR-0052 in the reference port. The parser-on-receipt, the tolerant extract and
the FR-010 response-format fragment now derive from a `template.prompt` that
declares `@responseRef`; `template.output` renders outbound and emits none of
them.

The direction rule lives in ONE new module, templates/find-inbound.ts. Three
call sites each deciding for themselves is how the old tier drifted: the parser
had no `@kind` filter at all, so an email template generated a parser for text
the system had just rendered, while the extractor and the fragment emitter each
applied a different json/xml gate on `@format`.

Three behaviour changes fall out:

  - The shape parsed INTO is @responseRef, not @payloadRef. That is the
    distinction trace-helper-file.ts has always drawn ("@responseRef types the
    result; @payloadRef types the request") and this tier ignored, so a model
    using one template for both directions could not tell them apart.
  - The reply syntax is @responseFormat (ADR-0053), not @Format. The fragment
    emitter was typing "produce your answer like this" off the format of the
    QUESTION.
  - The tolerant extract is unconditional. The old json/xml gate on @Format is
    what made a text-bodied prompt with a JSON reply emit a strict parser and no
    extract at all.

Emitted names follow the direction axis: <Prompt>.response.ts (was
<Output>.output.ts) and <Prompt>.responseFormat.ts (was <Output>.prompt.ts).
Leaving a parser named .output.ts generated from a prompt would reproduce the
confusion being removed, and ClassifyPrompt.prompt.ts is worse still.

The generator test file is rewritten as the gate: confirmed RED (6 fail) before
the change and green after, including a prompt with @Format: text and
@responseFormat: xml — the shape a single @Format could not express.

Records one KNOWN GAP found while doing this and deliberately not changed: the
STRICT parser body is Schema.parse(JSON.parse(text)) regardless of syntax, so
parse<Name> cannot work for an XML reply — only the tolerant extract handles
XML. It pre-dates ADR-0052 and changing it would withdraw a public generated
function from existing XML consumers, so it is pinned with a comment naming it
rather than silently blessed.

25 codegen-ts tests still assert the old polarity via template.output fixtures;
they are re-pointed next, together with the goldens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
… strict

The strict Zod tier is JSON-only by construction: its body is
`Schema.parse(JSON.parse(text))`, and there is no XML equivalent to generate.
The TS runtime ships no XML parser — which is precisely why this reached for
JSON.parse in the first place, and it did so for XML templates too, so
`parse<Name>` was a generated function that could never work.

Supplying a real one would mean taking an XML-parser dependency AND assuming a
model emits exactly well-formed XML — the assumption FR-010's tolerant extract
exists because you cannot make. So an XML reply now emits the tolerant extract
and nothing else; its typed shape is the nullable `<Name>Extracted` mirror,
which is the honest type for a best-effort parse of model output. A JSON reply
keeps the strict tier, because JSON.parse is a real exact parser and
`Schema.parse(JSON.parse(text))` is a correct thing to generate.

Nothing is withdrawn from a working consumer: every strict-parse assertion in
the suite is on a json template, no test or fixture exercises a strict XML
parse, and the extractor tier imports only `extractLenient<Name>WithLoader`
plus the mirror types — never `parse`/`safeParse`. The `zod` import is now
conditional so an XML file carries no unused import.

Also fixes an import path this branch's own rename broke: the emitted extractor
still imported from `./<Name>.output.js` after the parser file became
`<Name>.response.ts`, so every generated extractor would have failed to
resolve. Found by grepping the emitted filename rather than the source one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
The strict Zod tier emitted every field as mandatory. It never read @required
and never emitted .optional(), so `parse<Name>` threw on a reply that correctly
omitted a declared-OPTIONAL field — it enforced a contract the metadata does not
declare, and disagreed with the tolerant tier sitting in the same generated
file, which reads the real attr.

Proven before fixing: a value-object with `answer` (@required) and `note`
(unmarked) emitted `answer: z.string(), note: z.string(),` — both mandatory —
while the tolerant mirror correctly typed `note: string | null`.

Optionality now comes from the SAME `isRequired` predicate the tolerant tier
uses, so the two tiers in one file cannot disagree about the contract. The
`.optional()` wraps the ARRAY rather than its element: an absent list and a list
of absent things are different claims.

This is the #309 defect family, one tier over. Every other port reuses the
payload VO that #309 made @required-correct; only TypeScript re-derives its
schema inline here, which is how it drifted — the same "payload tier disagrees
with the rest of the toolchain" shape ADR-0052 came out of.

Found by a /challenge whose two arms split: one proposed deleting the strict
tier outright, the other showed why that cannot happen (Java has no extractor
generator, so strict is its ONLY enforcement path). Checking both positions
turned up the real state — the strict tier is OVER-strict while the tolerant
tier is UNDER-strict (it fires only on lost-required, MALFORMED never throws,
and response VOs in the corpus mostly declare no @required at all). This commit
fixes the over-strict half; the under-strict half is deliberate and documented
at render/src/extract/types.ts:377-383, so it is left alone.

One test assertion was written as a lazy spanning regex that matched ACROSS the
required field into the optional one's closer, so its negative form could not
fail. Replaced with exact block matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…g prompt

Mechanical conversion of the test fixtures that declared a `template.output`
purely to drive the inbound tier: they become a `template.prompt` whose
`@responseRef` names the shape they used to name via `@payloadRef`.
`@payloadRef` stays (required on a prompt, and now types the REQUEST) pointing
at the same object — these fixtures never exercise the request side, so reusing
the shape keeps the diff to the direction change alone. A parser-selecting
`@format: json` is dropped; `@format: xml` becomes `@responseFormat: "xml"`,
since on a prompt `@format` is the BODY's syntax.

Two guards inverted rather than moved, and are rewritten as such:

  - "throws when template name is not a template.output" used to reject a
    template.prompt. A prompt is now the only thing that can carry a response,
    so it asserts the opposite, and gains a sibling for a prompt that declares
    no @responseRef at all.
  - The nested-indentation golden now shows `.optional()` on both levels, since
    neither field is @required. The indentation contract it exists to pin is
    unchanged.

codegen-ts: 1225 → 1243 passing, 25 → 11 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
Six commits in: phases A and B green in all five ports, TS codegen re-pointed,
codegen-ts at 1243 pass / 11 fail. Records what is done, what remains in
dependency order, and two findings deliberately not acted on (the tolerant
tier's under-strictness, and whether toolcall is a parsing concern at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
Surfaced by a peer session reading the site rather than the claim. The Python
trace helper's comment asserts it uses "the SAME rule the output-parser /
extractor generators use" — and that parity claim is factual: verified that
output_parser_generator.py:136, extractor_generator.py:194,
output_prompt_generator.py:76 and output_format_spec_emitter.py:19 all read
TEMPLATE_ATTR_FORMAT with TEMPLATE_FORMAT_DEFAULT, the identical pattern.

So the whole cluster moves to @responseFormat together, and the comment must be
UPDATED rather than deleted — deleting it would hide that five sites share one
rule, which is how the original @Format misreading spread.

Records the rejected branch too: "reading @Format is correct for
template.output, since the output body IS the response" is exactly the
conflation ADR-0052 dissolves — once template.output is outbound-only its
@Format is the syntax of a document rendered OUT, never a response.

The trace helper was only implicit in the plan; it is not one of the three
generators Phase D/F enumerates, so it was the item most likely to be missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…ponding prompt

codegen-ts 1244 → 1249 passing, 10 → 5 failing.

The shared five-port `xpkg-collision-json` fixture gains a responding
`template.prompt` rather than having its `template.output` converted. That
corpus is the OUTBOUND render oracle and its collision sub-case is also the
payload tier's optionality oracle, so converting the node would have weakened
two guarantees to fix a third. Adding `DigestPrompt` beside `DigestDoc` leaves
every outbound assertion intact and gives the inbound tier a target — the
collision test's compile-and-run proof passes against it, extracting each
cross-package `Note` into its own shape.

The "text-format output gets NO extract block" test is INVERTED rather than
deleted. Its premise was the defect: @Format is the syntax of the rendered
prompt BODY, so gating the tolerant path on it meant a plain-text prompt whose
reply is JSON got a strict parser and no extract at all — the common case,
silently unserved. It now pins the opposite, and is a regression test for the
bug ADR-0053 fixed.

Remaining 5, all heavier: api-docs ACCURACY x3 (api-model.ts:820,914 still keys
inbound facts on template.output), the `template-output-simple` conformance
golden, and one extractor payload-import case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
A single-reviewer pass (the challenge's second arm died on a budget limit, so
this is one opinion, not a challenge result) found four real problems in changes
I made earlier on this branch. All verified before fixing.

1. A SILENTLY VACUOUS TEST, one edit away from firing. The parser-golden suite
   discovers fixtures by `f.endsWith(".output.ts")` and installed an
   `expect(true).toBe(true)` placeholder when zero matched. Converting the last
   fixture and renaming its golden to `.response.ts` — the obvious next step —
   would have left the suite reporting green while asserting nothing. It now
   accepts both suffixes AND fails loudly on zero discoveries: a discovery-driven
   suite that passes on an empty set is worse than no suite.

2. A DANGLING REF PLANTED IN A FIVE-PORT ORACLE. The `DigestPrompt` node I added
   to `xpkg-collision-json` carried `@textRef: "xpkg/digest-prompt"`, and
   `templates/xpkg/` contains only `digest.mustache`. Nothing resolves it today,
   which is precisely why it would have rotted quietly. Re-pointed at the
   existing `xpkg/digest`.

3. THAT CORPUS'S README STILL DOCUMENTED THE OLD GATE — "the extract tier gates
   on @Format ∈ {json,xml}" — which is the rule ADR-0052 removed. Four ports
   implement against that README, so a stale spec there propagates the defect
   into every port that reads it. Rewritten to state the @responseRef gate, that
   template.output emits no inbound artifact at all, and why the prompt was ADDED
   beside the output rather than replacing it.

4. A COMMENT THAT WOULD HAVE BEEN QUOTED BACK. The XML rationale said "the TS
   runtime ships no XML parser." It ships `xml-forgiving-reader.ts`. The honest
   reason is that strict all-or-nothing semantics over a FORGIVING reader is
   incoherent — it would throw or accept based on how much repair happened. The
   false version would have been cited the next time someone proposed strict XML.

Also narrows an over-claim rather than leaving it: `isRequired` reads the
`@required` ATTR only, while the registry documents that attr as "equivalent to
attaching a validator.required child". The fix still did what it claimed — the
strict and tolerant tiers now agree, verified byte-identical predicates — but
"the schema's contract is the metadata's contract" is broader than shipped.
Recorded with the other known findings.

codegen-ts 1249 → 1250 pass, 5 fail (unchanged set). 10/10 gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…g prompt

api-model.ts built the extractor symbols inside buildTemplateUnit — gated on
@Format in {json,xml} and typed on @payloadRef. After ADR-0052 that documented
functions template.output no longer emits, named the REQUEST shape as the parse
result, and said nothing about the prompts that actually emit them. The block
moves to buildPromptUnit, keyed on @responseRef via responseShape().

buildPromptUnit also gains the worked example buildTemplateUnit already had.
Without it the page documented extract<Name>(root, ...) while the setup preamble
never introduced `root` — the preamble derives its handles from the rendered
EXAMPLE text, so moving the symbols without their example silently dropped it.

The accuracy gate could not check `kind: prompt` at all — it fell through to
"unhandled kind", and its importPath dispatch returned [] for that kind. Both
were invisible until this fixture grew its first responding prompt. Now wired to
promptRender()'s aggregated prompts.ts, so the gate covers the kind it was
already meant to.

Its FIELD SHAPE case also moves: the documented extractor shape is the
@responseRef VO, asserted on the prompt unit rather than on an outbound document.

codegen-ts 1243 -> 1249 pass, 12 -> 8 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
… api-docs move

Two defects, one mine and one it exposed.

MINE: moving the extractor symbols to the prompt unit silently degraded the
outbound render example to `render<Name>({}, provider)`. templateExample built
the render call's object literal from `extract?.fields` — the render symbol had
never carried its own shape, borrowing the extractor's. The moment the extractor
left the unit, the literal had nothing to build from. The render symbol now
carries `payloadFieldShapes` directly, which is where it always belonged: that
is the payload the render handle takes.

EXPOSED, pre-existing, matched rather than hidden: the agent form labels a
field-shape annotation with the symbol's `returns`, so a prompt's render handle
reads `// string: { headline: string }` — the shape describes the PAYLOAD, not
the string it returns. Correct for an extractor (returns IS the shape), wrong for
any render. Unrelated to ADR-0052; the golden now states the real output so the
suite is honest about it.

Three api-docs fixtures gain a responding prompt, because `root` only appears in
the setup preamble when a documented example calls extract<Name>(root, …), and
those examples moved.

codegen-ts 1249 → 1255 pass, 8 → 2 fail. C# still fully green (903+354+291+46),
typecheck clean, 10/10 gates.

The last 2 failures are NOT fixed, deliberately: both need
`fixtures/conformance/template-output-simple/` converted to a responding prompt,
and C# drives its own OutputParserGenerator from that same directory
(OutputParserGeneratorTests.cs:142) while still keying on template.output.
Converting it now would trade 2 TypeScript failures for a red C# port. Those two
reds are a true signal that Phase F is incomplete — the honest place to fix them
is with the port conversion, not before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
The last TS consumer still reading @Format for the REPLY. It read that one
attribute twice — once as the reply's syntax, once as the prompt body's — under
a comment calling them "two intentionally different shapes". They are two
different FACTS, not two shapes of one, and conflating them mis-parsed every
prompt whose body format differed from its reply's. The shipped docs-site
fixture is exactly that shape: a text-bodied prompt whose reply is XML.

All four TS inbound consumers now go through responseFormatOf(): output-parser,
extractor, response-format fragment, and this. `@format` keeps its real job here
— the raw body format string render() takes.

Pinned by a new test whose discriminating case is @Format: text +
@responseFormat: xml, plus its mirror (@Format: xml + @responseFormat: json) so
it cannot pass by reading either attribute alone, plus the absent-default and a
case asserting the body still renders in its own format.

GATE PROVEN TO FAIL: reverting the fix to the old @Format read turns 3 of the 4
red. A gate believed without being seen fail is how the original misreading
survived — it shipped under a comment that described the collision accurately
and drew the wrong conclusion from it.

codegen-ts 1255 → 1259 pass, 2 fail (unchanged: both need the shared
template-output-simple fixture, which is coupled to the C#/Java/Python port
conversion). Typecheck clean, 10/10 gates.

Python and Java trace helpers still read @Format; they port with Phase F.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
`template-output-simple` was the shared inbound oracle: a `template.output`
whose `expected/` held the generated PARSER. Under ADR-0052 an output emits no
parser, so that golden had no owner — it is the last thing pinning the tier to
the wrong subtype. Phase B already added `template-prompt-response-json`
(a `@format: text` prompt with a `@responseFormat: json` reply, which is the
shape the old design could not express), so the golden moves THERE and
`template-output-simple` keeps only its loader expectation — a pure outbound
fixture now, which is what its name has always claimed.

Two gates were repaired in passing, both the same shape as the defect:

- `output-parser-conformance` discovered fixtures by EITHER golden suffix but
  the inner loop still filtered `.output.ts` alone, so a renamed golden would
  admit the fixture and then assert over zero files — reporting green while
  checking nothing, one line below the comment warning about exactly that. It
  now filters by the same suffix set and fails on an empty match.

- `extractor-render-payload-imports` had its template converted to a prompt but
  still asked `renderRenderHelper()` for it. The render helper is the OUTBOUND
  tier and is deliberately untouched by this ADR — it is the control proving the
  inbound move did not drag the outbound tier along — so it throws on a prompt.
  The model gains its own `template.output` and the render half binds to that.

codegen-ts: 1261 pass / 0 fail (was 1259 / 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…onseRef

ADR-0052 in the C# port. `template.output` is OUTBOUND ONLY — it renders a
document or an email and generates nothing that reads a model's reply. The
parser-on-receipt, the FR-010 response-format fragment and the tolerant extract
now key on a `template.prompt` carrying `@responseRef`, and the reply's syntax
comes from `@responseFormat` (ADR-0053) rather than from `@format`, which is the
syntax of the prompt BODY being sent.

The direction rule lives in ONE place (`Generators/FindInbound.cs`, mirroring
`find-inbound.ts`). Three generators previously each decided for themselves and
had drifted: OutputParserGenerator applied NO format filter at all, so an email
template got a `JsonSerializer.Deserialize` for text the system had just
rendered, while OutputPromptGenerator and ExtractorGenerator each applied their
own json/xml gate against the wrong attribute.

THE PAYLOAD TIER GAINS THE RESPONSE SHAPE. This is the part the plan did not
anticipate. C# named its strict record after the resolved VALUE-OBJECT but
emitted one only for a `template.output`'s `@payloadRef` — nothing in
MetaObjects.Codegen mentioned `template.prompt` at all. A parser bound to
`@responseRef` therefore referenced a type nobody declared, and the generated
code would not compile. PayloadGenerator now emits the `@responseRef` record
too, deduped by resolved VO FQN because CodegenRunner THROWS on a duplicate path
(it has no byte-identical collapse) and an output's payload may legitimately be
the same declared shape as a prompt's response. TypeScript never hit this: its
payload types come from entityFile(), which emits per `object.value` regardless
of any template.

The strict tier is now JSON-ONLY. `JsonSerializer.Deserialize` has no XML
equivalent worth generating — not because no XML reader exists (Render ships a
forgiving one) but because strict all-or-nothing semantics layered over a
REPAIRING parser is incoherent. An XML reply gets the tolerant extract alone.
Before this an XML template got `Deserialize` anyway: a method that could never
work.

Artifact names follow the direction axis (D4): `<Prompt>.response.cs` and
`<Prompt>.responseFormat.cs`. `CSharpNaming.PromptClassName` becomes
`ResponseFormatClassName` — `<Name>Prompt` generated FROM a prompt reads as
"ClassifyPromptPrompt", which is the confusion this ADR removes. The parser
class keeps `<Name>Parser`, matching TypeScript keeping `parse<Template>`.

Two obsolete-premise tests were rewritten rather than deleted, because they now
prove the opposite and that inversion IS the feature: a text-BODIED prompt with
a JSON reply — the common case — used to get a strict parser and no tolerant
extract at all. Both halves of the split are pinned in api-docs: a responding
prompt documents PAYLOAD/PROMPT/OUTPUT_PARSER, an output documents RENDER alone.

Gate proven by breaking it: removing the `@responseRef` predicate turns the
"prompt that declares no response" test red. The subtype half of the same filter
was found NOT to discriminate on its own — `@responseRef` is prompt-only
vocabulary the loader already enforces — so the ref gate is what carries the
weight, and that is the one now proven.

C#: 903 + 357 + 291 + 46 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
Two corrections a fresh session needs before touching Java.

C# is green but its green is weak evidence: most of its 25 failures were fixed
by REWRITING the tests, which is the move that launders a bad change into a
passing suite. One gate-can-it-fail probe was run and immediately caught a pin
that asserted nothing. Three concrete holes are now the first item in the plan —
the response-record emission has no gate at all (deleting it leaves C# green),
the compile gate bypasses the seam by calling PayloadCodegen directly, and no C#
test exercises an xml reply. Java/Kotlin/Python will copy whatever precedent C#
sets, so the precedent gets verified first.

And Phase F is not three generator re-points per port. The payload tier must
emit the @responseRef shape in every non-TS port, because none of them did —
TypeScript never hit it since entityFile() emits per object.value regardless of
any template. The ports need DIFFERENT answers: C# names its record after the
value-object so extending the walk sufficed, while Java and Python name the
primary after the TEMPLATE and so need a second record and a name for it.

Also records the vacuous-pin mechanism (the subtype half of the direction filter
does not discriminate — @responseRef is prompt-only vocabulary the loader
already enforces, so pin the ref, not the subtype), that Phase B had already
created the replacement fixture the old Task E1 was going to write, and two
pre-existing weaknesses found in passing: @required never reads a
validator.required child, and the strict Zod schema types field.enum as
z.unknown().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…by breaking it

C# was green after the ADR-0052 port, and green was weaker evidence than usual:
most of its 25 failures were fixed by rewriting the tests, which is the move that
launders a bad change into a passing suite. Three holes, all closed here, all
verified by deleting the product behaviour and watching the new test go red.

1. The headline change had NO gate. Every C# fixture set @responseRef equal to
   @payloadRef, so the OUTBOUND walk emitted the response record by coincidence
   and nothing could tell the two walks apart — deleting the @responseRef walk out
   of PayloadGenerator left the suite green. The two new PayloadGenerator tests use
   DIFFERENT value-objects for the two refs, which is the only shape that
   discriminates, plus the dedupe case where an output's payload and a prompt's
   response are legitimately the same shape (CodegenRunner throws on a duplicate
   path — no byte-identical collapse like TS #266).

2. The compile gate bypassed the seam. It built its payload by calling
   PayloadCodegen.GeneratePayloadRecords directly with a hand-written ref, which
   proves only that a record CAN be produced for a name the test already knew — it
   cannot see the generator failing to emit that record at all, which is exactly
   the ADR-0052 failure mode. Now routed through PayloadGenerator.Generate, with
   the two refs distinct; removing the inbound walk turns it red on a missing type.

3. Zero C# tests used @responseFormat: "xml", so the strict-tier-is-JSON-only
   behaviour was entirely unverified. Added the XML case (tolerant extract, no
   strict Parse/TryParse, no System.Text.Json) and its JSON control — without the
   control, "no strict tier" could pass because the generator emits nothing useful
   for either format.

Writing 3 first caught a false assertion of my own: a bare `TryParse(` matches the
tolerant tier's own int.TryParse coercion mappers, so the pin is on the public
signatures. Product code is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…onseRef

The Java half of ADR-0052. Phase A/B moved @PromptStyle onto template.prompt and
added @responseFormat in all five ports, but only the TypeScript and C# codegen
followed — so this branch left the Java reactor NOT COMPILING
(OutputFormatSpecEmitter called ot.getPromptStyle() on an OutputTemplate that no
longer has it). The plan recorded "Java BUILD SUCCESS" as the baseline; it was not
one, and nobody had run it.

FindInbound.java is the rule, in one place, mirroring the TS and C# files of the
same name: a responding prompt is a template.prompt whose @responseRef resolves,
and the gate is @responseRef PRESENCE, never a format value. Three call sites each
deciding for themselves is how the old tier drifted — the parser applied NO format
filter (so an email template got a Jackson readValue for text the system had just
rendered), while the fragment emitter applied its own @Format ∈ {json,xml} gate
against the OUTBOUND body's syntax, which is not the syntax of the reply. A
text-bodied prompt asking for a JSON answer — the common case — got a strict parser
and no fragment at all.

Naming. Java's records are TEMPLATE-named, so a responding prompt needs a second
record and a name for it: <Short>Response, beside the existing <Short>Payload. The
plan proposed routing the response VO through ADR-0044's nested-closure name map
instead, but that needs a conditional (the map deliberately excludes a template's
primary VO, so a prompt whose @responseRef equals its @payloadRef would have no
entry) and it mixes a value-object-derived name into a generator whose convention
is template-derived. C# diverges deliberately and correctly: its records are
VO-named, so there the response record simply IS the VO's record. Per D4 the
fragment class is <Short>ResponseFormat — generated from a prompt now, the old
"Prompt" suffix produced ClassifyPromptPrompt.

The response VO's nested closure still enters the shared ADR-0044 name map, or a
response-side nested VO could collide with a request-side one and clobber its file
— the #219 defect one tier down.

api-docs moves with codegen (JavaApiModelBuilder shares the same appliesTo
predicates, so docs cannot claim a symbol codegen suppressed) and now documents the
response record the parser actually returns.

Tests: every model asserting an inbound concern was a template.output, so each was
converted, and the two tests pinning the OLD rule were replaced rather than
adjusted — "@Format=text emits nothing" and "template.prompt is ignored" are the
exact statements ADR-0052 reverses. The replacements pin both negative arms
separately, because they now fail for different reasons: direction (an output, at
@Format json — the value that used to make it apply) versus no declared response.
The shared fixtures renamed in Phase B (template-prompt-response-{json,xml}) carry
@Format: text with a differing @responseFormat, so a generator still reading
@Format emits Format.JSON for both rows and the xml row fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…ts no parser

Found by challenging the branch's own implementation, then reproduced.

The C# inbound tier resolved @responseRef with the ANY-OBJECT NamingRefs.ResolveObjectRef
while PayloadGenerator — which emits the record that parser binds — resolves value-only
through ResolvePayloadVo. So a @responseRef naming an object.entity resolved in
FindInbound, OutputParserGenerator emitted `public static Answer Parse(string text)`, and
PayloadGenerator emitted no record at all. CS0246: generated C# that cannot compile.

Nothing upstream catches it. TypeScript's loader validates the @responseRef target
(validation-passes.ts:336-345); the C# loader validates @payloadRef and never
@responseRef, so the model loads with ZERO errors. Measured, not inferred: loaderErrors=0,
parsers=1, payloads=0, and the emitted parser binds `Answer`.

Two changes, because the resolver alone was not enough:

  FindInbound.ResponseShape now resolves through ResolvePayloadVo, the same target rule
  @payloadRef obeys. Java has always done this (SpringPayloadGenerator.resolveValueObject),
  which is why Java was never exposed.

  OutputParserGenerator.Generate now skips on RESOLUTION, not presence. InboundTemplates
  filters on @responseRef presence — it has no root to resolve against — so this loop was
  the only place the target could be checked, and it called EmitParser unconditionally.
  Every sibling inbound generator already skipped on ResponseShape; this one alone did not.
  AppliesTo gains the root parameter it needed to answer the same question, so the api-docs
  builder can no longer claim a parser symbol codegen suppressed.

The regression test asserts the loader accepts the model before asserting codegen emits
nothing — otherwise a future C# loader rule would make it pass vacuously.

Also fixes Java javadoc left asserting the rule ADR-0052 reverses, in the two classes the
previous commit rewrote: "one <TemplateShortName>Parser per template.output",
"template.prompt is ignored — only outputs need parsing", "only outputs need
prompt-fragment codegen". The code was right and its own documentation contradicted it.

Known and NOT fixed here: the C# loader still does not validate @responseRef's target, so
the same metadata that fails TypeScript's load still passes C#'s. That is a cross-port
loader parity gap covering four ports plus a conformance error fixture — it wants its own
change, and codegen now fails closed regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…cs documents it again

Two C# gaps, both surfaced by challenging the branch's own implementation.

1. PayloadGenerator filtered its outbound walk to template.output, so a template.prompt's
   REQUEST shape got no record in this port while Java, Kotlin and Python all emitted one.
   It reads as dead output from inside codegen — the render HELPER is outbound-only in
   every port, so nothing generated binds it — which is exactly the reasoning the previous
   commit's test wrote down. That reasoning was wrong: the binding is HAND-WRITTEN by the
   adopter, and the shipped docs show the call for a prompt
   (docs/features/templates-and-payloads.md:224 — `new WelcomePromptPayload(...)` passed to
   Renderer.render). The walk now covers every template subtype, matching the other ports;
   the existing dedupe by resolved VO FQN already handles a request and a response naming
   one shape.

   Emits_the_response_record_for_a_responding_prompt asserted the request record was ABSENT.
   That assertion is inverted here, not deleted — it encoded a premise about the consumer
   surface that turned out to be false, and the test is stronger now: it pins BOTH records,
   as separate files, from a prompt whose two refs are different value-objects.

2. The api-docs builder stopped emitting a PAYLOAD symbol for a template.output when the
   inbound tier landed, so a record PayloadGenerator still emits was documented nowhere —
   contradicting PayloadGenerator's own comment naming AppliesTo "the SINGLE SOURCE OF TRUTH
   the api-docs builder shares for the PAYLOAD symbol". Restored, gated by that same
   predicate, with the response symbol deduped by NAME against it: C# names records for the
   value-object, so when @responseRef and @payloadRef are one shape both walks land on one
   file and documenting it twice would claim two records exist.

   Both symbols now take their name from PayloadCodegen.ResolveEmittedName rather than the
   authored ref. The ref is what the author typed: an FQN one printed
   `record acme::ai::SupportAnswer`, and under an ADR-0044 collision the emitted record is
   package-qualified while the ref is not — so the docs named a type that was never emitted.

Each behaviour verified by reverting it and watching the corresponding test go red.
Emits_no_files_when_no_template_output_nodes became
Emits_no_files_when_there_are_no_templates: @payloadRef is required AND loader-validated, so
a declared template resolving to nothing is unreachable in a valid load — the case it
claimed to cover could not occur, and the reachable version of it lives in
A_responseRef_that_is_not_a_value_object_emits_no_parser, which exists only because
@responseRef has no equivalent loader rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…onseRef

The Kotlin half of ADR-0052, and the last JVM port. Like Java, Kotlin did not compile
on this branch — KotlinOutputFormatSpecEmitter read `promptStyle` off an OutputTemplate
that no longer has it — so the plan's recorded "Kotlin 315" baseline was never observed.
The full server/java reactor now builds and tests green end to end for the first time on
this branch.

FindInbound.kt is the rule in one place, mirroring the Java, C# and TypeScript files of
the same name. Kotlin needs its own copy rather than reusing codegen-spring's because
codegen-kotlin does not depend on that module. It resolves @responseRef through
KotlinGenUtil.resolveValueObjectRef — the same value-object resolver the payload tier
uses — so the parser can never bind a class the payload tier refused to emit. That is the
defect the C# port shipped, and writing this port after finding it is why Kotlin never
had it.

Four generators re-point (parser, fragment, extractor, payload) plus the api-docs builder,
which had walked OutputTemplate only and so produced no unit at all for a prompt. Names
follow Java: <Short>Response for the response record, <Short>ResponseFormat for the
fragment (D4 — generated from a prompt now, the old suffix produced ClassifyPromptPrompt).
The response VO's nested closure enters the shared ADR-0044 name maps, both of them, since
computePayloadNameMap and computeExtractedNameMap share one walk.

The extract tier's own @Format gate is gone too: it sat over the parser's delegating
extract and gated on the OUTBOUND body's syntax, so a text-bodied prompt expecting JSON
got no extractor.

Tests: models asserting an inbound concern were converted, and the four pinning the OLD
rule were replaced rather than adjusted — "@Format: text emits nothing" and "template.prompt
is ignored" are the statements ADR-0052 reverses. The cross-package collision test needed
BOTH nodes, because it asserts the render helper (outbound, template.output-only) and the
response-format fragment (inbound, prompt-only) in one pass; a prompt alone left the render
helper with nothing to emit. Two snapshots were regenerated from ACTUAL and two written for
the new response classes — the snapshot harness has no update flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…in every port

Only TypeScript validated it. C#, Java and Python (and Kotlin, which inherits the JVM
loader) checked @payloadRef's target and never @responseRef's, so the SAME metadata
failed one port's load and passed four — a four-way divergence in the loader, which is
the layer whose whole job is that this cannot happen.

It was latent while @responseRef only fed the trace helper. ADR-0052 makes the inbound
codegen tier key on it, which turned a silent divergence into generated code that does not
compile: a @responseRef naming an object.entity resolved in the C# generator, emitted
`public static Answer Parse(string text)`, and the payload tier — which resolves
value-only — emitted no such record. Measured before the fix: loaderErrors=0, parsers=1,
payloads=0.

The codegen tiers now fail closed too (094f580 for C#; Java and Kotlin resolve through
their payload resolver by construction), so this is belt and braces on purpose: a
generator skipping silently is the right behaviour for a generator, and a wrong ref is
still an authoring error the author should be told about once, at load, in whatever port
they happen to run.

The rule is @payloadRef's verbatim — object.value or sourceless object.projection (#210),
ERR_INVALID_TEMPLATE, resolved-source envelope — and the response closure's nested targets
get the same value-only walk, so the two refs cannot drift on what a legal payload target
is. ADR-0039: read resolving, since @responseRef may arrive through extends.

Gated by fixtures/conformance/error-template-response-ref-not-value, and each port's rule
verified by disabling it and watching that fixture go red: C# 905→1 failure, Java 568→1,
Python 404→1. TypeScript already passed it unchanged, which is what makes it the reference.

Also drops @PromptStyle from the api-docs-cross-port fixture's template.output — illegal
since the attribute moved, and it had been failing Python's load for the whole branch
(that fixture is loaded by the api-docs conformance test, not the metamodel corpus, which
is why the metamodel gate never saw it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
… carry it

ADR-0052 said it "rides the coordinated pre-1.0 breaking slot alongside FR-037/FR-038";
spec/roadmap.md targeted both of those at 1.1. Challenged, and the roadmap is wrong — but
not because 1.1 is the wrong SCHEDULE. It is unreachable.

ADR-0035 §1 (+ Amendment 1, and Consequences) : after 1.0, a breaking change to the
metamodel vocabulary requires a 2.0. FR-037 R1/R2 retire @readonly and FR-038 retires
@Verifiedby — both retire registered vocabulary. A 1.1 MINOR cannot carry either. So the
two cells did not express a plan I disagree with; they expressed one the project's own
compatibility rule forbids. That is a drafting defect, and it is fixed here rather than
adjudicated.

The distinction that makes the cells wrong is milestone vs mechanism. docs/RELEASING.md:167
is a row in a VERSION-BUMP table whose axis is pre-1.0 / post-1.0, not a calendar: while the
project is 0.x, a MINOR is HOW a break ships, because ^0.20.x does not resolve 0.21.0 and
the break is therefore adopted deliberately. The roadmap's Release column is a milestone
bucket — FR-031/032/035/036 all read "1.0" and all shipped in 0.x. Both rows now carry the
compound form the table already uses for FR-024 ("1.0 · 1.1"), splitting each FR's breaking
sliver from its additive body.

ADR-0052 keeps its claim but loses the definite article: 0.21.0 was "the pre-1.0 breaking
slot" (CHANGELOG, AGENTS.md) and it shipped. ADR-0035 §3 charters a WINDOW — "the next one
or two releases" — and ADR-0051:60 already says "a scarce pre-1.0 breaking slot". So this
is the next MINOR in that window, not a slot someone else is holding.

Adds the section neither document had: what the next breaking MINOR actually carries. Both
challenge arms independently reported that no populated successor slot is recorded anywhere
— three design docs claim one and nothing enumerates it, which is precisely what a peer
session hit when it went to cut the next release and found itself blocked with no way to
tell what it was blocked ON. It also records the cost, which had been stated nowhere: this
MINOR resets the §G3 quiet-period clock, so it pushes the 1.0 renumbering out by at least
one coordinated release. The analogous trade was already adjudicated ship-it for #210.

Two corrections folded in: @responseFormat is ADR-0053, not ADR-0052, so anyone counting
"the ADR-0052 batch" under-counts the breaking changes by one; and ADR-0035 §C3 still names
0.15.1 as the last breaking move, which 0.21.0 superseded.

Challenge record: ~/.claude/challenge-log/adr-0052-release-slot/ (both arms agreed on the
verdict from different evidence — one from the CHANGELOG's 0.21.0 precedent, one from the
roadmap condemning its own cells at :154).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…ot explicitly

Three corrections from the challenge's differential, which caught things both arms missed.

The Release column is a MILESTONE bucket, not a version: FR-031/032/035/036 all read "1.0"
and all shipped in 0.x. "pre-1.0 MINOR" was a mechanism token in a milestone column, so the
cells now read "1.0 · 1.1" — the same compound form FR-024 already uses, and accurate,
since the 1.0 line IS where a pre-1.0 breaking MINOR lands.

Rules out the option both challenge arms treated as non-existent — and it is the one a
reader will reach for first, because it looks free. 1.0.0 is a MAJOR under semver, so a
break landing IN the renumbering is neither a pre-1.0 MINOR nor a 2.0 event. It is still
wrong: §G3 wants "at least one coordinated release … with no metamodel-breaking changes, to
prove the rate has actually dropped". A 1.0 that carries a break has had no quiet period, so
it freezes the vocabulary on a stability claim nothing tested. The gate is about evidence,
not arithmetic — worth writing down, because the arithmetic reading is the tempting one.

Names the tension instead of implying it is closed. CLAUDE.md and the "Before 1.0" section
say GA is next and nothing blocks it; this section says a breaking MINOR comes first. Both
challenge arms leaned on roadmap:139 to conclude a further MINOR is available and neither
engaged the contrary evidence — so the section now states the choice and points at #210's
reversal trigger as the mechanism for making it. The one thing that stays unavailable either
way is targeting the breaking half at 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…ne place

The first piece of the Python port. Mirrors the TS, C#, Java and Kotlin files of the same
name: a responding prompt is a template.prompt whose @responseRef resolves, and the gate is
@responseRef PRESENCE, never a format value.

Resolution goes through resolve_payload_vo — the SAME target rule @payloadRef obeys — rather
than an any-object lookup. That is deliberate and load-bearing: C# used the any-object
resolver here and shipped a parser bound to a record the payload tier refused to emit
(CS0246). Writing the later ports through the value-only resolver is what makes them immune
by construction.

No generator consumes this yet; the three generators plus the payload tier and api-docs are
the remaining work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
@dmealing

Copy link
Copy Markdown
Member Author

Known coverage regression — not yet fixed

Flagged while applying a caution from a parallel session, and it is the same failure class they hit: a shared corpus can lose coverage without any test failing.

fixtures/conformance/api-docs-cross-port/input/meta.json had exactly one template — OrderSummary, a template.output carrying @promptStyle: inline. Under the pre-ADR-0052 rules that node exercised the PROMPT and OUTPUT_PARSER symbol paths in every port's api-docs builder.

This PR strips @promptStyle from it (now illegal on an output, and it had been failing Python's load for the entire branch). So that node exercises RENDER + PAYLOAD only, and no other case in the shared corpus covers the inbound half. Every port still passes, because nothing asserts what is no longer exercised.

Fix, for the follow-up: add a responding template.prompt plus a response object.value to that fixture, and add its unit row to expected-paths.json. That file is byte-gated across five ports — do it in one pass and run every runner, since a partial edit reddens all five.

Scope note: the per-port api-docs accuracy tests were updated for ADR-0052 in Java, C# and Kotlin (each now pins outbound-only for a template.output and the full inbound set for a responding prompt). It is only the shared cross-port corpus that lost coverage.

dmealing and others added 4 commits August 20, 2026 18:51
…onseRef

The fifth and last port. `template.output` renders OUTBOUND and generates nothing
that reads a model's reply; the response record, the FR-010 response-format
fragment, the parser-on-receipt and the tolerant extractor all move to a
`template.prompt` carrying `@responseRef`. Every one of them now calls through
`find_inbound`, which is the direction rule in one place — the gate is
`@responseRef` PRESENCE, never a format value.

The payload tier gets a SECOND record. Python names records after the TEMPLATE, so
a responding prompt needs `<Template>Response` beside `<Template>Payload` — same
call Java and Kotlin made, and for the same reason (one naming convention per
generator rather than a template-derived name beside a value-object-derived one).
It lands in its own `<template>_response.py` module rather than joining the payload
module, because strictness is per-module here: a prompt's REQUEST payload emits
`extra="forbid"` so a mistyped render slot fails at construction, while a reply
record must tolerate unknown fields, and a value-object reachable from both closures
could only have one. Every existing model's `<template>_payload.py` stays
byte-identical.

Emitted file names follow TS and C#: `_output_parser` → `_response_parser`,
`_output_prompt` → `_response_format`.

ADR-0053 lands in three more places: the strict `parse_*` tier is JSON-only (an XML
reply gets the tolerant extract and nothing strict, and the strict record is not even
imported — a dead import otherwise); the baked `OutputFormatSpec`'s Format comes from
`@responseFormat`; and the TRACE HELPER — a fifth inbound consumer the plan's Phase F
list missed — stops deriving the REPLY's parse format from `@format`, the syntax of
the prompt BODY.

api-docs follows: every template subtype gets a unit now, not just `template.output`
(a prompt's `@payloadRef` record was generated and documented nowhere), RENDER is
documented only for `template.output` because that is the only subtype the render
helper generator emits for, and the inbound symbols hang off `response_shape`.

GATES PROVEN TO FAIL, each by removing the behaviour and watching the test go red:
binding `@payloadRef` instead of `@responseRef` turns 3 parser tests red including
the discriminating round-trip (the request shape is a valid document the parser must
REFUSE); forcing the strict tier on turns the XML case red; reading `@format` in the
trace helper turns all 3 new cases red; documenting RENDER for a prompt turns the
api-docs forward gate red. Every fixture in this commit declares a request shape and
a reply shape that share no field name, so binding the wrong ref fails rather than
passing by coincidence.

Python 1750 pass, 0 fail (was 1733 + 6 fail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…ing failed

`api-docs-cross-port` had exactly one template — `OrderSummary`, a `template.output`
carrying `@promptStyle: inline` — and that one attribute is what made it exercise the
PROMPT and OUTPUT_PARSER symbol paths in every port's api-docs builder. ADR-0053 makes
`@promptStyle` prompt-only vocabulary, so it had to come off the output, and with it
went every bit of inbound coverage the corpus had.

All five ports stayed green. That is the point: a corpus that stops exercising a code
path emits no diagnostic at all — there is no assertion whose subject has gone missing,
only assertions that quietly cover less. **When a shared corpus loses a case's coverage,
no test fails.** The corpus now carries a README stating which node covers which path,
so an edit that removes one has to remove its stated purpose too.

The model gains `OrderAdvice`, a responding `template.prompt`, with `@format: text` and
`@responseFormat: json` — the discriminating shape — plus disjoint request/reply value
objects so a port binding the wrong ref documents visibly wrong symbols. `OrderSummary`
stays as the outbound control. The byte-gated manifest was regenerated from the TS
oracle in one pass and all five runners re-run.

Adding it surfaced a broken link that predates ADR-0052: the api-docs surface has always
emitted `api/<lang>/<pkg>/<Prompt>.md` for a top-level `template.prompt`, and that page
carries a "Model / metadata" back-link — but `docsFile()` wrote the neutral page for
`template.output` alone, so the link pointed at a page nothing generated, in every doc
tree containing a prompt. ADR-0052 makes a responding prompt the LAST node a doc tree
should omit. Fixed by dropping the subtype filter; no existing golden moved, because no
committed docs fixture had a top-level prompt — which is why it survived.

Also fixes a C# test the branch had already broken: `A_responseRef_that_is_not_a_value_
object_emits_no_parser` asserted `Assert.Empty(load.Errors)` on the premise that the C#
loader validates only `@payloadRef`. The loader gained the `@responseRef` rule two
commits later, and the test's own comment had predicted this ("if the C# loader ever
gains that rule this test would otherwise pass vacuously") — it just went red instead.
Now it asserts BOTH doors: the loader convicts the metadata, and codegen stays
fail-closed anyway, which is the state defence-in-depth exists to survive.

Verified: TS 1383, C# 291+46+905+363, Java+Kotlin reactor BUILD SUCCESS, Python 1750 —
all with the enlarged corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…eFormat

The last inbound consumer, and the plan's premise about it was wrong: Java never
read `@format` here. It called the 2-arg `MetaObjectExtractor.extract(mo, text)`
overload, which hardcodes `Format.JSON`. So a prompt declaring an XML reply got a
trace helper that parsed it as JSON — the reply's syntax was INEXPRESSIBLE at this
site, not merely mis-read. Same ADR-0053 ruling as TypeScript's and Python's, from
the opposite starting point.

The format is now baked from `FindInbound.responseFormatOf(prompt)`, so all five of
this port's inbound decisions come from one place.

GATE PROVEN TO FAIL: restoring the 2-arg call turns 4 of the 7 tests red. The three
new ones are discriminating by construction — `@format: text` + `@responseFormat:
xml`, its mirror `@format: xml` + `@responseFormat: json`, and the absent-default —
so no single attribute read can satisfy them all.

Also drops `examples/advanced-modeling/src/generated/ProgramDescriptionOutput.output.ts`.
That template declares `@format: markdown`, and the pre-ADR-0052 parser tier applied
NO format filter, so the repo shipped an example containing
`Schema.parse(JSON.parse(text))` over a rendered marketing description — a generated
function that could never work. Under ADR-0052 a `template.output` emits no parser at
all and `meta verify --codegen` now reports the file as one a fresh regen would not
emit; the example's README and its metadata comment say why.

Java reactor BUILD SUCCESS; advanced-modeling drift gate 7 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
…old rule

Phase G. `@promptStyle` is prompt-only vocabulary now and the inbound tier keys on
`@responseRef`, so a documentation set that says otherwise is not stale prose — it
teaches metadata the loader rejects, and file names codegen no longer writes.

- **New migration guide** — the two things that change for an existing model (a load
  error, and a diff), what to do with each `@promptStyle` left on an output, the
  before/after file names per port, and the one non-mechanical part: where the response
  RECORD comes from, which differs by port because the ports do not share a naming
  convention.
- **`templates-and-payloads.md`** — the subtype table's axis is direction; `@responseRef`
  / `@responseFormat` / `@promptStyle` documented as the prompt's inbound half; "Output
  parsing" → "Response parsing"; the fixture list points at the responding-prompt
  fixtures instead of `template-output-simple`, which is the outbound control now.
- **All five port docs** + the five `metaobjects-prompts` skill references + the skill
  itself, with the five byte-gated agent-context fixture sets regenerated. A shipped
  skill teaching illegal metadata is the worst version of this drift: it is read by an
  agent that will then author it.
- **The docs-site fixture** declares `@responseFormat: xml` on its text-bodied prompt —
  the discriminating shape, and the one its `@xmlText` response member always implied.
  Its goldens now show `template:@responseFormat` rendered.
- **CHANGELOG** — leads with the three ways the old tier had drifted at once, and closes
  on the corpus lesson rather than the code one.

Also updates the plan's STATUS block (all five ports green) and records a finding the
plan had listed as work: `verify` needs NO change in any port. Read all three — each keys
its render-drift check on `@payloadRef`, which ADR-0052 did not move, and a `@responseRef`
shape is parsed rather than rendered, so there is no render drift to check and its target
rule is enforced one layer earlier by the loader.

sdk + docs-site + advanced-modeling drift: 294 pass, 0 fail. ci-local --quick green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
@dmealing dmealing changed the title feat(metamodel)!: ADR-0052 — a template subtype's axis is DIRECTION (4 of 5 ports) feat(metamodel)!: ADR-0052 — a template subtype's axis is DIRECTION (all 5 ports) Aug 20, 2026
Only CHANGELOG.md conflicted: main's #320 cut everything under `[Unreleased]` into a
`[0.24.0]` section while this branch added its ADR-0052 entry there.

Resolved by keeping BOTH, with the ADR-0052 section left under `[Unreleased]`. It is a
BREAKING metamodel-vocabulary change and its release slot is still an open human call
(before GA or after) — folding it into a cut someone else defined would answer that
question silently.

Also removes a stray `||||||| constructed merge base` marker that main has been carrying
mid-CHANGELOG since an earlier merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
@dmealing
dmealing merged commit 2f27062 into main Aug 20, 2026
1 check passed
@dmealing
dmealing deleted the adr-0052-template-direction branch August 20, 2026 23:41
dmealing added a commit that referenced this pull request Aug 21, 2026
…9 → 0.10 move

ADR-0052's entry had been parked under `[Unreleased]` to avoid pre-answering "which
release carries the break". That placement does not hold anything back: the code merged
to `main` in #318, so whatever cuts next from `main` ships it either way. Parked, the
only thing it achieved was a `0.24.0` section that omits a breaking change present in
its own tarball.

So `0.24.0` says what it ships. Its summary previously justified MINOR by two DEFAULT
FLIPS alone; it is the pre-1.0 breaking slot, and now reads that way — with the
`⚠️ BREAKING FOR METADATA AUTHORS` callout `0.21.0` established, naming the four
adopter-visible consequences and pointing at the migration guide.

It also folds in the `metamodelVersion` `0.9` → `0.10` move and its gate (#322), placed
after the ADR-0052 section it is caused by rather than before it. Per ADR-0035 Amendment
2 a release that moves that number must say so — and this is the first release in the
project's history that has one to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant