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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.

**Cross-port conformance corpora** (every port runs the shared corpus):
- Metamodel: `fixtures/conformance/` (428 fixtures; 36 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Metamodel: `fixtures/conformance/` (449 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived.
Expand Down
343 changes: 258 additions & 85 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions agent-context/skills/metaobjects-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint.
A constraint enters **shared metadata** only if it is a **true cross-language domain
invariant**; a one-consumer preference stays in a thin local refinement layer.

Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` /
`presentIff`); ratification decides *which* belong in shared metadata. Output a
Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` /
`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a
**ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale —
human-approved, never applied silently. Distinguish `required` from has-a-safe-default
(`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ soft-delete / status / type view it models.
`validator.regex`** — hunt hand field-level validation (presence, length, numeric range,
array bounds, `@pattern` regex) the validator subtypes model.
- **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g.
"end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen`
"end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N
present), `validator.requiredWhen`
(conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded
multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in
`SKILL.md` to decide what belongs in shared metadata vs port-local).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend

**Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `<Entity>Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one.

**Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (`<Route path="/programs">`, `<a href="/programs">`) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `<Entity>Names` is the gold standard — call it out as good.
**Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (`<Route path="/programs">`, `<a href="/programs">`) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `<Entity>Names` is the gold standard — call it out as good.

**Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is
URL validation) — a distinct native type + behavior is a subtype, not a validated string.
- A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type;
Postgres `inet` column).
- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an
`interval` column, "seconds until…") → recommend **`field.duration`** (native duration type;
Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years
are not durations.

**Custom-provider vocabulary (adopters who register their own types/attrs):** check
new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype
Expand Down
16 changes: 11 additions & 5 deletions agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr
| Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" |
| URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) |
| IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) |
| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds |
| Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` |

Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`):
Expand Down Expand Up @@ -285,10 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends`
`field.object` embeds another `object` declaration. `@objectRef` names it;
`@storage` controls persistence:

- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …).
Illegal on array fields.
- `jsonb` — one `jsonb` column.
- `subdocument` (default, back-compat) — single jsonb column.
- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix`
replaces the `<column>_` prefix verbatim (`""` keeps the members' own columns).
- `jsonb` — one `jsonb` column; also the default when `@storage` is not set.
- `subdocument` — single jsonb column (a hint for document-store targets).

```json
{ "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } }
Expand Down Expand Up @@ -434,16 +435,21 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` |

All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does.
Every validator that becomes a CHECK takes an optional `@constraintName`.

```json
{ "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } }
{ "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } }
{ "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } }
{ "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } }
{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } }
```

These are children of `object.entity`, alongside its fields and identities.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car
names, and persistence is the repository interface/`Protocol` you implement. There the
artifact exists *for your code*, which is the only place a physical name appears.

Two categories stay literal, deliberately, and the gate pins them as such rather than
exempting them:

- a **flattened value-object column** (`@storage: flattened`) is a composite —
`<owner field column>_<member column>` — belonging to no single field of either object,
so there is no one constant to reference;
- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY
source's name (its table), and a write-through entity has two physical names.
**A flattened value object's columns are constants of the OWNING object's artifact.** A
`@storage: flattened` field has no column of its own: each member of the value object is a
column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`,
else `<field column>_`, then the member's column). The artifact carries one constant per
member, and the field itself has none, because the table has no column of that name:

| Port | Reads as |
|---|---|
| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` |
| C# | `ShipmentNames.ShipToStreetColumn` |
| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` |
| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` |

The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`.
A member constant that would share its name with another constant (a field `shipToStreet`
beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename
one to get it. The generated EF Core and Exposed bindings still spell these columns as
literals, because an owned copy of an older names generator does not emit the member
constants. Without the artifact, compute the name with core naming's
`resolveFlattenedColumnName` (or its port equivalent), never by concatenating.

**But prefer a typed handle where one exists — this rule has a real limit.** If the ORM
gives you a type-checked object for the same thing, use that. Replacing a Drizzle column
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus.
loaded root; there is no CLI flag and no generator argument.

```json
{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } }
{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } }
```

- `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no
exponent, no trailing zeros).
- `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an
`object.value` only. A top-level entity field that is null is always written as `null`.
- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each
port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the
canonical ISO-8601 form (`PT1H30M`) in every port.
- An absent block or key means `native`. An unknown key or value is refused when the config is
read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number.

Expand Down
Loading
Loading