Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cec110e
fix(codegen): generated routes ignore @formExclude and credential fie…
dmealing Oct 28, 2026
0053f02
fix(codegen): hide subtype credentials on an M:N onto a TPH base; Pyt…
dmealing Oct 28, 2026
186ec1d
refactor: simplify the #484 changes
dmealing Oct 28, 2026
82c82d0
fix(codegen): redact Java read-only mounts and Kotlin at the controll…
dmealing Oct 28, 2026
16f44a6
no-mistakes(review): apply reviewer inline fixes
dmealing Oct 28, 2026
525bb5c
no-mistakes(review): Trim codegen-ts barrel, note required-denied cre…
dmealing Oct 28, 2026
63df902
no-mistakes(review): Add behavioral credential checks for TS/Python p…
dmealing Oct 28, 2026
ffcc8e9
no-mistakes(review): Count TPH credential and denied marks on every s…
dmealing Oct 28, 2026
20d3243
no-mistakes(review): Deny hierarchy-marked TPH columns on every subty…
dmealing Oct 28, 2026
8e09a6e
no-mistakes(review): Fix stale KDoc on Kotlin M:N credential fields
dmealing Oct 28, 2026
9f71c47
no-mistakes(review): Widen TS and C# TPH subtype marks to hierarchy
dmealing Oct 28, 2026
0d4d866
no-mistakes(test): Import TphPlan in ejectable SpringGeneratorSupport…
dmealing Oct 28, 2026
50dac8a
no-mistakes(document): Docs current; fixed capability checklist claim…
dmealing Oct 28, 2026
13d47f9
Merge origin/main into fm/mo-adopter-fixes
dmealing Oct 28, 2026
73b19a8
fix(codegen): a served report hides a credential column in every port…
dmealing Oct 28, 2026
08d636a
Merge origin/main into fm/mo-adopter-fixes
dmealing Oct 28, 2026
862eb88
fix(codegen): a credential report column is neither filterable nor so…
dmealing Oct 29, 2026
176847e
no-mistakes(review): Test C# report JSON export omits credential keys
dmealing Oct 29, 2026
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
2 changes: 1 addition & 1 deletion .claude/rules/cross-language-porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Preserve the following contracts exactly across all language ports:
- Overlay/merge: same `package` + same object `name` across multiple files → merged. Last-writer-wins on attr conflicts; structural children accumulate.
- Default scan path: `metaobjects/**/*.json` (recursive).

**D1 is TS-only.** Cloudflare D1 is a peer of `sqlite`/`postgres` in TS's `dialect` vocabulary. It is SQLite at the SQL level — Java/Python/C# don't have an analogue (Cloudflare Workers run JS). When adding cross-language vocabulary, D1 doesn't constrain anything: its uniqueness is wrangler-CLI transport + Wrangler-native file layout (`migrations/<seq>_<slug>.sql`), both of which are TS-only concerns.
**D1 is TS-only.** Cloudflare D1 is a peer of `sqlite`/`postgres` in `meta migrate`'s `dialect` vocabulary (`migrate.dialect`, `--dialect d1`); codegen's `dialect` is `sqlite`/`postgres`/`mysql` and D1 uses `sqlite` (`"d1"` there is an error). It is SQLite at the SQL level — Java/Python/C# don't have an analogue (Cloudflare Workers run JS). When adding cross-language vocabulary, D1 doesn't constrain anything: its uniqueness is wrangler-CLI transport + Wrangler-native file layout (`migrations/<seq>_<slug>.sql`), both of which are TS-only concerns.

**Constants discipline:**
- TS: named constants, imported from `@metaobjectsdev/metadata/constants` (defined in 16 per-concern `*-constants.ts` modules under `packages/metadata/src/`, e.g. `core/field/field-constants.ts`, `persistence/db/db-constants.ts`). Never inline metamodel strings as literals in code.
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,29 @@ here.**
the command and rolls the sync back. A stale `docs/CONFORMANCE.md` cell that claimed Python passed
only 2 of the dependency corpus's cases is corrected.

- **Generated routes no longer accept or return fields they must not (#484).** A field marked
`@formExclude`, or a credential (a field that declares a `view.password`), is now **ignored** by
the generated create, PATCH and PUT in every port: the value is dropped, not rejected, so
an edit form that submits every field still saves, and a present `null` no longer clears it. A credential is
also never returned: not by list, get, or the echo of a write, or by a served report that groups by
it or takes its `min` or `max` (a report column is derived from its `@of` field, and it keeps the
credential mark; a report export names no column for it, and that column is not filterable or sortable). Before this, `@formExclude` kept
a field out of the generated form while the route still wrote it, and a generated read returned password
hashes. Generated forms (React, Angular) and the api docs omit credentials too. Your own code is unaffected:
the entity, DTO and generated queries still carry the field, so you keep writing a hash or token yourself.
A project with neither mark generates byte-identical output. If you relied on a route writing a
`@formExclude` field, write it from your own code. A field that is `@required` and also
`@formExclude` or a credential now makes the generated create route fail, because the route
can no longer supply its value: make the field optional, or create that entity from your own
code (see "Credentials and excluded fields" in `docs/features/api-contract.md`). Gated by the new `secret-fields/` sub-corpus
of the api-contract conformance corpus, in the generated lane of all five ports.

- **`meta gen` and `meta verify --codegen` refuse an unknown codegen `dialect`** instead of silently
generating Postgres code. `dialect: "d1"` gets a message that says to use `"sqlite"` (D1 is
SQLite; `"d1"` names only the `meta migrate` transport). `meta init --d1` now scaffolds
`dialect: "sqlite"` in `metaobjects.config.ts`; it used to write `"d1"`. If your config has `dialect: "d1"`,
change it to `"sqlite"`.

- **The "Model / metadata" link in the Python, C#, Java and Kotlin API docs no longer points at
a page that does not exist.** Those ports write no model pages, so the relative link was dead.
It is now written only when `modelBaseUrl` is set.
Expand Down Expand Up @@ -280,6 +303,12 @@ here.**

### Documentation

- **New recipes and a real D1 example.** `docs/recipes/` gains keeping drizzle-kit migrations
(`drizzle-kit-migrations.md`), guarding generated routes with your own auth
(`guarding-generated-routes.md`) and proving your `meta verify` gate can fail
(`prove-your-gate-fails.md`). The Cloudflare Workers recipe now has a per-request `drizzle(env.DB)`
example, and the `drizzle({} as never)` placeholder and the wrong `"d1"` codegen-dialect statements are corrected.

- **Two doc errors corrected against the code.** `docs/recipes/mysql.md` said Python ships a
Postgres driver only; it ships SQLite and MySQL drivers beside it. `docs/features/api-contract.md`
said only TypeScript enforces the `in`-list cap of 100; every port does.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,10 @@ soft-delete / status / type view it models.
and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`,
`view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`,
`view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that
carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these
only for TS adopters.** Only
carries them is valid in a backend port. The one exception is `view.password`: every port's generated
routes read it as a credential, so it is never returned and never written (see
"Credentials and excluded fields" in `docs/features/api-contract.md`).
Audit the other widgets only for TS adopters. Only
`view.base` / `view.currency` are cross-port-gated.

## Template — `template.*` (prompt pillar)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,4 @@ TypeScript is the reference implementation — it ships the full feature set:
- Output-parser codegen: **complete** (`outputParser()` generator; `parse*`/`safeParse*`/`extract*` emitted as `<Name>.response.ts` per **responding `template.prompt`** — one carrying `@responseRef`; ADR-0052).
- ObjectManager: **complete** (`@metaobjectsdev/runtime-ts`; `kyselyDriver` / `inMemoryDriver`).
- Schema migrate / `meta verify --db`: **complete** (TS owns the shared migrate engine — ADR-0015).
- D1 dialect: **TS-only by design** — `dialect: "d1"` in `metaobjects.config.ts` targets Cloudflare D1 via the Wrangler CLI layout. Do NOT recommend porting D1 support to other languages.
- D1 dialect: **TS-only by design** — `migrate.dialect: "d1"` (and `meta migrate --dialect d1`) targets Cloudflare D1 via the Wrangler CLI layout; codegen's `dialect` in `metaobjects.config.ts` is `"sqlite"` for D1 and `"d1"` there is refused. Do NOT recommend porting D1 support to other languages.
14 changes: 7 additions & 7 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`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/) | 117 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 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/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/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
Expand Down Expand Up @@ -365,9 +365,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc
`report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the
write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates).

### `fixtures/api-contract-conformance/` (117)
### `fixtures/api-contract-conformance/` (119)

All 117 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
All 119 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
routes answer identically over HTTP — list / get / create / patch+put / delete,
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
Expand All @@ -378,11 +378,11 @@ The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`,
`gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator
and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the
URL grammar `?filter[<field>][<op>]=<value>` (FR-009). On top of the 31 core
scenarios the corpus carries seven sub-corpora — `tph/` (10, single-table
scenarios the corpus carries eight sub-corpora — `tph/` (10, single-table
inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
`jsonb/` (5, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string),
`write-through/` (2, table-write + view-read entities), `projection/` (12, a
`write-through/` (2, table-write + view-read entities), `secret-fields/` (2, #484: a `@formExclude` or credential `view.password` field is ignored on create, PATCH and PUT, and a credential is never returned), `projection/` (12, a
read-only view answers reads and refuses writes with 405; a projection with no declared
identity has no item route; a projection keyed on a field not named `id` is addressed by
it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one
Expand All @@ -392,7 +392,7 @@ its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` re
a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java,
Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and
the port's own GENERATED API artifact booted over HTTP. `write-through/`,
`projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what
`secret-fields/`, `projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what
they test is whether a port's generator emits the routes. The `report/` sub-corpus is the one
that reads real views, so it runs full stack on Postgres, SQLite and MySQL
(`report/schema.sqlite.sql`, `report/schema.mysql.sql`, both TypeScript-produced) in every
Expand Down Expand Up @@ -606,7 +606,7 @@ files into a real Cube.
## Orphaned fixtures (tested but not yet documented)

The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37
+ render 16 + persistence 59 + api-contract 117 + source-resolution 25 + scope 10 +
+ render 16 + persistence 59 + api-contract 119 + source-resolution 25 + scope 10 +
dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance` and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ this tree is documentation, not the source of truth.
| See a worked, non-toy model end to end (projections, value objects, TPH, prompt payloads on one runnable spine) | [`../examples/advanced-modeling/`](../examples/advanced-modeling/) |
| Read the canonical spec (target-agnostic) | [`../spec/`](../spec/) |
| Find a release recipe (Cloudflare D1, etc.) | [`recipes/`](recipes/) |
| Keep drizzle-kit migrations while MetaObjects owns the model | [`recipes/drizzle-kit-migrations.md`](recipes/drizzle-kit-migrations.md) |
| Put generated routes behind your own auth (Fastify, Hono, `expose`) | [`recipes/guarding-generated-routes.md`](recipes/guarding-generated-routes.md) |
| Show that your `meta verify` CI gate actually fails on drift | [`recipes/prove-your-gate-fails.md`](recipes/prove-your-gate-fails.md) |
| See the deeper design rationale for a feature | [`superpowers/specs/`](superpowers/specs/) |

## Conventions used in these docs
Expand Down
28 changes: 28 additions & 0 deletions docs/features/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,34 @@ log-only.
| Kotlin | shipped — `metaobjects-codegen-kotlin` `KotlinSpringControllerGenerator` → Spring `@RestController` | One controller per writable entity (`source.rdb @kind="table"`) plus a READ-ONLY one per view-kind `object.projection` (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Filter operators ship via the generated `<Entity>FilterAllowlist` (`KotlinFilterAllowlistGenerator`) + an inline `parse<Entity>Filter` helper emitted in the controller. |
| Python | shipped — `metaobjects.codegen.generators.router_generator` → FastAPI `APIRouter` | One router per writable entity (`source.rdb @kind="table"`) plus a READ-ONLY one per view-kind `object.projection` (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Consumer wires the repository via FastAPI `app.dependency_overrides`; the generator emits a `Protocol` interface so the persistence layer (SQLAlchemy / asyncpg / etc.) is the consumer's choice. Filter operators ship via the generated `<entity>_filter_allowlist.py` (`filter_allowlist_generator.py`) + the shared `filter_parser` helper, wired into the list handler. |

## Credentials and excluded fields

Two marks narrow what the generated routes accept and send, in every port (#484):

| Mark | Create, PATCH, PUT | List, get, and the echo of a write |
|---|---|---|
| `@formExclude: true` | the field is **ignored** | returned |
| a child `view.password` (a credential) | the field is **ignored** | **never returned** (the key is absent, not `null`) |

A value for an ignored field is dropped, never rejected: the request answers `200` or `201` as if the key
were absent, and a present `null` does not clear it. That is the rule for every key outside the settable
set (`@mutability: writeOnce` on PATCH, the TPH discriminator, `@autoSet: onCreate`), and it is load-bearing:
a generated edit form submits every field it was given, so a `400` would fail every save. A required field
that is also ignored can never be created through the API, so mark such a field optional.

Only the generated HTTP boundary changes. The entity, its DTO and the generated queries still carry the
field, so your own code writes a hash or token and reads it back. Allowlists are unchanged: a field is
filterable or sortable only if you wrote `@filterable` or `@sortable` on it, so do not put those on a credential
(a filter on a secret answers whether a guess is right). To return a credential from a route, hand-write that
route; the generated one will not. Generated forms and the api docs omit both kinds of field as well.

A served report is covered too. A report column is derived from its `@of` field, and a derived column keeps
that field's credential mark, so a report that groups by a credential (or takes its `min` or `max`) never
returns the column, its `report-export` output has no column for it, and it is neither filterable nor sortable
(a report's other columns all are, so this one is taken out). `count` over a credential is a number and is returned.

`fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port.

## Read-only projections

**All five ports serve an `object.projection` whose only source is a read-only
Expand Down
4 changes: 3 additions & 1 deletion docs/features/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,9 @@ A derived column carries the type-shaping attributes of its `@of` field where th
(`@currency`, `@values`, `@intValueMap`, `@maxLength`, `@precision`, `@scale`, `@localTime`,
`@objectRef`, `@storage`, `@dbColumnType`, `isArray`) and nothing else: no `@column`, no
`@required` beyond the rules above, no `@default` (a measure's `@default` is applied in the
view, not carried), no validators.
view, not carried), no validators. The one view it does carry is a credential: a column whose
`@of` field declares or inherits `view.password` is itself a credential, so the served list route
omits it, an export has no column for it, and it is not filterable or sortable (#484).

### Measures

Expand Down
Loading
Loading