Skip to content

feat(metamodel): consolidate projection/payload vocabulary (#210, #212) - #276

Merged
dmealing merged 10 commits into
mainfrom
feat/role-shrink-and-210
Aug 8, 2026
Merged

feat(metamodel): consolidate projection/payload vocabulary (#210, #212)#276
dmealing merged 10 commits into
mainfrom
feat/role-shrink-and-210

Conversation

@dmealing

@dmealing dmealing commented Aug 8, 2026

Copy link
Copy Markdown
Member

Intent

Units C+D of the projection/payload vocabulary batch — the pre-1.0 BREAKING consolidation, closing #212 and #210, shipping as a coordinated 0.21.0 / Maven 7.21.0 MINOR across all four registries.

THREE breaking changes for metadata authors:
(1) Assembly origins (origin.aggregate/computed/collection/first) are now ILLEGAL on an object.value-hosted field — ERR_SUBTYPE_RULE_VIOLATION. origin.passthrough deliberately STAYS legal on a value: there it is FR-015 parameter lineage, not assembly, and the loaders already drew exactly that line via the FR-024 B5 value-host exemption. Retiring it too would have silently dropped the ERR_PASSTHROUGH_TYPE_MISMATCH check on stored-proc arguments. The durable rule encoded is 'passthrough on a value is lineage; assembly origins live on projections' (ADR-0028).
(2) source.rdb @ROLE shrinks from six registered members to primary|replica; index/cache/publish/mirror become reserved-not-registered. Justification: no port ever built the role-routing dispatch these anticipated — every read of @ROLE in all five ports is an equality test against primary, so the consumed information was one bit. An adopter scan across this repo, the public reference app and downstream consumer models found zero uses. Pruning now is the reversible direction (removing a registered member post-1.0 would be a 2.0 event; re-adding a reserved one is additive).
(3) A payload's nested field.object @objectref must target an object.value. TypeScript, C# and Python previously accepted a non-value target AND emitted code from it, so this is a genuine third break, not a tightening. It is implemented as ONE fail-closed loader rule rather than five per-port codegen filters, deliberately — the ports disagreed, and an independent adjudication ruled that filtering one port's payload closure without its extract closure would emit mappers constructing classes that no longer exist.

The migration path is additive: @payloadRef/@responseRef now accept a sourceless object.projection, so a payload that was assembling values re-hosts as a projection and keeps its origins.

Deliberate design choices a reviewer could not infer: the assembly-origin ban is implemented as a named subtype SET hoisted above the origin dispatch in every loader, so cross-port coverage is a property of the constant rather than of four separate branches. Nested payload targets deliberately stay value-only — the widen covers template-level refs only, and that is recorded in the commit. No new vocabulary, attributes or error codes anywhere (ADR-0023); the registry diff is one shrunk allowedValues plus three object.value/object.projection doctrinal strings.

Also in this branch: a raw NUL byte in constraint-merge.ts became an escape. That is not cosmetic housekeeping — a companion instance in the Java port made that file test as BINARY, so this environment's binary-skipping grep silently returned nothing for it, which mis-scoped an earlier unit onto a false premise. These were the last two NUL-bearing files in the repo.

Cut as MINOR not PATCH because pre-1.0 a caret range ^0.20.x resolves <0.21.0 — a patch would be auto-adopted on a routine npm update and adopters' metadata would stop loading with no deliberate action.

Already cleared by: per-unit task reviews with fix rounds, a release-grade whole-branch review that initially said DON'T SHIP (it found a shipped agent-context skill instructing agents to author now-illegal metadata — that skill ships inside @metaobjectsdev/sdk and installs into consumer repos), a doctrine-surface fix wave, and a scoped re-review that independently re-ran the sweep with a spelling-agnostic pattern and char-checked the migration guide's quoted loader errors against source.

What Changed

  • Assembly origins banned on object.valueorigin.aggregate/computed/collection/first on a value-hosted field now fail load with ERR_SUBTYPE_RULE_VIOLATION in all five ports, enforced via a shared hoisted subtype-set constant placed above the origin dispatch (cross-port coverage is a property of the constant, not four separate branches); origin.passthrough stays legal (it is FR-015 parameter lineage there). As the additive migration path, @payloadRef/@responseRef now accept a sourceless object.projection, so an assembly-origin payload re-hosts as a projection and keeps its origins.
  • source.rdb @role shrinks to primary|replicaindex/cache/publish/mirror become reserved-not-registered and the registry allowedValues manifest drops to two members; every port's @role read was already an equality test against primary, so the consumed information was one bit.
  • Nested payload @objectRef must target an object.value — enforced by a single fail-closed loader rule rather than five per-port codegen filters; TypeScript, C# and Python previously accepted a non-value target and emitted code from it. Also writes the NUL join delimiter in constraint-merge.ts as an escape, removing the last binary-detected file in the repo.

Risk Assessment

⚠️ Medium: The Round-1 finding (the dbImport/dialect guard keying on object subtype rather than source, which made the new migration-recommended sourceless-projection payload spuriously demand database config) is now fixed by aligning the guard with the #248 source-based predicate the generators already honor, hardened with a positive-case and a new regression test — leaving a coherent, cross-port-consistent, well-tested 5-port BREAKING consolidation with no outstanding source findings, substantial enough that 'low' would understate a coordinated pre-1.0 minor but safe to merge.

Testing

Exercised the change entirely against the TypeScript reference port's source (the only port with a locally-runnable test suite here; the repo runs Java/C#/Python/Kotlin gates on hosted CI only). All targeted tests pass: the new #210 unit test (12 cases), the full TS conformance corpus (532, including all 8 new cross-port error/migration fixtures — each verified a real pass, not a ledger gap), registry-conformance (8, locking the shrunk @ROLE), the codegen payload/vo-only tests (20), the constraint-merge tests after the NUL→escape fix (12), and the modified CLI/codegen/errors tests (15). I additionally drove the loader + payload codegen directly from worktree src to produce reviewer-visible end-to-end evidence: the migration path (a value-hosted assembly origin re-hosted onto a sourceless object.projection) loads with zero errors and emits correct payload TS, while each of the three breaking changes fires its exact error code and each legal counterpart loads clean — including confirmation that origin.passthrough kept its ERR_PASSTHROUGH_TYPE_MISMATCH type-preservation check on values. No failures found. One environmental gotcha resolved mid-testing: scripts run from /tmp resolved @metaobjectsdev/metadata to a stale/non-workspace copy (pre-widen behavior); I re-ran everything against the absolute src paths the tests use. Cross-port loader enforcement is verified via the shared conformance contract here, with each port's own gate owned by remote CI.

Evidence: Migration path end-to-end (load + emitted payload TS)

LOAD errors: (none — migration-path model loads clean) === Emitted payload TS — AuthorReport (sourceless object.projection as @payloadRef) === export interface AuthorReport { name: string; postCount?: number | null; summary?: string | null; } === Emitted payload TS — ReviewRequest (object.value, nested value-only) === export interface ReviewRequest { instructions?: string | null; note?: AuthorNote | null; } export interface AuthorNote { text: string; }

LOAD errors: (none — migration-path model loads clean)

=== Emitted payload TS — AuthorReport (sourceless object.projection as @payloadRef) ===

export interface AuthorReport {
  name: string;
  postCount?: number | null;
  summary?: string | null;
}

=== Emitted payload TS — ReviewRequest (object.value, nested value-only) ===

export interface ReviewRequest {
  instructions?: string | null;
  note?: AuthorNote | null;
}

export interface AuthorNote {
  text: string;
}
Evidence: The three breaking changes + legal carve-outs (against src)

(1) origin.aggregate on value -> [ "ERR_SUBTYPE_RULE_VIOLATION" ] (1) origin.computed on value -> [ "ERR_SUBTYPE_RULE_VIOLATION" ] (1) origin.collection on value -> [ "ERR_SUBTYPE_RULE_VIOLATION" ] (1) origin.first on value -> [ "ERR_SUBTYPE_RULE_VIOLATION" ] ( ) origin.passthrough on value -> [ "(clean — loads)" ] (2) @role: index -> [ "ERR_BAD_ATTR_VALUE" ] ( ) @role: replica (legal) -> [ "(clean — loads)" ] (3) nested @objectRef->entity -> [ "ERR_SUBTYPE_RULE_VIOLATION" ] ( ) nested @objectRef->value -> [ "(clean — loads)" ]

(1) origin.aggregate on value     -> [ "ERR_SUBTYPE_RULE_VIOLATION" ]
(1) origin.computed on value     -> [ "ERR_SUBTYPE_RULE_VIOLATION" ]
(1) origin.collection on value     -> [ "ERR_SUBTYPE_RULE_VIOLATION" ]
(1) origin.first on value     -> [ "ERR_SUBTYPE_RULE_VIOLATION" ]
( ) origin.passthrough on value -> [ "(clean — loads)" ]
(2) @role: index               -> [ "ERR_BAD_ATTR_VALUE" ]
( ) @role: replica (legal)     -> [ "(clean — loads)" ]
(3) nested @objectRef->entity  -> [ "ERR_SUBTYPE_RULE_VIOLATION" ]
( ) nested @objectRef->value    -> [ "(clean — loads)" ]
Evidence: ERR_PASSTHROUGH_TYPE_MISMATCH preserved on values (passthrough carve-out rationale)

passthrough string->int on value (mismatch): [ "ERR_PASSTHROUGH_TYPE_MISMATCH" ] passthrough string->string on value (ok) : [ "(clean)" ]

passthrough string->int on value (mismatch): [ "ERR_PASSTHROUGH_TYPE_MISMATCH" ]
passthrough string->string on value (ok)   : [ "(clean)" ]
Evidence: Migration-path demo source (loads + generates payload codegen from src)
// End-to-end migration-path demo — imports worktree SRC directly (like the tests).
import { MetaDataLoader, InMemoryStringSource } from "/home/doug/.no-mistakes/worktrees/4a36a911fd68/01KZF8ZNEVAQQH4VFVHSVSJ20E/server/typescript/packages/metadata/src/index.ts";
import { generatePayloadInterfaces } from "/home/doug/.no-mistakes/worktrees/4a36a911fd68/01KZF8ZNEVAQQH4VFVHSVSJ20E/server/typescript/packages/codegen-ts/src/index.ts";

const model = {
  "metadata.root": { package: "demo", children: [
    { "object.entity": { name: "Author", children: [
      { "source.rdb": { "@table": "authors" } },
      { "field.uuid": { name: "id" } },
      { "field.string": { name: "name", "@required": true } },
      { "relationship.aggregation": { name: "posts", "@objectRef": "demo::Post", "@cardinality": "many" } },
      { "identity.primary": { name: "pk", "@fields": ["id"] } },
    ] } },
    { "object.entity": { name: "Post", children: [
      { "source.rdb": { "@table": "posts" } }, { "field.uuid": { name: "id" } },
      { "identity.primary": { name: "pk", "@fields": ["id"] } },
    ] } },
    // MIGRATION: a value that used to host assembly origins -> SOURCELESS object.projection.
    // No source.rdb; the assembly origin (origin.aggregate) re-hosts here legally.
    { "object.projection": { name: "AuthorReport", children: [
      { "field.string": { name: "name", "extends": "demo::Author.name" } },
      { "field.long": { name: "postCount", children: [
        { "origin.aggregate": { "@agg": "count", "@of": "demo::Post.id", "@via": "demo::Author.posts" } } ] } },
      { "field.string": { name: "summary" } },
    ] } },
    // value object that still nests a value (nested targets stay value-only)
    { "object.value": { name: "AuthorNote", children: [
      { "field.string": { name: "text", "@required": true } },
    ] } },
    { "object.value": { name: "ReviewRequest", children: [
      { "field.string": { name: "instructions" } },
      { "field.object": { name: "note", "@objectRef": "demo::AuthorNote" } },
    ] } },
    // @payloadRef -> sourceless projection (the widen) + @responseRef -> value
    { "template.prompt": {
      name: "authorPrompt", "@payloadRef": "AuthorReport",
      "@responseRef": "ReviewRequest", "@textRef": "prompts/author", "@format": "xml",
    } },
  ] },
};
const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(model))]);
console.log("LOAD errors:", errors.length ? JSON.stringify(errors.map(e=>e.code)) : "(none — migration-path model loads clean)");
if (errors.length) { console.log(JSON.stringify(errors, null, 2)); process.exit(1); }
console.log("\n=== Emitted payload TS — AuthorReport (sourceless object.projection as @payloadRef) ===\n");
console.log(generatePayloadInterfaces(root, "AuthorReport", "demo"));
console.log("=== Emitted payload TS — ReviewRequest (object.value, nested value-only) ===\n");
console.log(generatePayloadInterfaces(root, "ReviewRequest", "demo"));
Evidence: Breaking-changes demo source (three breaks + carve-outs, from src)
// The THREE breaking changes + the passthrough-stays-legal nuance, against worktree src.
import { MetaDataLoader, InMemoryStringSource } from "/home/doug/.no-mistakes/worktrees/4a36a911fd68/01KZF8ZNEVAQQH4VFVHSVSJ20E/server/typescript/packages/metadata/src/index.ts";
async function codes(children) {
  const { errors } = await new MetaDataLoader().load([
    new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: "demo", children } }))]);
  return errors.length ? errors.map(e => e.code) : ["(clean — loads)"];
}
const Ent = (extra=[]) => ({ "object.entity": { name: "Author", children: [
  { "source.rdb": { "@table": "authors" } }, { "field.uuid": { name: "id" } },
  { "field.string": { name: "name" } }, ...extra,
  { "identity.primary": { name: "pk", "@fields": ["id"] } },
] } });
const Note = { "object.value": { name: "Note", children: [{ "field.string": { name: "t" } }] } };

for (const sub of ["aggregate","computed","collection","first"]) {
  const origin = sub==="aggregate" ? { "@agg":"count","@of":"demo::Author.id" }
    : sub==="computed" ? { "@expr": { op:"isNotNull", arg:{field:"name"} } }
    : sub==="first" ? { "@of":"demo::Author.name","@orderBy":["name:desc"] }
    : { "@via":"demo::Author.books" };
  const key = "origin." + sub;
  const fld = sub==="collection"
    ? { "field.object": { name:"x", isArray:true, "@objectRef":"demo::Note", children:[{ [key]: origin }] } }
    : sub==="computed" ? { "field.boolean": { name:"x", children:[{ [key]: origin }] } }
    : sub==="first" ? { "field.string": { name:"x", children:[{ [key]: origin }] } }
    : { "field.int": { name:"x", children:[{ [key]: origin }] } };
  console.log("(1) origin."+sub+" on value     ->", await codes([Ent(), Note, { "object.value": { name:"Bad", children:[fld] } }]));
}
console.log("( ) origin.passthrough on value ->", await codes([Ent(), { "object.value": { name:"Args", children:[
  { "field.string": { name:"n", children:[{ "origin.passthrough": { "@from":"demo::Author.name" } }] } }] } }]));
console.log("(2) @role: index               ->", await codes([Ent([{ "source.rdb": { "@role":"index" } }])]));
console.log("( ) @role: replica (legal)     ->", await codes([{ "object.entity": { name:"E", children:[
  { "source.rdb": { "@table":"e" } }, { "source.rdb": { "@role":"replica", "@kind":"view", "@view":"v" } },
  { "field.uuid": { name:"id" } }, { "identity.primary": { name:"pk","@fields":["id"] } }] } }]));
console.log("(3) nested @objectRef->entity  ->", await codes([Ent(), Note, { "object.value": { name:"Req", children:[
  { "field.object": { name:"a", "@objectRef":"demo::Author" } }] } },
  { "template.prompt": { name:"P","@payloadRef":"Req","@textRef":"x/y","@format":"xml" } }]));
console.log("( ) nested @objectRef->value    ->", await codes([Ent(), Note, { "object.value": { name:"Req", children:[
  { "field.object": { name:"a", "@objectRef":"demo::Note" } }] } },
  { "template.prompt": { name:"P","@payloadRef":"Req","@textRef":"x/y","@format":"xml" } }]));

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ server/typescript/packages/codegen-ts/src/runner.ts:160 - The dbImport/dialect guard keys on !e.isAbstract &amp;&amp; e.subType !== OBJECT_SUBTYPE_VALUE, but this change makes the sourceless object.projection a first-class, migration-recommended payload shape (the migration guide tells adopters to re-host assembly-origin payloads as sourceless projections, and @payloadRef/@responseRef now accept one). A sourceless projection emits NO database code — queriesFile/routesFile/apiModel all gate emission on hasAnyRdbSource (source-detect.ts, which returns false for a projection with no source.rdb), and the migration guide itself states 'a sourceless projection has no DDL, so meta migrate emits nothing for it.' Yet the guard counts a sourceless projection as 'db-emitting' and hard-throws requiring dialect+dbImport (it runs BEFORE normalizeConfig fills its inert placeholders). Concrete reachable path: a prompt-only model (no sourced entity) whose payload is a sourceless projection — e.g. an origin.computed @expr-only or origin-free self-declared projection that references no entity — fails codegen with 'codegen config is missing dialect and dbImport' for code that is never emitted. The common assembly-origin migration is unaffected (aggregate/collection/first origins reference sourced entities, so the guard trips legitimately anyway); only the computed-only/origin-free-in-a-no-entity-project edge case bites. The principled fix is a one-line alignment with the Persistability is decided by a hardcoded subType === "value" compare — any custom object subtype silently becomes a table #248 R2 invariant the generators already honor — filter on hasAnyRdbSource(e) rather than subType !== OBJECT_SUBTYPE_VALUE — which is exactly the source-based predicate this same branch already applied to the example's own config filter (isPersisted in examples/advanced-modeling/metaobjects.config.ts:37). Flagging ask-user because it governs a user-facing config requirement / when codegen errors; recommend folding the guard alignment into this breaking release since the author already established the correct predicate.

🔧 Fix: fix(runner): gate dbImport/dialect guard on source.rdb, not object subtype
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • cd server/typescript && bun test packages/metadata/test/template-payload-target-210.test.ts (12 pass — all three breaks + migration path + nested-value-only, against src)
  • cd server/typescript && bun test packages/metadata/test/conformance.test.ts (532 pass — full TS conformance corpus incl. all 8 new fixtures: error-value-origin-{aggregate,computed,collection,first}, error-source-role-reserved, error-payload-nested-object-ref-entity, error-template-payload-ref-sourced-projection, template-payload-ref-sourceless-projection)
  • Per-fixture classification (runFixture + classifyAgainstLedger): all 8 new fixtures classify as real 'pass', none in conformance-expected-failures.json ledger
  • cd server/typescript && bun test packages/codegen-ts/test/payload-codegen.test.ts packages/codegen-ts/test/vo-only-config-optional.test.ts packages/metadata/test/origin-collection.test.ts (20 pass — codegen side: sourceless projection needs no DB config + payload emit)
  • cd server/typescript && bun test packages/metadata/test/registry-conformance.test.ts (8 pass — shrunk @role allowedValues manifest byte-matches)
  • cd server/typescript && bun test packages/metadata/test/constraint-merge.test.ts packages/metadata/test/constraint-validate.test.ts (12 pass — NUL→escape fix exercised)
  • cd server/typescript && bun test packages/codegen-ts/test/fr004-verify-demo.test.ts packages/cli/test/integration/verify.test.ts packages/cli/test/unit/payload-field-tree.test.ts packages/metadata/test/errors.test.ts (15 pass — modified test files + error-code ledger)
  • Manual end-to-end (worktree src): migration-path model (sourceless projection @payloadRef w/ origin.aggregate + nested-value @responseRef) loads clean and emits payload TS interfaces
  • Manual end-to-end (worktree src): each of the three breaks fires its exact code (ERR_SUBTYPE_RULE_VIOLATION / ERR_BAD_ATTR_VALUE) and each legal counterpart loads clean
  • Manual check (worktree src): ERR_PASSTHROUGH_TYPE_MISMATCH still fires for a string→int passthrough on a value (carve-out rationale preserved)
  • Byte check: tr -cd '\000' on constraint-merge.ts = 0 raw NUL at target (1 at base 726f9debe); delimiter now written as ""
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ℹ️ CLAUDE.md:443 - The always-loaded taxonomy summary lists only 'Origin subtypes: passthrough, aggregate', omitting computed/collection/first, which have been registered subtypes since the 0.17.0 line (meta migrate: aggregate/monitoring read-model views can't be modeled as projections — 4 missing origin/field capabilities #195). This is PRE-EXISTING — not made stale by this change, which bans assembly origins on object.value without altering the registered origin-subtype set. Flagging as a follow-up because the line is directly adjacent to this change's subject (which origin subtypes are permitted where); leaving it risks a future session believing only two origin subtypes exist. Out of scope to fix here per scope-discipline (do not opportunistically rewrite unrelated documentation).

🔧 Fix: docs: complete origin subtypes plus #210 host rule
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 10 commits August 7, 2026 17:31
…pe, not a raw byte

childRuleId() joins its composite-key parts on NUL — sound as a delimiter
(cannot collide with a real name) — but the delimiter was written as a raw
0x00 byte inside the string literal, which made the whole file test as
binary: file(1) reported it as "data", and binary-skipping text-search
tools ignored it silently, returning no output rather than an error. A
sibling instance of this same defect in the Java port caused a cross-port
code audit to mis-classify that file. This was the last NUL-bearing
tracked source file in the repo.

The literal is now the escape sequence backslash-u0000 (unambiguous in TS;
avoids the deprecated-octal reading backslash-0 can take before a digit).
The runtime string is byte-identical, confirmed by the constraint-merge
tests, and the file now tests as UTF-8 text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
Closes #212's actionable content. @ROLE registered six members (primary,
replica, index, cache, publish, mirror), but cross-port verification
established that every read of @ROLE in all five ports is an equality test
against primary — no consumer ever dispatched on the other five, so the
consumed information content is one bit and the four unused members were
indistinguishable from replica. Per the 2026-08-05 ruling, allowedValues
shrinks to ["primary", "replica"]; index/cache/publish/mirror become
reserved-not-registered (the ADR-0040 treatment) with the re-entry bar
recorded in the registered description: a role member enters the registry
only when a shipping consumer dispatches on it.

An adopter scan over this repo, the public reference app and downstream
consumer models found zero uses of the four retired members.

Mechanics, all five ports (Kotlin composes from the JVM registry):
- spec/metamodel/db.json + the regenerated TS embedded definition + the
  committed Python/C# spec copies carry the two-member list.
- Per-port constants for the four retired members are deleted outright
  (TS source-constants.ts, Java MetaSource/RdbSource, C# SourceConstants,
  Python source_constants); the roles arrays shrink to the two survivors.
  C# SourceSchema's in-code description byte-matches db.json.
- expected-registry.json and the metamodel docs regenerate; both diffs are
  confined to the @ROLE attribute block/row.
- New conformance fixture error-source-role-reserved: a retired member now
  fails load with ERR_BAD_ATTR_VALUE via the existing generic
  allowedValues check — no new error code, no new validation pass.
- spec/roadmap.md marks #212 shipped (the ADR-0007/0028/FR-024 doc
  amendments landed with the ruling).

No behavior dispatch changes anywhere (none existed); codegen golden
output is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
…end stale research-doc claim (#212)

Review follow-ups on the #212 @ROLE shrink, both stale six-member
enumerations the constant-name straggler grep could not see (they were
free-text literals, not constant reads):

- ValidationPhase.validateSourceNode emitted a self-contradictory error on
  the exact path the new error-source-role-reserved fixture exercises:
  "@ROLE 'publish' is not a valid value; allowed: primary, replica, index,
  cache, publish, mirror" — a hand-written allowed-list listing the
  rejected value among the allowed ones, and a cross-port diagnostic
  divergence (TS derives its list from the registry spec). The message now
  derives from MetaSource.VALID_ROLES via a TreeSet (sorted — Set.of
  iteration order is unspecified, and a non-deterministic error string
  would be its own defect), so it cannot drift from the registered set
  again. The stale block comment above it is corrected too. The sibling
  hand-written @kind list is pre-existing and deliberately left alone.

- docs/superpowers/specs/2026-08-02-multi-persistence-architecture-research.md
  asserted present-tense that source-constants.ts "already ships" the
  six-member SOURCE_ROLES — now false, and this file names the very source
  file the shrink changed. Amended with a dated header note in the same
  style as the 2026-05-23 source-v2 design spec (specs asserting current
  behavior get amended; plans stay immutable records), without rewriting
  the research.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
…ad refs to sourceless projections (#210)

Closes #210, per the 2026-08-05 ruling. The durable rule: passthrough on a
value is lineage; assembly origins live on projections.

Half 1 — a field hosted on an object.value may no longer carry
origin.aggregate, origin.computed, origin.collection or origin.first: all
four loaders (TS / Python / Java — Kotlin inherits it — / C#) now reject the
shape with ERR_SUBTYPE_RULE_VIOLATION at the offending origin node, driven by
a new ASSEMBLY_ORIGIN_SUBTYPES constant per port. origin.passthrough STAYS
legal on a value — FR-015 parameter lineage, the FR-024 B5 exemption — and
keeps its ERR_PASSTHROUGH_TYPE_MISMATCH check. The now-unreachable value-host
branches inside the aggregate/first validation arms are removed.

Half 2 — @payloadRef / @responseRef accept an object.value OR a SOURCELESS
object.projection ("sourceless" per the #248 persistability contract: no
declared/inherited source.* child; unambiguous for a concrete projection
since ERR_PROJECTION_INHERITED_SOURCE). A sourced projection stays
ERR_INVALID_TEMPLATE. Message widened identically in all four loaders:
"does not resolve to an object.value or sourceless object.projection at
root". Codegen resolvers widen in all five ports (TS payload-codegen was
already subtype-blind — doc updated; TS prompt-render's batch filter, Python
resolve_payload_vo, Java SpringNaming.resolveValueObjectRef, Kotlin
KotlinGenUtil.resolveValueObjectRef, C# RenderHelperGenerator.
ResolveValueObject). Payload emitters walk projection fields with resolving
accessors (ADR-0039) — pinned by the extends-bound field in the new positive
fixture across every port's runner.

NESTED payload targets stay value-only — a deliberate decision, not an
omission: a payload field's `field.object @objectRef` must resolve to an
object.value, now enforced FAIL-CLOSED in all four loaders
(ERR_SUBTYPE_RULE_VIOLATION on the field node; the #219/ADR-0044-adjudicated
gap where TS/C#/Python codegen accepted an entity nested target while
Kotlin/Java filtered). The per-port codegen filters stay as belt-and-braces.

Registry: object.value's rules + description drop the "by assembly"
construction mode and state the new rule; object.projection's rules gain the
complement sentence. expected-registry.json, the embedded TS definition, the
Python/C# spec copies and the metamodel docs regenerate — diffs confined to
those strings.

Seven new conformance fixtures gate all four loaders:
error-value-origin-{aggregate,computed,collection,first},
template-payload-ref-sourceless-projection (positive, expected +
expected-effective), error-template-payload-ref-sourced-projection,
error-payload-nested-object-ref-entity. Plus per-port loader unit tests (TS +
Python).

Re-hosted as sourceless projections: the codegen-kotlin payload-with-origins
fixture (its snapshot is byte-identical — payload emitters are
declared-type-authoritative per #270), origin-collection-simple, the
examples/advanced-modeling payload (generated output regenerated under its
drift gate; its config filter now keys on persistability), and every port
test that loaded an assembly origin on a value host (TS x5 files, Kotlin x7
models, Java Spring x11 hosts, C# x4 models). Python payload-emitter tests
construct trees programmatically (no loader) and stay as #270's
defense-in-depth pins.

Docs: ADR-0028 amended (assembly origins leave object.value); roadmap #210
marked shipped; new migration guide
docs/features/migrations/value-assembly-origins-and-source-role-shrink.md
covering this change AND the #212 @ROLE shrink; templates-and-payloads.md
target-set statements widened; CONFORMANCE.md fixture count 263 -> 270.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
…ession, doc-comment widen sweep, pass-level nested-visited

Review round 1 on the #210 landing, all findings addressed:

- ADR-0028's Consequences still asserted the retired rule ("value+origin.*
  payloads remain valid ... no migration is forced") 35 lines below the
  amendment that retired it — in the very ADR the loader's new error messages
  cite. Annotated in place (the Decision-3 supersession style): the clause now
  holds only for origin.passthrough; a migration IS forced for assembly
  origins, pointer to the migration guide. The hazard-2 lesson one layer up:
  sweep prose asserting the old rule, not just the subtype names.
- Six stale "must be an object.value" doc comments widened: C#
  RenderHelperGenerator (header skip-contract, inline @payloadRef comment, and
  the AppliesTo XML doc, which misdescribed the widened resolver it delegates
  to), TS prompt-render-file header, and both payload emitters'
  GeneratePayloadRecords/generatePayloadInterfaces docs (now say
  subtype-blind; the loader owns the target-set constraint).
- TS resolveEmittedName's doc claimed a subtype filter the code never
  implemented (findObject is subtype-blind); it now says exactly that.
- TS template-payload-target-210 test gains the Python mirror's drift guard:
  the originNode table must set-equal ASSEMBLY_ORIGIN_SUBTYPES, so a fifth
  assembly origin cannot pass vacuously via children:[undefined].
- The nested value-only walk's visited set hoists to the pass level in
  TS/Python/C#: a bad payload shared by N templates now reports ONCE,
  converging on Java's throw-on-first single-error count.
- examples/advanced-modeling config: dropped the unreachable
  value-subtype clause from the filter (a value can never declare a source,
  loader-enforced) and removed the filter from routesFile (its built-in #248
  persistability gate covers it; regen verified byte-identical, 26 unchanged).
  formFile keeps the filter as the one generator that needs it.

Reruns green: TS metadata 2312 / codegen-ts 1036 + golden 141 / drift gate;
Python loader+conformance 391; C# Conformance 858 + Codegen 343; workspace
typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
…hing the retired shape; migration guide covers all three breaking changes

Final whole-branch review found no code defect but blocked the cut on the
doctrine surface. Everything here is docs, skills, or comments; the one source
file touched is a C# doc comment.

CRITICAL — the shipped agent-authoring skills taught un-loadable metadata:

- metaobjects-audit SKILL.md's prompt anti-pattern prescribed "an object.value
  with origin.* (passthrough / aggregate / collection) fields" — metadata the
  loader now rejects, in a skill that installs into consumer repos via
  @metaobjectsdev/sdk. Rewritten to the #210 rule (value = passthrough-only
  lineage; assembly origins on a sourceless projection, which @payloadRef
  accepts). Method lesson recorded: the unit-D sweep grepped the literal
  origin.<subtype> spellings, but this file writes the vocabulary as bare
  member names inside an origin.* summary — the sweep was re-run across
  agent-context/ with a spelling-agnostic pattern (bare
  aggregate|collection|computed|first near value/payload) and every hit read.
- metaobjects-prompts SKILL.md never received the @payloadRef widen: it
  pointed at projections for derivation while requiring the payload be an
  object.value — closing this release's own migration path in the same file.
  The attr table, the section heading/body, and the @responseRef line now
  state the widened target set; the nested value-only rule is noted as
  loader-enforced.
- The four fixtures/agent-context-conformance expected trees regenerated via
  the corpus regen script (byte-gate green); the sdk-bundled copy is build
  output (gitignored), re-bundled locally.

The migration guide now frames THREE breaking changes, not two: the
value-only nested payload-target rule gets its own section with the error and
a rewrite (declare a curated object.value, optionally extends-bound, and
repoint @objectref), since TS/C#/Python previously loaded AND emitted from a
non-value nested target. Its "byte-identical across the re-host" promise is
scoped to the host-subtype change alone, with an explicit warning that an
extends anchor can flip a field's optionality (the canonical example's
title?: -> title: diff is cited). The @ROLE error quote is marked
wording-varies-by-port (Java differs), and the ERR_SOURCE_NO_PRIMARY note now
says what is actually new (seeing both errors, not the second appearing).

Reachability + staleness:

- 0.x-to-1.0.md gains section 7 for the three changes, linking the guide (old
  section 7 renumbers to 8; no inbound anchors existed).
- source-kinds.md's multi-source section rewritten off the six-member world
  and the never-built "route by @ROLE" dispatch (ADR-0007 Amendment 2 got the
  retraction; the user-facing doc now matches): @ROLE is primary|replica, a
  designation not a routing mechanism; links the guide.
- templates-and-payloads.md: the surviving "the payload IS an object.value"
  aside widened; See-also links the guide.
- spec/roadmap.md: #271 marked SHIPPED (efe12d4) instead of an unshipped
  blocking prelude; FR-021's "object.value projections with the same origin.*
  machinery" parenthetical corrected to the post-#210 split.
- docs/CONFORMANCE.md L72 fixture count 263 -> 270 (L28 was already updated;
  the file contradicted itself).
- C# MetaSource.cs doc comment: "@ROLE (primary/replica/...)" loses the
  open-enum ellipsis — the set is closed (#212).
- advanced-modeling config comment: entityFile does NOT skip sourceless
  shapes — it emits shape-only output; queriesFile/routesFile skip. The
  rewrite had flattened the nuance the prior comment carried.

Gates: sdk suite 150 pass (incl. the agent-context byte gate over all four
stacks); C# build clean; advanced-modeling regen still "26 unchanged" + drift
gate green. CHANGELOG.md deliberately untouched (owned by the release
controller).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
This cut carries THREE breaking changes, not the two the plan anticipated. The
third surfaced in final review: a payload's nested field.object @objectref must
now target an object.value, and TypeScript, C# and Python previously accepted a
non-value target AND emitted code from it -- so it is a real break for those
ports, not a tightening of an existing rule.

Cut as MINOR rather than PATCH on the mechanical argument, not the doctrinal
one: pre-1.0, a caret range ^0.20.x resolves <0.21.0, so a patch would be
auto-adopted on a routine update and adopters' metadata would stop loading with
no deliberate action on their part. MINOR makes them opt in, which is what the
pre-1.0 breaking slot exists for. RELEASING.md's own litmus test agrees -- a
consumer on ^prev cannot run `npm update && meta gen` and still get working
output.

The changelog entry leads with a banner enumerating all three breaks and links
the migration guide from the top rather than burying it, and states plainly that
origin.passthrough is unaffected -- the most likely thing for an author to
over-correct on.

Also pins the migration hub's section 7 to the real version (it was written
anticipatorily as "0.20.x") and fixes two cosmetic slips in the guide: a
now-singular "errors you'll see" heading, and a stray double blank line left by
moving the nested-target error into its own section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3msoGxjRMwx94PhKSLDuE
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