diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72a89c3b4..3a4d98db6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
+### Fixed
+
+- **C#** — `PayloadGenerator`/`PayloadCodegen.EmitRecord` failed to emit a payload record at
+ all (silently produced a header-only file) for any `template.output` in a **named package**
+ whose `@payloadRef` was authored bare. `MetaData.Attr()` resolves a bare ref to its
+ fully-qualified form per ADR-0042, but `PayloadCodegen.FindObject`'s bare short-name match
+ never accounted for that — the top-level `voName` needed the same `CSharpNaming.StripPkg`
+ treatment `FieldType` already applies to nested `@objectRef` fields. Adopters outside the
+ default/root package hit this on every `template.output`.
+
## [0.17.0] — 2026-07-18
Coordinated additive **minor** across all four registries: **npm `0.17.0`** · **PyPI `0.17.0`** · **NuGet `0.17.0`** · **Maven Central `7.9.0`** (Java/Kotlin). Bundles the accumulated projection/view + read-model + prompt work below, plus a full documentation + agent-context skills refresh (the seven `meta init` skills were accuracy-passed and Fable-reviewed, closing a class of stale-vocabulary and calibration defects; the runtime-ui skill gained its missing Python + C# language references). No breaking changes.
diff --git a/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs
new file mode 100644
index 000000000..c731b88ec
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs
@@ -0,0 +1,102 @@
+using MetaObjects.Codegen;
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+///
+/// PayloadGenerator emission tests. Mirrors OutputParserGeneratorTests' shape (file-per-
+/// template.output, GenContext wiring) plus the regression this generator specifically needed:
+/// a template.output declared in a NAMED package with a BARE @payloadRef. ADR-0042 resolves
+/// that bare attr to its fully-qualified form (MetaData.Attr() returns "pkg::Name", not the
+/// literal string the author typed) — PayloadCodegen.EmitRecord's FindObject only matched a
+/// bare short name, so the top-level payload record (and its file) silently emitted empty.
+/// Nested @objectRef fields never hit this because FieldType already stripped them; only the
+/// generator's own top-level payloadRef was unstripped.
+///
+public sealed class PayloadGeneratorTests
+{
+ private static MetaRoot Load(string model)
+ {
+ var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "payload-gen.json")]);
+ Assert.Empty(r.Errors);
+ return r.Root;
+ }
+
+ private static GenContext Ctx(MetaRoot root) => new()
+ {
+ Entities = root.Objects(),
+ Root = root,
+ Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
+ };
+
+ [Fact]
+ public void Emits_no_files_when_no_template_output_nodes()
+ {
+ const string m = """
+ { "metadata.root": { "package": "acme::ai", "children": [
+ { "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "x" } } ] } },
+ { "template.prompt": { "name": "promptOnly", "@payloadRef": "Payload", "@textRef": "p/x", "@format": "text" } }
+ ]}}
+ """;
+ var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList();
+ Assert.Empty(files);
+ }
+
+ // Regression: a template.output in a NAMED package (not the default/root package) with a
+ // BARE @payloadRef. Before the fix, this emitted a file with a header comment and NOTHING
+ // else — no record, because FindObject's bare-name match failed against the FQN attr value.
+ [Fact]
+ public void Emits_full_record_for_bare_payloadRef_in_a_named_package()
+ {
+ const string m = """
+ { "metadata.root": { "package": "acme::intake", "children": [
+ { "object.value": { "name": "NoteEntry", "children": [
+ { "field.string": { "name": "value" } },
+ { "field.string": { "name": "reasoning" } }
+ ]}},
+ { "object.value": { "name": "ClassificationResponse", "children": [
+ { "field.string": { "name": "documentType" } },
+ { "field.decimal": { "name": "confidence" } },
+ { "field.object": { "name": "note", "@objectRef": "NoteEntry" } }
+ ]}},
+ { "template.output": { "name": "ClassificationResponseTemplate",
+ "@payloadRef": "ClassificationResponse", "@textRef": "ai/classification-response", "@format": "text" } }
+ ]}}
+ """;
+ var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList();
+
+ var file = Assert.Single(files);
+ Assert.Equal("ClassificationResponse.payload.cs", file.Path);
+ Assert.Contains("public sealed record ClassificationResponse", file.Content);
+ Assert.Contains("public required string documentType { get; init; }", file.Content);
+ Assert.Contains("public required double confidence { get; init; }", file.Content);
+ Assert.Contains("public required NoteEntry note { get; init; }", file.Content);
+ Assert.Contains("public sealed record NoteEntry", file.Content);
+ Assert.Contains("public required string value { get; init; }", file.Content);
+ // The FQN must not leak into the file name or the record/type names.
+ Assert.DoesNotContain("acme::intake::", file.Content);
+ Assert.DoesNotContain("::", file.Path);
+ }
+
+ [Fact]
+ public void Emits_one_file_per_template_output_with_expected_path_and_class()
+ {
+ const string m = """
+ { "metadata.root": { "package": "acme::ai", "children": [
+ { "object.value": { "name": "AlphaPayload", "children": [ { "field.string": { "name": "name" } } ] } },
+ { "object.value": { "name": "BetaPayload", "children": [ { "field.int": { "name": "n" } } ] } },
+ { "template.output": { "name": "Alpha", "@payloadRef": "AlphaPayload", "@textRef": "a/x", "@format": "json" } },
+ { "template.output": { "name": "Beta", "@payloadRef": "BetaPayload", "@textRef": "b/x", "@format": "json" } }
+ ]}}
+ """;
+ var files = new PayloadGenerator().Generate(Ctx(Load(m))).OrderBy(f => f.Path).ToList();
+ Assert.Equal(2, files.Count);
+ Assert.Equal("AlphaPayload.payload.cs", files[0].Path);
+ Assert.Equal("BetaPayload.payload.cs", files[1].Path);
+ Assert.Contains("public sealed record AlphaPayload", files[0].Content);
+ Assert.Contains("public sealed record BetaPayload", files[1].Content);
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs
index 37a4818a3..3d2307366 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs
@@ -95,6 +95,8 @@ protected virtual EmittedFile EmitPayload(string payloadRef, GenContext ctx)
sb.AppendLine($"namespace {ctx.Config.Namespace};");
sb.AppendLine();
sb.Append(records);
- return new EmittedFile($"{payloadRef}.payload.cs", sb.ToString());
+ // payloadRef may be the FQN form (ADR-0042 attr resolution) — the file name is the
+ // bare record name, matching PayloadCodegen.EmitRecord's own stripping.
+ return new EmittedFile($"{CSharpNaming.StripPkg(payloadRef)}.payload.cs", sb.ToString());
}
}
diff --git a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
index b8d0f56ec..c521106ac 100644
--- a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
+++ b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
@@ -128,6 +128,14 @@ private static List CollectEnumDecls(MetaData vo)
private static void EmitRecord(MetaData root, string voName, HashSet emitted, List output)
{
+ // A @payloadRef may reach here fully-qualified — ADR-0042 resolves a bare template
+ // attr to the FQN form, and MetaData.Attr() returns that resolved (possibly
+ // package-qualified) value, not the literal string the author typed. FindObject and
+ // the emitted record's own type name both need the bare short name (a C# record
+ // identifier can't contain "::" and isn't package-scoped); nested @objectRef fields
+ // already arrive here pre-stripped via FieldType's CSharpNaming.StripPkg call, so
+ // stripping here is a no-op for them and the fix for the top-level payloadRef case.
+ voName = CSharpNaming.StripPkg(voName);
if (!emitted.Add(voName)) return;
var vo = FindObject(root, voName);
if (vo is null) return;