Skip to content

feat(validation): metadata reference enforcement + validation on the type registry + collect-all (5 ports)#52

Merged
dmealing merged 21 commits into
mainfrom
feat/metadata-validation
Jun 21, 2026
Merged

feat(validation): metadata reference enforcement + validation on the type registry + collect-all (5 ports)#52
dmealing merged 21 commits into
mainfrom
feat/metadata-validation

Conversation

@dmealing

Copy link
Copy Markdown
Member

Stacked on #44 (config-driven identity default-name). Once #44 merges this retargets to main.

What this does

Makes dangling metadata references fail the load, and reworks validation so it's derived from each type's registration rather than hand-coded per pass — across all five ports.

  1. Reference enforcement (the "bogus refs should fail" ask). An unresolved relationship.@objectRefERR_INVALID_RELATIONSHIP; an unresolved identity.reference.@referencesERR_INVALID_REFERENCE, each with a source envelope pinpointing the node. Catches metadata drift immediately (a renamed/removed entity surfaces at load).
  2. Validation on the TypeDefinition. Each type's registration carries its cross-reference descriptors (+ an optional validator); one registry-driven walk enforces them. A downstream provider's new type validates itself with no core changes.
  3. A load reports every error, not just the first (Java cross-pass collect-all). Passes collect their findings (deduped by code + source envelope) and surface them together; single-error loads stay byte-identical to the prior eager-throw. The other ports already collected.
  4. Cleanup + docs. Aligned @payloadRef/@responseRef validation across ports (reverted a TS-only descriptor split that caused a double resolution); design docs for the architecture + the config-driven future; the Phase-3 plan.

BREAKING

Models that referenced a non-existent entity via @objectRef / @references previously loaded silently; they now error. Fix the reference or remove the relationship/identity. (CHANGELOG updated.)

Test state — all green

  • TS metadata 2045 + typecheck clean
  • Java 1058 + conformance 417
  • C# conformance 665
  • Python 1206 (incl. 305 conformance; the pg8000 integration-collection errors are pre-existing/env-only)
  • Kotlin: reference enforcement runs through the shared JVM loader (gated transitively via Java ConformanceTest)

Four new shared fixtures/conformance/ error fixtures gate the enforcement in every data port.

Reviewed before filing

A staff-level review ran across all five ports (no blockers). Fixes applied from it: a downstream-code crash guard (ErrorCode.valueOfERR_UNKNOWN fallback; C# Enum.IsDefined parity), restored the owning-entity qualifier in reference error messages (locatability), corrected stale ValidationPhase javadoc, and hardened the dedupe key.

Known caveats (called out deliberately)

  • The per-type validate hook is an extension point used by no core type (only a downstream test exercises it) — shipped as the documented escape hatch for downstream semantic validation; the references half IS used by core.
  • The downstream self-validation capability is verified by a TS unit test, not yet a cross-port conformance fixture.
  • maxOccurs/defaultName are read from the spec JSON only in TS; C#/Python hardcode the matching values (commented). True single-source-of-truth wiring is the config-driven-validation work (Config-driven metadata validation — checks as data, one engine per port (write-once across 5 languages) #51) — the gold-standard direction this PR's design doc lays out.

🤖 Generated with Claude Code

dmealing and others added 20 commits June 18, 2026 09:01
…R-024 friction)

The no-name identity.primary form shown across the docs, llms-full, and the
website example FAILED to load (ERR_IDENTITY_NAME_REQUIRED) — a copy-paste trap
for users and agents. Fix it the config-driven way, gated by cardinality.

- New type-def config: `maxOccurs` (declared + loader-enforced) and `defaultName`.
  `defaultName` is honored ONLY when `maxOccurs === 1` — a static default name is
  collision-free by construction only for a singleton.
- spec/metamodel/identity.json: identity.primary -> { maxOccurs: 1, defaultName:
  "primary" }. A nameless primary now loads, named "primary" (stable + referenceable
  as Entity.primary). Multi-cardinality children (secondary/reference, validators,
  views) still require explicit names — no unpredictable counter auto-naming.
- TS reference: parser applies the default (singleton-gated); new validate-max-occurs
  pass enforces the singleton (catches two primaries); ERR_TOO_MANY_OCCURRENCES added.
  4 new tests; metadata suite green; the no-name CLI repro now generates.
- Cross-port: shared spec/metamodel + ERROR-CODES.json carry the change; Java (spec
  auto-copied) / Python / C# get the new error code + embedded spec. The DEFAULT-NAME
  behavior is TS-only for now, matching the existing FR-024 posture (Java/Python/C#
  don't enforce identity-name-required yet — so they never had the bug). All four
  ports verified green: TS 2035, Java 1046, C# conformance 657, Python conformance 301.

Design: docs/superpowers/specs/2026-06-18-identity-default-name-design.md
…s Java/Python/C#

Completes the cross-port behavioral implementation begun in TS (b43f5e7) for the
name-less `identity.primary` friction (FR-024): a name-less primary failed to load,
a copy-paste trap present in docs, llms-full.txt, and the .dev homepage.

The fix is a generic, config-driven registry rule — NOT a per-type loader
special-case:
  - A type may declare `maxOccurs` (singleton == 1) + `defaultName` on its
    registration. identity.primary declares `maxOccurs: 1, defaultName: "primary"`
    (mirrors the shared spec/metamodel/identity.json SSOT).
  - The parser names a name-less node from `defaultName` only when `maxOccurs == 1`
    (collision-free by construction) and serializes it with that name.
  - A new singleton-cardinality validation pass emits ERR_TOO_MANY_OCCURRENCES
    against the offending child when a parent exceeds a registered maxOccurs
    (e.g. two identity.primary on one object).

Per-port:
  - Java: maxOccurs/defaultName on TypeDefinition(+builder, preserved across the
    three doc-slot/strict-scoping rebuilds and TypeDefinitionBuilder.from);
    PrimaryIdentity registration; CanonicalJsonParser default-name; ValidationPhase
    validateMaxOccurs; ERR_TOO_MANY_OCCURRENCES message constant.
  - C#: MaxOccurs/DefaultName on TypeDefinition (preserved across the three Registry
    rebuilds); Def() helper + identity registration; Parser default-name;
    ValidationPasses.ValidateMaxOccurs wired into the loader.
  - Python: max_occurs/default_name on TypeDefinition; core_types registration;
    parser default-name; validation_passes _validate_max_occurs.

Gated by two new cross-port conformance fixtures (identity-primary-default-name,
error-too-many-primary) that every port now passes — previously Java/Python/C#
were vocabulary-only here and would have silently diverged from the TS oracle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion)

The loader now names a name-less identity.primary "primary" (config-driven singleton
default), so the minimal `{ "identity.primary": { "@fields": "id" } }` form loads — no
more copy-paste trap. Document this in entities.md (the singleton rule + the
ERR_TOO_MANY_OCCURRENCES guard) and the metamodel spec (the generic maxOccurs/defaultName
mechanism). Existing no-name examples throughout the docs are now correct as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ferences resolution (all 5 ports)

A dangling relationship @objectref or identity.reference @references loaded silently —
drift between two pieces of metadata that should fail loudly (like extends -> ERR_UNRESOLVED_SUPER,
@payloadRef -> ERR_INVALID_TEMPLATE, origin paths -> ERR_INVALID_ORIGIN already do). Now every
relationship's @objectref and every identity.reference's @references must resolve to a real object
in the loaded tree, or the model fails to load.

  - relationship @objectref unresolved -> ERR_INVALID_RELATIONSHIP (folded into the existing code)
  - identity.reference @references unresolved -> new ERR_INVALID_REFERENCE

Implemented as a registered validation pass in every port (TS validateRelationships +
validateIdentityReferences; Java ValidationPhase; Python validation_passes; C# ValidationPasses),
resolving via each port's shared object index (refMatchesObject / FindObject / _build_object_index).
The target entity is the segment before the first dotted field path (packages use "::", never ".").

Gated cross-port by two new conformance fixtures (error-relationship-unresolved-objectref,
error-identity-reference-unresolved) — all five ports green. ERR_INVALID_REFERENCE added to every
port's error enum + the shared ERROR-CODES.json. Fixed two incomplete unit-test scaffolds that
built relationships without their target entity (Java RelationshipReferentialActionsTest stubs;
the Kotlin RelationshipsTest "unresolved objectRef" case repurposed to assert the new rejection).

Also includes a design doc surveying validation-architecture best practice (TS binder/checker,
GraphQL, Smithy, SHACL, XSD keyref) and recommending a phased move to a formalized semantic phase
(symbol table + rule registry + declarative reference descriptors). This commit is Phase 1; the
doc scopes Phases 2-3 as a follow-up FR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se 2 de-risk

Exploratory branch (NOT for PR #44). Proves the converged validation architecture end to
end in both an OO port (Java) and a data-oriented port (TS):

  * SymbolTable          — object index built once per load (the binder analogue)
  * ReferenceDescriptor  — an attr declares what it points at (data-driven cross-refs)
  * NodeValidator        — imperative rule, registered per (type, subType) by the provider
  * ValidationRegistry   — reference descriptors + validators, keyed by type.subType
  * runRegisteredValidation / RegisteredValidation.run — one recursive root.validate(ctx)
    walk: apply declared references → invoke registered validators → recurse

The built-in @objectref / @references checks are MIGRATED onto declarative reference
descriptors (no bespoke pass) — conformance stays green (TS 2045, Java metadata 1056 +
conformance 388 + ktx 33), behavior-preserving (Java still eager-throws the first error).

The load-bearing proof (R2 — downstream extensibility):
  * TS: a fake external provider registers a brand-new type `widget.gauge` + its reference
    descriptor + its imperative validator (with its OWN error codes); a model with a bad
    feed ref AND an inverted range produces BOTH errors — no core edits.
  * Java: a downstream ValidationRegistry registers a method-reference validator + a
    custom-code reference descriptor (tighter target-kind); both fire via the same walk on
    a loaded tree — no core edits. (New-type registration uses existing provider machinery,
    proven by the TS slice.)

Same registry, same recursive walk, same declarative descriptors; validator bodies
idiomatic per port (TS closures, Java method references). Design doc rewritten around this
model (symbol table + rule registry + declarative references + provider-extensibility as
the driver). Phase 3 = migrate the remaining resolvers (extends/payloadRef/origins), wire
through the provider SPI, normalize the error model (Java → collect), port to C#/Python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e registry

Deepens the slice in TS so validation is part of a type's registration, not a side-channel:

  * TypeDefinition gains `references: ReferenceDescriptor[]` + `validate: NodeValidator`
    (contract in validation-types.ts to avoid an import cycle).
  * The loader DERIVES validation from its TypeRegistry — runRegisteredValidation reads
    each node's TypeDefinition.references + .validate. No separate ValidationRegistry, no
    validationRegistry LoadOption.
  * Core declares its built-in cross-references ON their TypeDefinitions (relationship
    @objectref, identity.reference @references). Conformance stays green (388) — behavior
    preserved.
  * ParseError.code widened to `ErrorCode | (string & {})` so a downstream provider emits
    its OWN error codes (the closed-union seam, now closed).

Proof rewritten: a fake provider registers a brand-new type `widget.gauge` WITH its
references + validator on the same TypeDefinition; composing it into the registry is the
ONLY wiring; the custom type validates itself (dangling @feeds + inverted range, both with
the provider's own codes) — no separate registry, no core edits. Full TS suite 2045 green.

This is the user's original vision realized: configurable validation is part of the type's
registration; a new type's validation rides in automatically. Java derivation next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… on the type

Brings Java to the same realization as the TS slice: validation is part of a type's
registration, derived by the loader — not a side-registry.

  * TypeDefinition gains `references: List<ReferenceDescriptor>` + `validator: NodeValidator`
    (additive, like maxOccurs/defaultName; preserved across TypeDefinitionBuilder.from and
    the three registry rebuild sites).
  * TypeDefinitionBuilder gains .reference(...) / .validator(...).
  * RegisteredValidation.run(root, MetaDataRegistry) DERIVES validation: per node it reads
    its TypeDefinition's references + validator. The old separate ValidationRegistry is
    deleted.
  * Core declares its cross-references ON their types — ReferenceIdentity (@references) and
    the three relationship subtypes (@objectref) via the registration builder.
  * ValidationPhase runs the derived walk over loader.getTypeRegistry() (collected, first
    error eager-thrown to preserve cross-port behavior).

Green: metadata 1057 + conformance 388 (behavior-preserving — core references now resolve
via TypeDefinition.references), and codegen-spring/codegen-kotlin/metadata-ktx/om all pass.
DownstreamValidationTest proves the descriptors live on the TypeDefinitions and the derived
walk enforces them. A downstream NEW type's validation rides in via setTypeRegistry on the
same mechanism (proven end-to-end by the TS slice).

Known prototype seam: registry <-> loader.validation form an intra-module package cycle
(compiles fine under Java's single-module javac); production moves the contract types to a
base package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 5 ports aligned

Brings C# and Python to the same realization as TS/Java: validation is part of a type's
registration, derived by the loader.

  * TypeDefinition gains References (List<ReferenceDescriptor>) + Validate (NodeValidator) —
    C# internal-set props copied across the three registry rebuild sites; Python dataclass
    fields copied in register().
  * A registry-derived recursive walk (C# RegisteredValidation.Run / Python
    registered_validation.run) reads each node's TypeDefinition references + validator.
  * Core declares its cross-references ON their types — identity.reference (@references) and
    the relationship subtypes (@objectref) — in CoreTypes.cs / core_types.py.
  * The loader runs the derived walk; the old procedural @objectref / @references passes are
    removed.

Behavior-preserving: C# conformance 665, Python conformance 391 + full suite 1206 — the
built-in reference checks now resolve via TypeDefinition.References, same fixtures. The
downstream-custom-code seam (C# MetaError.Code / Python ErrorCode are closed enums) maps
unknown codes to ERR_UNKNOWN for now (TS widened ParseError.code; the OO ports follow in
the error-model normalization step).

All five ports (TS / Java / C# / Python, + Kotlin via shared JVM) now carry validation on
the type and derive it from the registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the validation CONTRACT (ReferenceDescriptor, NodeValidator, ValidationContext,
SymbolTable, ValidationError) from com.metaobjects.loader.validation to a base package
com.metaobjects.validation, keeping only the registry-dependent runner
(RegisteredValidation) in loader.validation. Dependency now flows one way:

  registry        -> com.metaobjects.validation   (TypeDefinition.references/validator)
  loader.validation -> registry + com.metaobjects.validation   (the runner)

No more intra-module cycle (the one prototype seam called out earlier). Behavior unchanged:
metadata 1057 + conformance 388 + downstream 3, codegen-spring/metadata-ktx green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e 3 scoped

Update the design doc to the realized state: validation-on-the-TypeDefinition, derived by
the loader, in TS/Java/C#/Python (+Kotlin via shared JVM), with the downstream new-type
proof and the package-cycle fix. Scope the remaining Phase 3 precisely (migrate
payloadRef/extends/origins onto descriptors; carry the reference field in spec JSON;
normalize the error model to collect-all + downstream codes; provider-SPI registration +
a downstream-extension conformance fixture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…refs unified

Completes the object-cross-reference unification in the reference port: @payloadRef and
@responseRef now resolve via reference descriptors on the template TypeDefinitions
(targetSubType: object.value), alongside @objectref / @references. The bespoke existence +
kind checks are removed from validateTemplatePayloadRefs (the @kind/@textRef + @requiredSlots
rules stay — slots still needs the resolved payload).

Per-reference source convention preserved via a `resolvedSource` descriptor flag: payloadRef
emits the FR5d resolved-source envelope (referrer + target); objectRef/references stay plain.
This keeps each kind's existing envelope AND means the other four ports need NO change — the
shared fixtures are untouched (objectRef/references plain, payloadRef resolved via TS's
descriptor or the other ports' existing pass). TS 2045 green; Python conformance 391 green
(unaffected).

Other ports keep @payloadRef as their existing pass (identical behavior); migrating it onto
the descriptor there is a behavior-neutral internal follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, spec-JSON declarative, Java collect-all, downstream codes)

Captures the deliberate, conformance-gated refinements left after the
validation-on-the-TypeDefinition architecture was realized in all five ports.
Each task is independent and called out with its files, blast radius, and TDD
steps — none is required for the architecture, which is complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st the first (Phase 3, Task 3)

The registry-derived reference pass already collects every finding; ValidationPhase
was throwing only get(0). Now a load with multiple broken object references surfaces
them all via MetaDataValidationException (IS-A MetaDataException carrying the first
error's code/envelope for back-compat + getValidationErrors() for the full set).

A single-error load still throws a plain MetaDataException, byte-identical to before —
so every single-finding conformance fixture is unchanged. Contained to the reference
pass; the structural eager-throw passes (which guard a malformed tree) are untouched.

TDD: new DownstreamValidationTest.reportsEveryDanglingReferenceNotJustTheFirst.
Green: metadata 1058, conformance 417 (incl. 388), metadata-ktx 33.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llect; cross-pass deferred)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…very error, not the first (Phase 3, Task 3)

Supersedes the narrower reference-only collect (1e512ea). ValidationPhase now runs
EVERY pass and collects each finding instead of aborting on the first: a model with
defects across multiple passes surfaces them all. Mechanism:

- pass(...) wraps each validation pass, catching its MetaDataException into a list and
  letting the next pass run; a non-MetaDataException (a real bug) still propagates.
- Findings are deduped on (code + source envelope) — the same defect flagged by two
  passes (e.g. a missing required attr caught by both the generic and a subtype pass)
  is one finding, while genuinely-distinct errors stay separate.
- All but the last finding are recorded via loader.addError (source order); the last is
  thrown. This matches the conformance harness's "drain getErrors() then the thrown
  error" merge and keeps single-error loads byte-identical to the historical eager-throw.

Dropped the aggregate MetaDataValidationException — loader.getErrors() is the channel.
Fixtures stayed green via dedupe; updated one unit test (bad @ROLE now legitimately
reports both ERR_BAD_ATTR_VALUE and ERR_SOURCE_NO_PRIMARY) to assert the expected code
is among all reported errors.

Green: metadata 1058 + conformance 417, metadata-ktx/codegen-base/codegen-spring/
codegen-kotlin/render/om/core-spring 234+, codegen-mustache/plantuml/maven-plugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll (dedupe + throw-last)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it — align TS with the other ports (Option 1)

Reverts 0ea44c7. Migrating @payloadRef/@responseRef onto reference descriptors in TS
left their dependent rule (@requiredSlots, which needs the RESOLVED payload) behind in
the template pass — so TS resolved the payload twice and validated payloadRef in two
places, while Java/C#/Python kept it unified in one pass (single resolution). TS was the
outlier.

This restores the unified template pass in TS: @payloadRef/@responseRef existence +
object.value kind + @requiredSlots are validated together, one resolution, matching the
other three ports exactly. objectRef/references stay on descriptors (pure references, no
entanglement) — those remain consistent across all ports. The descriptor-only
resolvedSource flag + errorResolved helper are removed (unused after the revert).

Each reference KIND is now handled identically in every port. Zero behavior change —
the payloadRef conformance fixtures stay green via the restored pass. Also fixed a
pre-existing noUncheckedIndexedAccess typecheck error in default-name-singleton.test.ts.

Green: TS metadata 2045, typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dard) + Phase 3 Task 1 resolution

New spec docs/superpowers/specs/2026-06-21-config-driven-validation-design.md: the
write-once standard — checks as DATA in shared JSON config, one generic interpreter per
rule-shape per port, so a rule is authored once (not coded in 5 languages) and a
downstream provider's type self-validates with zero new code. Four closed rule-shapes
(reference / allowedValues / requires / fieldPathRef) cover the whole current corpus;
the validate hook is the rare escape hatch. Phased (proof slice first). Sibling to
FR-033 (#23). Updated the Phase 3 plan to record Task 1's actual resolution (Option 1
revert, not descriptor propagation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rror locatability, doc accuracy (5 ports)

From the pre-PR staff review:
- Downstream-code crash guard (Java): the registry-reference loop's ErrorCode.valueOf(e.code())
  would throw IllegalArgumentException (not a MetaDataException) on a downstream provider's
  non-enum code, crashing the load and contradicting the advertised extensibility. Now mapped
  via toErrorCode() → ERR_UNKNOWN fallback (message keeps the raw code). C# tightened with
  Enum.IsDefined so a numeric-string code maps to ERR_UNKNOWN like Python's value-lookup;
  Python already correct.
- Error locatability (all 4 data ports): the registry-derived reference error dropped the
  owning-entity qualifier (e.g. "items" instead of "Order.items"). Restored by qualifying the
  node name with its parent — message-only, conformance (code+source) unchanged.
- Doc accuracy (Java): ValidationPhase javadoc still claimed eager-throw-on-first-error;
  updated to the collect-all / throw-last contract (class header + legacy run() entry point).
- Hardening (Java): dedupe() no longer collapses two findings that share neither code nor
  envelope (index-tagged key).
- Clarity (C#/Python): commented that maxOccurs/defaultName are intentionally hardcoded to
  match spec_metamodel/identity.json (true SSOT wiring tracked in #51).

Green: TS metadata 2045 + typecheck, Java 1058 + conformance 417, C# conformance 665,
Python 1206 (integration pg8000 collection errors are pre-existing/env-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lidation + collect-all (Unreleased)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base automatically changed from fix/identity-default-name to main June 21, 2026 13:15
# Conflicts:
#	server/csharp/MetaObjects/CoreTypes.cs
#	server/csharp/MetaObjects/Registry.cs
#	server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
#	server/java/metadata/src/main/java/com/metaobjects/registry/MetaDataRegistry.java
#	server/java/metadata/src/main/java/com/metaobjects/registry/TypeDefinition.java
#	server/java/metadata/src/main/java/com/metaobjects/registry/TypeDefinitionBuilder.java
#	server/python/src/metaobjects/core_types.py
#	server/python/src/metaobjects/registry.py
#	server/typescript/packages/metadata/src/registry.ts
@dmealing
dmealing merged commit 9841af6 into main Jun 21, 2026
29 checks passed
@dmealing
dmealing deleted the feat/metadata-validation branch June 21, 2026 13:43
dmealing added a commit that referenced this pull request Jun 21, 2026
…ttr()

Field-level `extends` (abstract fields — a concrete field extending a package-level
abstract field to inherit `required` / `maxLength` / `default` / etc.) was silently
dropping the inherited attributes: the generators read them with `ownAttr()` (own
declarations only), so an inherited `@required` produced a NULLABLE column, an
inherited `@maxLength` was ignored, etc. Output was under-constrained — e.g. a field
`{ extends: Name }` of an abstract `Name {required, maxLength:200}` generated
`text("name")` instead of `varchar("name",{length:200}).notNull()`.

This surfaced after the collect-all rework (#52) made inherited attrs resolve as
effective-but-not-own; the generators' long-standing `ownAttr()` usage then missed
them. Switched every FIELD_ATTR_* read (57 sites across column-mapper, drizzle-schema,
zod-validators, inferred-types, entity-constants, filters, payload/extract, docs, …)
to the effective `attr()`. For a field with no `extends`, `attr() === ownAttr()`, so
this is a no-op for all existing output (codegen-ts 803/0, byte-identical goldens);
it only restores the inherited case. Identity/object/source/validator reads stay
`ownAttr()` (correctly own-only).

Also: buildFkMapForEntity now strips the package from the FK target before the lookup
(`findObject(stripPackage(targetName))`) — the YAML front-end resolves `@references`
to an FQN (`pkg::Entity`), and the un-stripped lookup dropped every FK under YAML.
Mirrors the relation-resolver, which already strips.

Tests: a YAML fixture exercising both (abstract-field required+maxLength inheritance,
and a package-qualified FK target). codegen-ts 803/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dmealing added a commit that referenced this pull request Jun 21, 2026
…ttr() (#55)

Field-level `extends` (abstract fields — a concrete field extending a package-level
abstract field to inherit `required` / `maxLength` / `default` / etc.) was silently
dropping the inherited attributes: the generators read them with `ownAttr()` (own
declarations only), so an inherited `@required` produced a NULLABLE column, an
inherited `@maxLength` was ignored, etc. Output was under-constrained — e.g. a field
`{ extends: Name }` of an abstract `Name {required, maxLength:200}` generated
`text("name")` instead of `varchar("name",{length:200}).notNull()`.

This surfaced after the collect-all rework (#52) made inherited attrs resolve as
effective-but-not-own; the generators' long-standing `ownAttr()` usage then missed
them. Switched every FIELD_ATTR_* read (57 sites across column-mapper, drizzle-schema,
zod-validators, inferred-types, entity-constants, filters, payload/extract, docs, …)
to the effective `attr()`. For a field with no `extends`, `attr() === ownAttr()`, so
this is a no-op for all existing output (codegen-ts 803/0, byte-identical goldens);
it only restores the inherited case. Identity/object/source/validator reads stay
`ownAttr()` (correctly own-only).

Also: buildFkMapForEntity now strips the package from the FK target before the lookup
(`findObject(stripPackage(targetName))`) — the YAML front-end resolves `@references`
to an FQN (`pkg::Entity`), and the un-stripped lookup dropped every FK under YAML.
Mirrors the relation-resolver, which already strips.

Tests: a YAML fixture exercising both (abstract-field required+maxLength inheritance,
and a package-qualified FK target). codegen-ts 803/0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
dmealing added a commit that referenced this pull request Jun 21, 2026
…xtension point (#59)

Closes the last #52-review loose end. The per-type imperative `NodeValidator` hook on the
TypeDefinition is set by no core type, which reads as dead code — but it is deliberate:

- It is an EXTENSION POINT: a downstream provider registers a new type WITH its own
  validator + error codes and it runs via the registry with zero core changes (the ADR-0023
  thesis), proven by the widget.gauge test in validation-registry.test.ts.
- It is the documented escape hatch in the config-driven-validation design (#51) for novel
  cross-field rules that fit no declarative shape.
- Core's per-type validation lives in reference descriptors (live) + declarative rule-shapes
  (#51, planned); a core rule on this imperative hook would contradict that direction — so
  core intentionally does not use it, and dogfooding a rule onto it would be make-work.

Decision: keep the hook, document the intent. Clarifying comments added to the hook
declaration in all four ports (TS/Java/C#/Python; Kotlin shares the JVM type) + a status
note in the validation-architecture design doc. No behavior change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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