Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ here.**

## [Unreleased]

### Added

- **Generated record models enforce the four cross-field validators in every port.**
`validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and
`validator.atLeastOne` used to render only as DB `CHECK` constraints, so a store with no `CHECK`
support (a graph or document database) enforced none of them and the application had to repeat the
rules in its writer. The full create/insert model now enforces them with the language's native
mechanism: a Zod refinement on the insert schema (TypeScript), a class-level Bean Validation
constraint on the create DTO (Java, Kotlin), `IValidatableObject` (C#) and a Pydantic
`model_validator` (Python). Semantics mirror the `CHECK` derivation: null-safe, `comparison` passes
when either side is null, `@equals` is read per the gating field's subtype. PATCH and
partial-update models are unchanged (a partial body cannot see the merged row, so the database
`CHECK` remains the enforcement there), and `CHECK` emission is unchanged. A record that violates a
declared rule is now rejected at the model where it used to reach the database. Gated by
`fixtures/validation-conformance/cross-field-cases.json` in all five ports.

### Fixed

- **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.**
Expand Down
6 changes: 5 additions & 1 deletion agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,11 @@ behavior:

Entity-scoped `validator.*` children declare invariants that reference sibling fields
**by name** (the same name-reference pattern as `identity.*`). The backend derives the
enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored.
enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`,
and the generated full create/record model also enforces the rule in every port (Zod refinement,
Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a
store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them
(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there.

| Subtype | Rule | Key attrs |
|---|---|---|
Expand Down
28 changes: 5 additions & 23 deletions docs/1.0-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,29 +155,11 @@ Status legend: ✅ done · 🔶 in progress · ⬜ not started · ❓ **[RATIFY]
in all three non-TS ports; a Python runtime path dropped an inherited M:N) — assume
more lurk, and lead every cross-port fix with a shared fixture.

**Known instance, measured 2026-09-06 — the four entity-scoped cross-field validators.**
`validator.comparison`, `requiredWhen`, `presentIff` and `atLeastOne` are registered
vocabulary in all five ports and are taught by the shipped `metaobjects-authoring` skill,
but their only enforcement is the database `CHECK` that TS-owned `migrate` emits (gated by
`migrate-ts/test/validator-check/cross-field.test.ts`, 7 tests). **No port's generated API
tier validates them:** Python's `entity_model.py` and C#'s `EntityGenerator.cs` dispatch
only on the four FIELD-scoped validators (`length`, `array`, `numeric`, `regex`), and the
shared `validation-conformance` corpus covers only those same four — **zero fixtures across
every behavioral corpus touch the cross-field family.** They are also four of the 13 entries
in `registry-conformance/coverage-report.json`'s `untestedSubTypes`, which is the same list
the `<type>.base` defect hid in until `0.25.0`.

Consequence for an adopter today: a request body violating a declared cross-field rule is
refused by the DATABASE, not by the API, in every port — a constraint violation rather than
a 400.

**This does not block the cut**, and the reason is worth stating so it is not re-litigated:
the vocabulary is enforced (the CHECK reaches every port, because schema is TS-owned per
ADR-0015), and adding API-tier enforcement later changes no load verdict and no metadata —
it is additive, and reaches an adopter as a wrong request failing earlier and more clearly.
What must NOT happen is adding a cross-port `validation-conformance` fixture for these
before the ports implement the tier; that turns three lanes red to record a gap this bullet
already records.
**Known instance (measured 2026-09-06, resolved Unreleased).** The four entity-scoped cross-field
validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) were enforced only by the DB
`CHECK`. The generated record model now enforces them in every port; see
[features/entities.md](features/entities.md#cross-field-validators) and the CHANGELOG `[Unreleased]` entry.
They remain among the `untestedSubTypes` in `registry-conformance/coverage-report.json`, which tracks the registry, not the generated behavior.

## F. Adoption proof (the audit as readiness signal)

Expand Down
2 changes: 1 addition & 1 deletion docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) |
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 119 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL |
| [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) |
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases + 20 cross-field cases (`cross-field-cases.json`, generated record model only) | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 6 shared inputs; `shared-provided-enum` carries the enum-type decision table and `cross-package-binding` the reference-binding decision table (ADR-0059) every port answers | ✓ | ✓ | ✓ | ✓ | ✓ |
Expand Down
22 changes: 22 additions & 0 deletions docs/features/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,28 @@ row = om.create("Author", {"name": "Ada", "bio": None})
found = om.find_by_id("Author", row["id"])
```

## Cross-field validators

`validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and `validator.atLeastOne`
are declared as children of the entity and reference sibling fields by name. They are enforced in
two places:

- **The generated full record (create) model**, in every port, with the language's native
mechanism: a Zod refinement on the insert schema (TypeScript), a class-level Bean Validation
constraint on the create DTO (Java, Kotlin), `IValidatableObject` (C#) and a Pydantic
`model_validator` (Python). A store with no `CHECK` support, such as a graph or document
database, is therefore still covered.
- **A database `CHECK` constraint**, derived by `meta migrate`.

The two share one set of semantics: absent or null is SQL `NULL`; `comparison` passes when either
side is null; `requiredWhen` is violated only when the gate equals `@equals` and the target is null;
`presentIff` requires the target to be present exactly when the gate equals `@equals`; `atLeastOne`
needs one non-null field. `@equals` is read per the gating field's subtype. The shared cases in
`fixtures/validation-conformance/cross-field-cases.json` gate all five ports.

PATCH and partial-update models do **not** enforce these rules, because a partial body cannot see
the merged row. The database `CHECK` is the enforcement there.

## Verified by

The following conformance fixtures gate this feature's behavior across ports:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,21 @@ has no empty-string case):**
(no anchors) accepts `"xxABCyy"` on TS/Python, rejects on Java/Kotlin/C#. Ruling 2 pins **full-match**.

**Field-scoped vs entity-scoped split is correct and load-bearing:** cross-field validators
(`comparison`, `presentIff`, `requiredWhen`, `atLeastOne`) render ONLY as TS-owned DB CHECK
(`comparison`, `presentIff`, `requiredWhen`, `atLeastOne`) were rendered ONLY as TS-owned DB CHECK
constraints (`migrate-ts/src/expected-schema.ts`), never as jakarta/Zod — so they evaluate the MERGED
row after UPDATE and hold under PATCH with zero handler work. Do NOT add cross-field validation to any
port's handler. Per-field only.
row after UPDATE and hold under PATCH with zero handler work. PATCH and partial-update handlers still
add nothing: a partial body cannot see the merged row, so the DB CHECK stays the enforcement there.

> **Amended (Unreleased) — record models now enforce cross-field rules.** The original reading above
> ("DB CHECK only") left a store with no CHECK constraints (a graph or document database, say) with
> no enforcement of these four validators at all. Every port's generated **full record model** — the
> create/insert shape — now also enforces them, in the language's native idiom: a Zod refinement on
> the insert schema (TS), a class-level Bean Validation constraint on the create DTO (Java, Kotlin),
> `IValidatableObject` on the create model (C#) and a Pydantic `model_validator(mode="after")`
> (Python). The semantics mirror the DB CHECK derivation exactly (null-safe; `comparison` passes when
> either side is null; `@equals` rendered per the gating field's subtype) and are gated by the shared
> `fixtures/validation-conformance/cross-field-cases.json` cases in all five port runners. PATCH and
> partial-update models are deliberately unchanged, and DB CHECK emission is unchanged.

---

Expand Down
9 changes: 7 additions & 2 deletions examples/advanced-modeling/codegen/generators/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
} from "@metaobjectsdev/codegen-ts/engine";
import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js";
import { isTphDiscriminatorBase, tphPlan } from "../templates/tph-discriminator.js";
import { hasInsertRefinements } from "../templates/zod-validators.js";

// --- composition (OWNED) — assembles one <Entity>.routes.ts. Change this to change the output. ---
// Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a
Expand Down Expand Up @@ -621,7 +622,11 @@ function renderTphRoutes(
const subFileSpec = entityModuleSpecifier(
ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle,
);
const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`);
// A subtype with cross-field rules has a refined InsertSchema, which Zod will not
// `.omit()`; its codegen exports the discriminator-less refined body instead.
const subInsertBody = hasInsertRefinements(sub)
? code`${imp(`${sub.name}InsertBodySchema@${subFileSpec}`)}`
: code`${imp(`${sub.name}InsertSchema@${subFileSpec}`)}.omit({ ${discField}: true })`;
// FR-036 Program B: the per-subtype UPDATE must carry the FR-035 present-key
// tristate (a non-@required subtype column accepts an explicit null → clears;
// a @required column's explicit null is rejected). The subtype's own
Expand All @@ -640,7 +645,7 @@ function renderTphRoutes(
path: ${baseConstSym}.$path + ${JSON.stringify(`/${segment}`)},
db: ${dbSym},
table: ${tableSym},
insertSchema: ${subInsertSym}.omit({ ${discField}: true }),
insertSchema: ${subInsertBody},
updateSchema: ${subUpdateSym},
filterAllowlist: ${subFilterSym},
sortAllowlist: ${subSortSym},
Expand Down
9 changes: 6 additions & 3 deletions examples/advanced-modeling/codegen/templates/queries-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator
import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts/engine";
import { isReport } from "@metaobjectsdev/codegen-ts/engine";
import { hasItemRoute } from "@metaobjectsdev/codegen-ts/engine";
import { hasAutoSetFields } from "./zod-validators.js";
import { hasAutoSetFields, hasInsertRefinements } from "./zod-validators.js";
import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine";

/**
Expand Down Expand Up @@ -372,6 +372,9 @@ export async function list${pluralize(baseName)}(db: Db, opts?: { limit?: number
// the schema (schemaInputType), so a subtype module from an owned entity generator
// that predates the `<Sub>Create` alias still satisfies it.
const subCreateSym = schemaInputType(subInsertSym);
// A refined (cross-field) InsertSchema has no `.partial()`; the partial update reads
// the unrefined shape, as an unrefined subtype's does.
const subPartialSym = hasInsertRefinements(sub) ? imp(`${sub.name}InsertBaseSchema@${subFileSpec}`) : subInsertSym;

subtypeSections.push(code`
export async function list${pluralize(sub.name)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${subTypeSym}[]> {
Expand All @@ -395,7 +398,7 @@ ${supportsReturning(ctx.dialect) ? code`export async function create${sub.name}(
}

export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> {
const validated = ${subInsertSym}.partial().parse(data) as Record<string, unknown>;
const validated = ${subPartialSym}.partial().parse(data) as Record<string, unknown>;
// The discriminator is immutable — a ${sub.name} can never become another subtype.
const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated;
const [row] = await db.update(${tableSym}).set(safe)
Expand All @@ -417,7 +420,7 @@ export async function create${sub.name}(db: Db, data: ${subCreateSym}): Promise<
}

export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> {
const validated = ${subInsertSym}.partial().parse(data) as Record<string, unknown>;
const validated = ${subPartialSym}.partial().parse(data) as Record<string, unknown>;
// The discriminator is immutable — a ${sub.name} can never become another subtype.
const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated;
if (Object.keys(safe).length > 0) {
Expand Down
Loading
Loading