MetaObjects is a cross-language metadata standard for declaring typed entity models that drive code generation, runtime metadata access, and drift detection — across TypeScript, C#, Java, Python, and Kotlin (Kotlin runs on the JVM via metadata-ktx + codegen-kotlin).
The metamodel is the durable spine; generated code is the disposable artifact. Substrate is local-first: typed metadata lives in your repo, generated code is idiomatic per-language output that runs without any MetaObjects dependency at runtime. If @metaobjectsdev/* disappears tomorrow, you keep working code.
The first four ship per-language today across the five ports (TS / C# / Java / Python / Kotlin), with cross-port conformance corpora verifying byte-identical behavior. The fifth ships its vocabulary and its verify checks in every port, and its test scaffolding in TypeScript only:
- Codegen — emit idiomatic per-language code (Drizzle/Zod + Fastify for TS, EF Core + ASP.NET for C#, Spring REST + DTO + Repository for Java via
codegen-spring, Pydantic + FastAPI for Python, KotlinPoet + Exposed + Spring for Kotlin viacodegen-kotlin). Hand-edit-preserving regen via three-way merge. - Runtime metadata — load metadata at runtime, drive behavior dynamically (CRUD, validation, relationships, dynamic admin UIs, LLM tool registration). On Kysely (TS), a DB-API 2 driver via ObjectManager (Python), modernized JDBC + Spring-tx via OMDB (Java), Exposed (Kotlin), EF Core (C#).
- Drift detection —
meta verifycatches divergence between code and metadata (covers entity codegen, prompt templates, output parsers, schema). Quality-of-life on top of codegen + runtime. - Prompt construction — a prompt is code, not a string scattered across services. Declare a prompt's payload as a typed projection (payload bloat becomes a diff), keep its text external and provider-resolved, and render it deterministically: snapshot-testable, cache-stable (no whitespace change silently breaking exact-prefix prompt-cache hits), and drift-checked at build time so a renamed field can't degrade a prompt. Conformance-gated, so the guarantee holds in every language port. Render + payload-VO codegen +
verify+ parser-on-receipt for a respondingtemplate.prompt— one carrying@responseRef(FR-006) — + the output-format prompt fragment & tolerantextractparser (FR-010) ship in all five ports today (since 0.24.0 the whole inbound tier keys off@responseRef; atemplate.outputis outbound-only and emits no parser — ADR-0052) — the library-side building blocks of the pillar are complete. The one remaining library-side piece is MCP exposure of declared prompts/tools (seespec/roadmap.md); the application-level consolidation (eval harness, end-to-end declared-prompt orchestration) and consumer adoption are exercised in adopter projects, not in this library repo. Designed indocs/superpowers/specs/2026-05-22-fr-004-cross-language-prompt-construction-design.md. - Requirements and testing — declare what the software is supposed to do in the same model as the entities, so a capability claim is checkable instead of prose. The other four pillars keep the code honest about the model; this one asks whether the thing you said the software does is actually built — an absence no test can fail on, because a test exercises code that exists.
requirement.functional(fails when nothing implements it) andrequirement.architectural(fails when something violates it) are registered vocabulary in all five ports, with the loader enforcing the closed@statusenum.@implementedByis resolved, not trusted — it names a real member of the real model, so a claim whose implementation was renamed or deleted fails the build instead of going quietly stale.meta verifyreports the ledger on every run (unresolved links, entities no claim covers, gaps recorded versus gaps nobody has ruled on) plus an authoring lint whose findings can never fail a build;meta docsrenders it for humans and for agents. The port split, stated exactly: the vocabulary and theverifychecks are cross-port;requirementTests()— which scaffolds a test stub per claim — is TypeScript-only. A project that declares norequirement.*nodes sees no change at all. Note the standing carve-out:agent-context/skills/metaobjects-fit-assessment/SKILL.mddeliberately does NOT treatrequirement.*as an assessment axis — see the ruling in that file before "finishing the job" there.
Last refreshed 2026-09-12.
1.0 gating — the quiet period is RETIRED (2026-09-06). docs/1.0-readiness.md §G3 no
longer asks for "one coordinated release with no metamodel-breaking change." It measured a
variable the maintainer sets, and it had already converged — metamodelVersion held at 0.13
across v0.24.2–v0.24.5, four consecutive releases — before 0.25.0 spent the breaking slot
by decision. It is replaced by G3a (declared scope covered, nothing outstanding needs new
vocabulary — DECLARED 2026-09-07), G3b (DONE — docs/compatibility-policy.md carries
the correction bar: the three-part test under which input that never had a valid meaning may
stop loading in a PATCH, explicitly NOT covering retirement of vocabulary that worked), G3c
(DONE — the migration guide and compat policy are current at the cut) and G3d (OPEN, and the
one gate now blocking the cut — an adopter estate must run the RELEASE CANDIDATE with the drift
gate ENFORCED before promote; ruled 2026-09-07 knowing all six adopters are maintainer-owned,
because a conformance corpus gates the ports against each other and never against use). Ruling
in ADR-0035 Amendment 3. Do not reintroduce a waiting gate in any form.
Where the versions are. latest is 1.0.3 on npm, 8.0.3 on Maven Central,
1.0.3 on PyPI and 1.0.3 on NuGet (the Maven major is always npm major + 7, so
1.0.3 is 8.0.3). All four moved at 1.0.3: every port had changed product code, and PyPI and
NuGet — which sat 1.0.2 out at 1.0.1 — adopted the shared minor.patch then current and
skipped the number they missed (ADR-0035 Amendment 1). 1.0 is CUT: the 1.0.0-rc.5 through
rc.8 candidates are superseded, and npm's next tag was REPOINTED onto the release — not
deleted, because dist-tag rm 403s for every token we hold (docs/RELEASING.md §4).
metamodelVersion reads 1.0, frozen — C4 landed and G4 shipped it, and none of 1.0.1,
1.0.2 or 1.0.3 moved it. Per-release detail lives in CHANGELOG.md — it is the log, and this file does
not duplicate it.
The npm surface is 14 @metaobjectsdev/* packages in full lockstep; the two angular
packages are on their own 0.6.x line and are not published — source-only by decision
(ADR-0048). They build in-repo and stay off the registry until they meet that ADR's promotion
bar.
A lagging version number is INFORMATION, not drift. Since 0.24.5 a registry publishes
only when it has a changed product file, and when it does it adopts the shared minor.patch
then current, skipping the numbers it sat out (ADR-0035 Amendment 1, operative table in
docs/RELEASING.md). Two carve-outs: the 14 npm packages move atomically with each other, and
a change to expected-registry.json / metamodelVersion still forces all four, because that
is the contract every port byte-matches. So PyPI at 0.25.0 while npm is at 0.25.2 says
PyPI has had no product change since 0.25.0 — nothing is broken.
All five ports ship loader + canonical serializer + conformance + codegen + render + payload-VO + verify:
- TypeScript —
codegen-ts(Vite-style plugins; Drizzle, Zod, Fastify) +runtime-ts+migrate-ts+ the universal web client packages (runtime-web,react,tanstack). - C# —
MetaObjects(loader + canonical serializer + conformance) +MetaObjects.Render(Mustache + payload-VO +verify) +MetaObjects.Codegen(EF Core entities +AppDbContext+ CRUD minimal-API routes). Schema migrations are TS-owned (ADR-0015): the C# migrate engine and themigrate/--from-dbCLI surface were removed; the C# CLI isgen/verifyonly, packaged as a .NET tool invokeddotnet meta(not a baremeta— that name belongs to the Node schema CLI). EF Core runtime data-access stays per-port. - Java —
metadata+omdb+om+dynamic+core-spring+metadata-ktx(Kotlin facade) +codegen-spring(Spring controllers + DTOs + repositories + filter allowlists + payload records + output parsers) +codegen-mustache+codegen-plantuml+render+maven-plugin(metaobjects:generate/metaobjects:editor+ ametaobjects:verifycodegen-drift goal — distinct from the removed live-DBmetaobjects:verify; Kotlin generators run throughmetaobjects:generatevia the shared SPI). FR-003 (OMDB runtime persistence + binding registry + typed jsonb + Spring-tx + source/origin metamodel) fully shipped, including Plan 4 (engine-debt remediation: atomic mapping cache, JDBC codec registry,inTransactiontemplate). Schema migrations are owned by the TypeScript toolchain (@metaobjectsdev/cli migrate); the Java port's diff-and-converge migration engine and itsmetaobjects:migrate/ live-DB-driftmetaobjects:verifyMaven goals were removed, and per ADR-0015 Decision 2 the dev/test runtime auto-create path (MetaClassDBValidatorService+ the drivers'createTable/createIndex/createForeignKey/createSequenceDDL) is also removed — OMDB is pure data-access (CRUD/query/codec/transactions only). - Python —
metaobjects(loader + canonical serializer + conformance + render + verify + codegen) + anObjectManagerruntime layer. Themigratemodule was removed (schema is TS-owned, ADR-0015); Python is pure data-access (codegen + ObjectManager runtime), with ametaobjectsconsole-script (gen/verifycodegen — nomigrate). All five conformance corpora green. - Kotlin —
codegen-kotlin(KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators.integration-tests-kotlinruns the persistence-conformance corpus through Exposed against Testcontainers Postgres.
Cross-port conformance corpora (every port runs the shared corpus):
- Metamodel:
fixtures/conformance/(322 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live indocs/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-producedcanonical/schema.postgres.sql(Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The migration scenarios are exercised by TS only (TS owns schema migrations). The corpus now gates WRITES, not just reads (SP-H): anop: roundtripscenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. TheAllTypesentity (roundtrip-all-types.yaml) carries one field of every persistablefield.*subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an array-of-VOfield.object @isArray @storage:jsonbcolumn (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.classwere cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; seefixtures/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. - YAML / verify corpora green across the ports that ship those layers.
Key cross-language features shipped: FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core UsingEntity / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal GET /<source-plural>/{id}/<relation> + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete field.* subtype write+read round-trips cross-port via the persistence op: roundtrip gate; cut field.byte/field.short/field.class non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen).
Latest release: 1.0.3 (2026-09-12) — npm 1.0.3, Maven Central 8.0.3, PyPI 1.0.3, NuGet 1.0.3; the first cut since 0.25.0 where all four registries had changed product code. Carries FR-023 (metadata dependencies, Phase 1a on TypeScript + Python) and ADR-0055 in all five ports: overlay: true is applied in a DEFERRED pass after every source is parsed, so an overlay resolves against a base in any file — a mixed file, or its own file above it — and ERR_OVERLAY_NO_TARGET now means the target is gone rather than not parsed yet. The #160 overlay-only source partition is deleted in every port, and Java finally emits the error code it had declared but never attached.
See spec/roadmap.md for the active + planned work picture.
This repository is PUBLIC. Never commit references to other/private projects or to a developer's local environment. In any committed file — specs, plans, code, docs, fixtures — before every commit:
- No other-project names. Do not name private or sibling consumer projects. Use generic terms: "a downstream consumer", "the reference web consumer", "a C# adopter", "a sibling project".
- No absolute local paths. Never commit a developer's home path (e.g.
/home/<user>/…or a~/-rooted path). Use repo-relative paths or placeholders (<repo-root>,<consumer-repo>) in command examples. - Scan the staged diff for both of the above and genericize anything found before committing.
A local pre-commit hook enforces this (.githooks/pre-commit). The committed hook names no private project: it enforces generic structural patterns (absolute home paths) and loads a private-name denylist from a path you configure (kept in a private repo, never here). One-time per clone:
git config core.hooksPath .githooks
git config hooks.denyListPath /path/to/your/private/denylist.txt
It blocks commits whose added lines match (git commit --no-verify bypasses, discouraged); the npm author email is the one allowed exception. Guard a new private name by editing that private denylist (single source of truth) — never add real names to this public repo or the committed hook.
Pre-push typecheck gate (.githooks/pre-push, same core.hooksPath): bun test
transpiles per-file and does NOT typecheck, so type-broken code can ship green on the
test suite while the CI typecheck job goes red — and a direct admin push to main
bypasses branch protection. This hook closes that hole locally: when a push touches
server/typescript/ or client/web/, it runs the same bun run --filter '*' build && … typecheck gate CI runs and blocks the push when it is red (~6s on a clean tree;
skipped entirely for non-TS pushes). Bypass in an emergency with git push --no-verify
or SKIP_TS_TYPECHECK=1 git push. The Java/C#/Python compile+conformance gates do NOT run on PRs (hosted CI runs
them on release tags, a nightly schedule, and manual dispatch, for cost). Instead, every push to
main triggers local-ci.yml on the maintainer's self-hosted runner: parallel
per-port jobs, each running scripts/ci-local.sh --only <port> --strict-toolchains;
a nightly dispatch runs the full matrix. PRs get the leak-scan only — run
scripts/ci-local.sh --quick locally before opening one.
Lane selection is "not known-green", not "affected" (scripts/ci-ports-to-run.sh).
The selector unions the ports this push touched (scripts/ci-affected-ports.sh) with
the ports whose newest verdict on main is not a success, read from the workflow's
own run history — so a stale red lane gets re-run, not merely reported. Affected
alone has a hole that bit during the 1.0.1 cut: five lanes failed, the next commit was
docs-only, its run skipped every code lane and reported green, and the red sat
unverified under a green tip. skipped is walked past (it describes the selection, not
the code); anything that is not success — cancelled included — and any lane with no
verdict in the window counts as not-green, because the selector may only ever widen.
Both selector scripts are tested by the ci lane selection gate in the gates lane,
which also asserts the lane→port map against the workflow's real job list.
This repo holds all implementations of the standard, organized by deployment target → language/platform → framework integration:
metaobjects/
├── spec/ # canonical metamodel docs (target-agnostic)
├── fixtures/conformance/ # cross-language test fixtures
│
├── server/ # runs on a server
│ ├── typescript/ java/ python/ csharp/
│
└── client/ # runs on an end-user device
├── web/ # browser (TS-only — the browser is TS-native)
└── ios/ android/ # future
TypeScript plays two distinct roles, and the layout reflects that:
- Server-side TS is a peer port to Java/Python/C# at
server/typescript/. - Universal web client TS at
client/web/is consumed by ALL backends (a Java backend serving React still uses the TS client packages).
Where does a new package go?
- Server-side or client-side? → top-level dir.
- What language/platform? → second-level dir.
- What framework integration? → package name at the third level.
Worked examples: a Drizzle TS-server integration → server/typescript/packages/...; an Angular browser integration → client/web/packages/angular/; future iOS SwiftUI → client/ios/packages/swiftui/.
Server-side (server/typescript/packages/):
metadata/(@metaobjectsdev/metadata) — metamodel loader, types, constantscodegen-ts/(@metaobjectsdev/codegen-ts) — framework-neutral TS codegen engine (entityFile, queriesFile, routesFile, barrel)codegen-ts-react/(@metaobjectsdev/codegen-ts-react) — React codegen (formFile)codegen-ts-tanstack/(@metaobjectsdev/codegen-ts-tanstack) — TanStack codegen (tanstackQuery, tanstackGrid, tanstackGridHook)runtime-ts/(@metaobjectsdev/runtime-ts) — Node-side runtime (Kysely, Drizzle, Fastify helpers)migrate-ts/(@metaobjectsdev/migrate-ts) — migration toolingsdk/(@metaobjectsdev/sdk) — workspace memory, path helperscli/(@metaobjectsdev/cli, binarymeta) — CLI commands:init,gen,migrate
Client-side / universal web (client/web/packages/):
runtime-web/(@metaobjectsdev/runtime-web) — pure framework-agnostic browser core (currency, filter-qs, EntityFetcher contract, GridConfig). Zero React, zero TanStack.react/(@metaobjectsdev/react) — React runtime:useEntityForm,<CurrencyInput>.tanstack/(@metaobjectsdev/tanstack) — TanStack runtime:EntityFetcherProvider,<EntityGrid>, default cell renderers.- MetaObjects does not add a first-party package per framework. React ships a codegen+runtime pair; Angular ships source-only (ADR-0048's promotion bar). Any other framework is reached by owning and retargeting the generators (FR-040), not by waiting for an official package.
Each framework integration ships as a pair of packages — one for codegen (server-side, runs at meta gen time) and one for runtime (browser-side, runs in the user's app). Mirrors Prisma (prisma + @prisma/client), Apollo (@apollo/codegen-cli + @apollo/client), and Drizzle (drizzle-kit + drizzle-orm).
| Integration | Codegen | Runtime |
|---|---|---|
| React | @metaobjectsdev/codegen-ts-react |
@metaobjectsdev/react |
| TanStack (depends on React) | @metaobjectsdev/codegen-ts-tanstack |
@metaobjectsdev/tanstack |
Each codegen package emits imports that target its matching runtime package. Codegen packages live under server/typescript/packages/ because they execute server-side, even though their output targets the browser. Runtime packages live under client/web/packages/ and have zero Node-only deps.
Two disjoint dependency trees:
Runtime side (browser): Codegen side (server):
@metaobjectsdev/runtime-web ←┐ @metaobjectsdev/codegen-ts ←┐
↑ \ ↑ \
└── @metaobjectsdev/react ┐ ├── @metaobjectsdev/codegen-ts-react
↑ \ └── @metaobjectsdev/codegen-ts-tanstack
└── @metaobjectsdev/tanstack
The two-package split is the shape a first-party integration takes when there is one — it is not a commitment to add more. Reaching another framework is an ownership move, not a roadmap item: eject the generator and retarget its emit (FR-040).
A user's metaobjects.config.ts:
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack";
export default defineConfig({
generators: [entityFile(), queriesFile(), routesFile(), formFile(), tanstackQuery(), tanstackGrid(), barrel()],
});A consumer's React component:
import { formatCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput, useEntityForm } from "@metaobjectsdev/react";
import { EntityFetcherProvider, EntityGrid } from "@metaobjectsdev/tanstack";- TS runtime: Bun-first for development (zero-config TS, native test runner). Node-compatible for distribution; users install via npm/pnpm/bun without lock-in to Bun's runtime.
- Module system: ESM only. No CommonJS, no transpile step required.
- Storage format: JSON files in
metaobjects/meta.<concept>.jsonat project root..metaobjects/.gen-state/holds the codegen merge base: the snapshot bodies are gitignored (a second full copy of all generated output), but.hashes.jsonis COMMITTED — one hash per generated path, and the only thing that letsmeta gentell "this file is exactly what I wrote" from "somebody edited this" on a machine that did not generate it. Ignore it and a fresh clone or CI runner silently overwrites hand edits; commit it and an edited file is refused by name instead. - Codegen substrate: ts-poet for greenfield emit, ts-morph for in-place edits, Biome for format pass,
git merge-file --diff3for hand-edit-preserving regen. - Runtime substrate: Kysely for TS (user-provided connection, async-only).
- Migration substrate: Postgres + SQLite for TS v0.3.
- Metadata location: resolved via
resolveCollection()(@metaobjectsdev/sdk) — the single authority.metaobjects/is the default value ofsourcesand nothing else: no other module, command or user-facing message may assert that a directory of that name exists or is where metadata lives. Exactly five sites may name it —sdk/src/metadata-files.ts(DEFAULT_METADATA_DIR, its single definition),sdk/src/sources.ts(DEFAULT_SOURCES, the default),sdk/src/collection.ts(insideresolveCollection, applying that default),sdk/src/index.ts(the barrel re-export of the constant, no use), andcli/src/commands/init.ts(the scaffolder writing the layout). Enforced bysdk/test/no-hardcoded-metadata-dir.test.ts, whose allowlist demands a written reason per entry. See docs/features/metadata-sources.md.
- A custom DSL. Plain typed metadata only — Wasp's seven-year DSL-tax is the cautionary tale.
- A spec-driven workflow like Kiro / Spec Kit. Humans don't author rich specs; Claude proposes metadata, humans review.
- A proprietary runtime. All generated code runs without MetaObjects installed; runtime libraries are normal language-native packages.
- A prompt-to-app builder (not Lovable, Bolt, or v0). MetaObjects generates entity-shaped boilerplate; users hand-write the interesting business logic.
- Replacing CLAUDE.md, cursor rules, or other prompt-engineering surfaces — MetaObjects complements them.
- An LLM provider. The MCP integration is model-agnostic.
- An AI agent platform. (Codegen Inc. died Jan 2026 trying that.)
- For new features or non-trivial changes, prefer the brainstorming → plan → implementation flow. Don't jump to implementation without a plan.
- Entity records are prescriptive (drive codegen + runtime). The other record types (decision, principle, convention, glossary, failure) are descriptive (supporting context for reasoning).
- Confidence and provenance are first-class on memory records. Bias toward under-flagging on drift checks (false-positive rate >15% is a kill criterion).
- Templates are user-owned plain TS. Anything inside a generated file is fair game to hand-edit; three-way merge preserves it.
- TDD discipline throughout implementation.
- Cross-language conformance fixtures live at
fixtures/conformance/. Adding new metamodel behavior means adding a conformance fixture so every language port (TS, Java, Python, C#) automatically verifies it. Seespec/conformance-tests.mdfor the fixture format and canonical serializer contract.
Default convention: one file per domain concept under metaobjects/. Multiple objects per file when they share a domain. Projections (source.dbView) live inline with their base entity.
metaobjects/ is the default value of sources in .metaobjects/config.json — never a requirement. A project declaring sources explicitly can point anywhere (and need not have such a directory at all); "sources": [], which is what meta init scaffolds, takes the default.
project-root/
├── metaobjects/ # VISIBLE — entity declarations
│ ├── meta.common.json # shared abstracts (BaseEntity)
│ ├── meta.commerce.json # Program, Purchase, ProgramSummary
│ ├── meta.users.json # Subscriber
│ └── meta.content.json # Video, Week, Workout, Exercise
├── .metaobjects/ # HIDDEN — tool state
│ ├── config.json # static project state
│ └── .gen-state/ # codegen merge base
│ ├── .hashes.json # COMMIT THIS — one hash per generated path
│ └── <mirrored output> # gitignored (a 2nd copy of all output)
└── metaobjects.config.ts # runtime config
File-naming: meta.<concept>.json. Each file declares its package:
Optional layered overlay pattern (for larger projects with team-level concern boundaries):
metaobjects/
├── meta.user.json # STRUCTURAL (always present)
├── meta.user.ui.json # UI overlay (views, layouts) — overlay: true
└── meta.user.db.json # DB overlay (sources, dbColumns) — overlay: true
All three share the same package and object name. The Loader merges them. meta.user.ui.json
and meta.user.db.json's top-level object declaration must carry overlay: true: the loader's
merge doesn't require it (a same-(type, name) redeclaration merges either way), but leaving it
off is exactly what meta verify's overlay authoring lint (docs/features/metadata-dependencies.md)
flags as advisory — and it's what would turn a renamed/removed User into a silent second object
instead of a loud ERR_OVERLAY_NO_TARGET. Use only when team-level concerns justify the file
proliferation. Default to single-file-per-domain.
BaseEntity pattern: shared abstract bases live in meta.common.json. Concrete entities use extends: "BaseEntity" to inherit id + createdAt without redeclaring.
apiPrefix in metaobjects.config.ts is a server fact: it is where the generated routes
mount, and codegen bakes it in, because the code that registers /api/customers is the
server.
export default defineConfig({
apiPrefix: "/api", // generated routes mount under /api
});It does not reach the browser. A client's base URL is a DEPLOYMENT fact — one bundle may be served against a separate API host, a dev proxy, a preview environment or an SSR pass — so it is supplied at runtime by the provider, not stamped into the entity descriptor:
<EntityFetcherProvider fetcher={fetcher} baseUrl="/api">baseUrl is optional and defaults to "", which is right for the apiPrefix: "" scaffold and
a trap for everyone else: omitting it drops the prefix from every generated hook and compiles
clean. meta verify advises when apiPrefix is non-empty and a provider passes no baseUrl.
@metaobjectsdev/codegen-ts follows a Vite-style plugin model.
Core interface — every emitter implements Generator:
import type { Generator, GenContext, EmittedFile } from "@metaobjectsdev/codegen-ts";
interface Generator {
name: string; // kebab-case; surfaces in diagnostics
filter?: (entity: MetaData) => boolean;
generate(ctx: GenContext): EmittedFile[] | Promise<EmittedFile[]>;
}Helpers perEntity() and oncePerRun() cover the common "file per entity" / "one-shot" cases.
Built-in factories: entityFile, queriesFile, routesFile, formFile, barrel. Per ADR-0034 (scaffold-and-own), meta init copies the entityFile/queriesFile/routesFile/barrel reference templates into the consumer repo at codegen/generators/*.ts and the scaffolded config imports those owned local copies. The owned copy is the ONLY import path for those four: the deprecated @metaobjectsdev/codegen-ts/generators re-export of them was removed at the 1.0 cut. meta eject <generator> copies any other ejectable generator at any time (--list names them all).
User wiring (metaobjects.config.ts):
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
export default defineConfig({
outDir: "packages/database/src/generated",
dialect: "sqlite",
apiPrefix: "/api",
generators: [entityFile(), queriesFile(), routesFile(), barrel()],
});Per-target output directories. Each generator can write to its own
directory/package via a named targets registry + per-generator target, so
generated code lands with its runtime concern (model → database package, routes →
API app, hooks/forms/grids → web app). A target is { outDir, importBase?, outputLayout?, dbImport? }; the top-level outDir is the implicit default
(entity-module) target. Cross-target references to the entity module are emitted as
extension-less importBase package paths (@acme/database/generated/acme/commerce/Program)
while same-target references stay relative; the entity-module target must set
importBase when any generator routes elsewhere. With no targets, output is
byte-identical to a single-outDir project. Full config reference: @metaobjectsdev/cli
README, "Multiple output targets".
Two config files, by design:
metaobjects.config.ts(TypeScript) — generator wiring, type-checked..metaobjects/config.json(JSON) — static project state. Parseable by non-TS tooling (CI scripts, etc.).
The runner runGen() (1) loads metadata, (2) resolves targets + derives the
entity-module target, (3) precomputes shared render state once, (4) runs each
generator with a per-target RenderContext, (5) errors on conflicting duplicate
full output paths — two emissions of the same path whose CONTENT differs, where the
result would depend on generator order — while byte-identical duplicates collapse to
one file (#266: a shared artifact rendered from the whole loaded root, like the shared
enums.ts, is emitted by every entityFile() instance); also errors on an unknown
target, missing importBase for cross-target imports, or any generator throw, (6) writes each file under its target's outDir, deciding from
.metaobjects/.gen-state/ — a three-way merge against the snapshot body when one is
present, else the committed .hashes.json (hash matches what we recorded writing ⇒
overwrite; edited or unrecorded ⇒ refused). The @generated header is informational:
every use of it is an emitter stamping the marker into output, and the overwrite decision
never reads it. Because the snapshot bodies are gitignored while .hashes.json is
committed, a fresh clone or CI runner takes the second branch — so a hand-edited generated
file is REFUSED there rather than merged.
Generated CRUD endpoints support a typed, metadata-driven filter + sort layer:
URL grammar (bracketed qs): ?filter[field][op]=value&sort=field:asc|desc&limit=N&offset=N. Bare value is sugar for eq.
Nine operators, gated by field subtype:
eq,ne,gt,gte,lt,lte,in,like,isNull- Strings get
eq/ne/in/like/isNull; numbers + dates geteq/ne/gt/gte/lt/lte/in/isNull; booleans geteq/isNull.
Authoring: mark fields with @filterable: true. @sortable inherits from @filterable by default.
Generated artifacts per entity:
<Entity>FilterAllowlist— server-side allowlist<Entity>SortAllowlist— server-side sort allowlist<Entity>Filter— client TS filter type
Client usage:
import { useSubscribers } from "./generated/Subscriber.hooks";
const { data } = useSubscribers({
email: { like: "amy@%" },
subscribed: true,
sort: "createdAt:desc",
limit: 25,
});Server validation: every request validated against the allowlist. Unknown field / disallowed op / invalid value → 400 with structured error code.
Leading wildcards are rejected by default (TS generated routes): the generated allowlist ships leadingWildcard: false on every field, so a like pattern starting with % (e.g. "%@example.com") → 400 filter.leading_wildcard_disallowed — an unanchored LIKE defeats index usage, so it is fail-closed. Opt in per field by hand-editing that field's entry in the generated <Entity>FilterAllowlist to leadingWildcard: true (hand edits inside generated files are preserved by the three-way merge). This gate is a TS-only extension — the other ports do not enforce it (see docs/features/api-contract.md, "TS-only filter extensions").
Architecture: parseFilterParams (in @metaobjectsdev/runtime-ts/drizzle-fastify) translates parsed qs into a Drizzle expression tree. buildFilterQs (in @metaobjectsdev/runtime-web and @metaobjectsdev/tanstack) serializes a typed filter object back to a bracketed qs URL.
source is a top-level metadata type describing where an object's data lives. Subtypes: dbTable (writable, default) and dbView (read-only).
Authoring a projection:
{ "object.entity": {
"name": "ProgramSummary",
"extends": "Program",
"children": [
{ "source.dbView": { "@name": "v_program_summary" }},
{ "field.int": { "name": "weekCount", "children": [
{ "origin.aggregate": {
"@agg": "count", "@of": "Week.id", "@via": "Program.weeks" }}
]}},
{ "identity.primary": { "@fields": ["id"] }}
]
}}origin subtypes: passthrough (cross-entity field reference) and aggregate (count/sum/avg/min/max). Origins drive view DDL.
Source-aware codegen dispatch:
- Projection (dbView only) → read-only Zod, read-only routes, read-only hooks.
- Write-through (dbTable + dbView) → mutations target table, queries target view.
- Vanilla entity → standard behavior.
columnNamingStrategy in metaobjects.config.ts: snake_case (default) | literal | kebab-case.
field.currency declares "this column stores money as integer minor units."
{ "field.currency": {
"name": "priceCents",
"@currency": "USD",
"children": [
{ "view.currency": { "@locale": "en-US" }}
]
}}Storage: integer minor units (cents for USD, yen for JPY). Wire format is unchanged from long. Server never formats currency; all formatting is client-side via Intl.NumberFormat.
Runtime imports (browser-safe sub-paths):
import { formatCurrency, parseCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput } from "@metaobjectsdev/react";Cross-language ports must preserve the wire contract: integer minor-unit storage, @currency (ISO 4217), @locale (BCP 47) attrs.
@metaobjectsdev/codegen-ts-tanstack ships two generators:
tanstackQuery()— emits<Entity>.hooks.tsper entity (5 hooks:useEntity,useEntities,useCreate/Update/Delete<Entity>).tanstackGrid()— emits<Entity>.columns.tsxper entity with alayout.dataGridchild.
Grid metadata:
{ "layout.dataGrid": {
"name": "default",
"@columns": ["email", "firstName", "subscribed", "createdAt"],
"@defaultSortField": "createdAt",
"@defaultSortOrder": "desc",
"@pageSize": 25
}}Runtime surface (@metaobjectsdev/runtime-web and @metaobjectsdev/tanstack):
<EntityFetcherProvider value={fetcher}>— supplies the fetcher function.<CellRendererProvider value={{...}}>— renderer overrides keyed by view subtype.<EntityGrid columns={...} grid={...} data={...} />— opinionated TanStack Table component.
Narrowing what emits: wire only the generators whose output you import, and narrow one with its filter option — filter is ANDed with the generator's built-in gates, so it can only narrow. There is no @emit* metadata attribute for this (@emitTanstack / @emitRoutes / @emitForm / @emitGrid / @emitAngular were never registered vocabulary — they passed meta gen and failed meta verify; meta upgrade --apply removes them). The one opt-IN, a TPH subtype's own per-subtype grid, widens rather than narrows, so it is a generator option: tanstackGrid({ tphSubtypeGrids }), with the same predicate passed to tanstackGridHook().
Preserve the following contracts exactly across all language ports:
Metamodel subtype vocabularies (must be identical across languages): the registry-conformance gate (fixtures/registry-conformance/) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to expected-registry.json. All five ports (TS / C# / Java / Kotlin / Python) are live + green (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See fixtures/registry-conformance/README.md.
- Filter operators:
eq,ne,gt,gte,lt,lte,in,like,isNull - Object subtypes:
entity(owns data: own identity, writable sources, lifecycle),value(pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; mayextendsentity fields for shape; a value-hosted field may carryorigin.passthroughbut never an assembly origin),projection(derived read-only representation: fieldsextends-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only@kinds; the declared field set IS the exposure — inclusive list, fail-closed). A field carryingorigin.*is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable@kind(read-only kinds only in read role). See ADR-0028. (FR-024 Phase E —object.projection/valueare registered inexpected-registry.jsonand the projection/value validation passes [identity pass-through, value-purity, projection-licensing,@viainference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The B4b entity-primary-source-readonly cutover [the "writable@kind" clause above —ERR_ENTITY_PRIMARY_SOURCE_READONLY] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs labelobject.projectionunits asprojectionand document their generated<Name>Dto) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) - Source subtypes:
rdb(paradigm; ADR-0007). The pre-v2dbTable/dbViewsubtypes are RETIRED —source.rdb+@kind: table|view|materializedView|storedProc|tableFunctionis the form, with read-only-ness derived from@kind. Multi-source via@role(exactly oneprimaryper object). Source physical name =@table(NOT@name); field physical name =@column(renamed from@dbColumn). Referential actions on relationships:@onDelete/@onUpdate. - Origin subtypes:
passthrough,aggregate,collection,computed,first(concrete;baseis the abstract root).passthroughis legal on anobject.value-hosted field (FR-015 parameter lineage); the four assembly origins (aggregate/computed/collection/first) live onobject.projectiononly — a value-hosted assembly origin isERR_SUBTYPE_RULE_VIOLATION(#210). - Relationship subtypes:
association,aggregation,composition. Cardinality via@cardinality: one|many; target via@objectRef. M:N (FR-018) slim vocabulary:@cardinality: "many"+@objectRef(target) +@through(the junction/through entity — a third entity that MUST declare twoidentity.referencechildren, one per FK side). The relationship's FK fields are derived from those references (theidentity.referenceSSOT for FK direction), never restated.@sourceRefField(optional) disambiguates a directed self-join by naming the source-side FK field on the junction (the other reference is the target side).@symmetric(optional boolean) marks an undirected self-join (union-on-read) — valid only when@objectRef== the declaring entity, and mutually exclusive with@sourceRefField. The pre-FR-018@joinEntity/@joinFieldsattrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField →ERR_BAD_ATTR_VALUE; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N →ERR_INVALID_RELATIONSHIP. - Index subtypes:
index.lookup(non-unique retrieval index; uniqueness is encoded in the type:identity.secondary= unique alternate key,index.lookup= non-unique;@uniqueis REMOVED fromidentity.secondary—ERR_UNKNOWN_ATTRon any legacy@unique). RDB-physical escapes@using/@expr/@where/@ordersare registered by the db provider on bothidentity.secondaryandindex.lookup.index.fulltext/index.vector/index.spatialare reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See ADR-0040. - Layout subtypes:
dataGrid - API subtypes:
api.base/api.operational(request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives inbinding.*per operation:restnow,messaging/grpcreserved). Children:operation.query(outputRef →object.projection) /operation.command(inputRef →object.value, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declaredapiextends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See ADR-0030. (FR-024 declared-API — planned; not yet inexpected-registry.json; the remaining third of FR-024 after the projection/value taxonomy + validation parity.) - Currency attrs:
@currency(ISO 4217),@locale(BCP 47) - Schema attrs:
@schemaonsource.rdb(DB schema name; Postgres defaultpublic, SQLite rejects non-default values) - Storage attrs:
@storageonfield.object(with@objectRef) — valuesflattened/jsonb/subdocument. Unifies "owned types" (flattened storage) and "structured JSONB" (jsonb storage). Defaults to single-jsonb-column when absent (back-compat). - Enum:
field.enumis a first-class field subtype (peer ofcurrency), string-backed. Required@valuesstring-array attr (member symbols). Members must be a non-empty set, each matching^[A-Za-z_][A-Za-z0-9_]*$, no duplicates — every port's loader enforces this (own-only) emittingERR_BAD_ATTR_VALUE(missing@values→ERR_MISSING_REQUIRED_ATTR). Reuse via abstractfield.enum+extends. Codegen: TS union +z.enum, C#enum+ EFHasConversion<string>(), DBvarchar+CHECK. Int-backed storage shipped (@intValueMap, a{memberSymbol: int}map, switches the column tointeger+ intCHECKwhile the wire format, the generated type and every runtime value stay the member SYMBOL; a MAP rather than a positional array so reordering@valuescannot silently re-map every member;@isArray+@intValueMapis a LOAD ERROR, and an unmapped stored integer THROWS in all five ports). Display labels and native PG enum remain deferred (seedocs/superpowers/specs/2026-05-23-enum-datatype-design.md). - Documentation common attrs (any node):
description,title,notes,deprecated,replacedBy,seeAlso,aliases. Registered via the cross-languagecommonAttrsregistry hook (registerCommonAttrs/RegisterCommonAttrs/register_common_attrs/ JavaMetaDataRegistry.registerCommonAttribute— wired in all four ports).notesis the internal-only rationale slot — never emitted to user-facing doc-gen (JSDoc / XML-doc / PostgresCOMMENT ON/ Mermaid prose). Thedescription/notessplit is by CONTENT KIND, not audience — an audience split invites writing the same content twice at two levels of politeness.descriptionstates what the element IS and COVERS (scope and boundary, derivable from the model);notesstates what you had to look OUTSIDE the model to learn (evidence, citations, what breaks if it changes). Mechanical test: a sentence belongs innotesexactly when it would have to change because the IMPLEMENTATION changed while the model did not.titleis a noun phrase,summarya one-line sentence. TS doc-gen ships all three tiers; C# ships XML-doc + COMMENT ON. Seedocs/superpowers/specs/2026-05-24-documentation-provider-design.md.
Wire format:
- Currency: integer minor units on the wire always. Float arithmetic for money is forbidden.
- Pagination:
?limit=N&offset=N— identical across all generated endpoints. - Runtime return types: a port's runtime
ObjectManagerreturns native in-process language types (field.decimal→BigDecimal/decimal/Decimal, TSstring; temporal→native; jsonb→native map). Wire canonicalization (the bullets above +normalization.md) is applied at the serialization boundary, never inside the runtime query path. See ADR-0019.
Grammar:
- Dotted-path syntax for
@via:"Program.weeks"or"Program.weeks.workouts". - Dotted-path syntax for
@of:"Week.id". extendsmay target a nested child to ANY depth:Customer.id, cross-packageacme::sales::Customer.id, triple-nestCustomer.priceCents.display(object → field → view). Addressing model: a package qualifies the ROOT-level node only; each subsequent dotted segment traverses CHILD NAMES (nested children carry BARE names — packages never fold onto non-root nodes). Intermediate segments resolve by unique name (cross-type collision = unresolved); the FINAL segment is type-scoped to the referrer (a field resolves fields; an identity identities; a view views).extendsis THE inheritance mechanism;origin.*never inherits.@vialives onorigin.*only and may be omitted ONLY when exactly one single-hop relationship leads from the base entity to thefrom/ofentity (multi-hop always explicit). See ADR-0029.- Package segments:
::separator —acme::common::id. - Canonical JSON: reserved structural keywords are bare (
name/package/extends/abstract/overlay/isArray/children/value); inline attributes are@-prefixed.@-prefixing a reserved word (e.g.@isArray) is invalid (ERR_RESERVED_ATTR). YAML authoring is sigil-free — bare attrs; the desugar re-adds@when lowering to canonical JSON; canonical JSON is the on-disk interchange (YAML is the universal authoring front-end across ports). See ADR-0006.
Loader pipeline:
extends:resolution happens after all files are loaded (deferred, not eager).- Overlay/merge: same
package+ same objectnameacross 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.
Constants discipline:
- TS: named constants, imported from
@metaobjectsdev/metadata/constants(defined in 16 per-concern*-constants.tsmodules underpackages/metadata/src/, e.g.core/field/field-constants.ts,persistence/db/db-constants.ts). Never inline metamodel strings as literals in code. - New type or subtype names: add to TS constants first; add the parallel in other language implementations.
Two contracts, two numbers — metamodelVersion moves when the metamodel does (ADR-0035 Amendment 2).
The package version promises the SOFTWARE surface (exports, CLI flags, generated-code shape);
metamodelVersion promises the METADATA contract (registered vocabulary, canonical/interchange
format, wire contract). A breaking metamodel change moves metamodelVersion's major and does not
force a package major. So any change to the registered vocabulary is also a version edit — bump it
with node scripts/check-metamodel-version.mjs --set <version>, which writes the manifest and all four
port constants at once (Kotlin emits through the JVM's; a partial edit only fails in the forgotten
port's lane). The gate node scripts/check-metamodel-version.mjs runs in ci-local.sh's gates lane:
it diffs expected-registry.json against the last release tag, classifies, and fails if the version
did not move enough. Pre-1.0 a breaking change moves the MINOR, as the package line does at 0.x.
Its one blind spot is stated, not hidden — a rule can change with no machine-readable footprint (#210's
only manifest edit was a rules prose string), so prose changes prompt a question rather than being
classified, and answering it is a human step. Post-1.0 the caret rule no longer gates the metadata
axis, so a release that moves metamodelVersion must say so in the CHANGELOG.
These are the load-bearing principles that have emerged through implementation. Apply them every time.
-
Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037) — driven by semantic behavior, not surface storage. Don't ask "is X a string/number/date?"; ask "what does X do?" Ordered test: (0) derivable from existing subtype + attrs (
isArray/@maxLength) + structure? → derive in codegen, add nothing; (1) physical-only (native type/meaning unchanged)? → the@dbColumnTypeescape hatch, not first-class vocab; (2) logical — does X have its own native type, behavior, or attributes (a thing that owns custom logic)? → subtype (the extension point:field.uuid,field.uri,field.inet); a structural variant within a subtype (changes generated shape, shares native type)? →@kind(the one chartered structural-variant axis: source table/view, uri url/urn — never a catch-all, never on a plain string); otherwise X just modifies/validates/configures an existing type → attribute (boolean flag@localTime; validation@stringFormatemail/hostname; config@maxLength). The "string formats" set splits by behavior: url/uri→field.uri, ip→field.inet(native types + behavior), only email/hostname→@stringFormat(plain validated strings); uuid is alreadyfield.uuid. Self-documentation over economy; no same-name overloads (hence@stringFormat, not a third@format). Consult it every time. See ADR-0037. -
Pattern-derivable from metadata = codegen, never hand-code. This is the metaobjects raison d'être. If you find yourself proposing that users hand-write something the metadata fully describes (FK references, basic CRUD, validator chains, type-safe finders, relations() blocks), stop. Codegen it. The only exception is what metadata genuinely cannot express (custom SQL views, regex patterns from outside metadata, business logic). When in doubt, generate.
-
Study reference implementations for subtle pipeline behavior; don't re-derive from spec. For complex orchestration (loader, parser, super resolution, overlay/override merging, registry lifecycle), the spec describes WHAT but the implementation captures HOW — including edge cases and error handling. When porting, read existing implementations first. First-principles reasoning produces subtly wrong behavior that breaks cross-language interop.
-
"Validated by spike" ≠ "right design". A spike proves a technique works under a specific test. It doesn't prove it is the best production choice. Always ask "what's the UX cost?" alongside "does this work?"
-
NEVER call
own*()accessors by default — resolving/effective is the default;ownbreaksextends(ADR-0039).extendsis a super-reference, not a flatten: inherited attrs/children live on the parent, reachable only via the resolving accessors (attr()/children()/getMetaAttr(name)/ Pythonattrs().get()). Reading a field/node's effective property (isArray,subType,@maxLength,@precision,@default,@column,@objectRef,@storage, …) or iterating its member set through an own-only accessor (ownAttr/ownChildren/ownFields/field.isArraynative flag / Java,false) silently drops everything inherited viaextends— corrupting codegen and runtime. The one legitimateown*()use: codegen emitting a generated subclass, iteratingownFields()so inherited members aren't re-emitted (the generated base already has them). The metamodel-internal siblings (own-mode canonical serializer, overlay-merge, super-resolution walks) use the same "emit only the declared-here layer" principle. The one attr deliberately own-only is@dbColumnType(physical, never inherited). Everyown*()call must carry a comment naming its sanctioned case; any other own read is a bug. Watch the naming inversion: Pythonattr()is OWN, TSattr()resolves. See ADR-0039. -
Bind metadata→native types at build time, never runtime reflection. Resolving an object's native class/module from its FQN must happen in generated code (static imports for data-oriented ports; a domain-sliced, FQN-keyed registry for OO ports), not via
Class.forName/Type.GetType/importlib— runtime reflection is impossible in TS and breaks under GraalVM native-image / .NET AOT. See ADR-0001. -
Record significant cross-cutting decisions as ADRs. Durable, cross-language/cross-feature architectural contracts live in
spec/decisions/(Nygard format). Consult them before changing a cross-language contract; add a new ADR when you make one. Feature-level decisions stay in the FR spec; this file holds only the one-line rule + the pointer. -
Strict metadata provenance — never invent a metamodel attribute (ADR-0023). Every type/subtype/attribute the loader accepts in THIS repo must come from a registered metamodel provider; the library boots strict + seals the registry after the defined-provider bootstrap, so any undeclared attr is
ERR_UNKNOWN_ATTRand any post-bootstrap registration isERR_REGISTRY_SEALED(codegen "making up" an attr is a hard build failure). Before adding ANY new metamodel attribute: (1) prove it cannot be computed from existing metadata — if a generator can derive it (FK refs, column types, validator chains, naming), derive it, don't add an attr; (2) get explicit human agreement and write the can't-be-computed justification into the FR spec / ADR; (3) add it to a registered provider AND aregistry-conformancefixture so all five ports gate it. Downstream apps may add their own providers or loosen strictness — but the library's tests never permit an unregistered attr. See ADR-0023. The legitimate escape hatch for arbitrary author-supplied properties is the registeredattr.propertiesbag (exempt from the strict-attr check), not a new first-class attr.
- Named constants for metamodel strings — always. Type names, subtype names, reserved JSON keys, special attribute names, structural separators, and wildcards live in per-concern
*-constants.tsmodules underpackages/metadata/src/, barreled into the browser-safe@metaobjectsdev/metadata/constantsentry — import from there and use them. Gets you compile-time typo safety. (This rule used to name a singlepackages/metadata/src/constants.ts; that file holds no constants and has not for some time.) - Use
as constarrays + type unions for closed sets (e.g.,FIELD_SUBTYPES = [...] as const; type FieldSubType = (typeof FIELD_SUBTYPES)[number]). - String literals OK only for: error message text, instance/entity names that are user data, and test data values that aren't metamodel-level concepts.
- No backwards-compat hacks.
- No
anyescape hatches. Useunknownand narrow. - Never
instanceofa metadata node from another package. Cross-package code (codegen-ts,migrate-ts,runtime-ts,cli) identifies nodes with the guards@metaobjectsdev/metadataexports —isMetaRoot/isMetaObject/isMetaField/isMetaSource/isWritableSource/isReadOnlySource— neverx instanceof MetaSource. Two physical copies of the package in one process (a globally-installed or linkedmetaCLI plus a project-local dependency) give the class object and the instance different identities, soinstanceofreturns false for a real node. The failure is silent: incodegen-tsthe entity reads as "not backed by any store" and simply emits no table/queries/routes; inmigrate-tsit drops the table from the EXPECTED schema, someta migrateproposesDROP TABLEagainst a live database. This is the same class-identity defect that split ts-poet'sCodeobjects in 0.21.6. The CLI's alias map (load-metaobjects-config.tsCLI_PKG_PATHS) closes it formeta gen/migrateonly — a consumer embeddingrunGen()or the migrate engine programmatically never runs it. Sites insidemetadataare immune by construction (a package's own module graph resolves its own files) and keep usinginstanceof. Mechanism + blast radius:metadata/src/shared/node-guards.ts.
meta init # scaffold metaobjects/, .metaobjects/, codegen/generators/, metaobjects.config.ts, .gitignore
meta gen [<entity>...] # codegen: render templates → format → three-way merge → write
meta gen --dry-run # preview without writing
meta eject <generator> [--list] # copy a reference generator into the repo to own (FR-040)
meta migrate # diff metadata vs DB schema; emit migration SQL
meta migrate --dry-run # preview without writing migration file
meta migrate apply-pending # replay the committed chain against --db (no diff, no metadata)
meta verify --codegen --docs --templates # drift gates: generated code, docs pages, prompt bodies
meta verify --db | --replay # schema drift vs a live DB | the chain applies to an empty one
meta docs [--agent] # neutral model/api pages; --agent writes the three agent/ pages
meta upgrade --apply # rewrite metadata a retirement made illegal
meta types [<type>] --format json|toon # what vocabulary this build actually registers
The above is the Node meta (schema + TS codegen). Each non-TS port runs
codegen through its own build tool — dotnet meta gen/verify (C#),
mvn metaobjects:generate/:verify (Java/Kotlin), metaobjects gen/verify
(Python). Schema (migrate, verify --db) is Node-meta-only. Full matrix +
rationale: docs/features/cli.md (locked CLI architecture,
ADR-0015).
The Bun workspace root is the repository root (/package.json), which globs server/typescript/packages/* and client/web/packages/*. Java/Python/C# live outside the JS workspace (not globbed). Run bun install once at the repo root. Run bun test scoped — cd server/typescript && bun test for the server suite (this also picks up server/typescript/bunfig.toml's test preload), and per-package for client/web. Never run a bare bun test at the repo root: it walks java/, python/, csharp/, and fixtures/ looking for test files, turning a fast per-package run into many minutes.
bun install # once, at the repo root
cd server/typescript && bun test # server suite (per-package; a bare run over the whole server suite now exceeds 5 min — scope it)
cd client/web/packages/<pkg> && bun test # a single client package
bun run --filter '*' typecheck # whole workspace, from repo root
bun run --filter '*' build # whole workspace, from repo root
PRs welcome. When contributing:
- Follow the TDD discipline: write tests first, then implementation.
- Use named constants for all metamodel strings — never inline
"field","object", etc. - No
any— useunknownand narrow. - Run
bun testin the relevant package before opening a PR. All tests must pass. - For cross-language changes, ensure the wire format and vocabulary are preserved exactly.
- Look at existing generator implementations before adding a new one — the pattern is intentionally consistent.
For significant new features or architectural changes, open an issue first to discuss the approach.
Publishing: To iterate an unreleased change against a downstream project, use docs/features/prerelease.md — publish to the private registry (bun run prerelease:publish), consume it, iterate, and revert with one verified command. For full public releases, see docs/RELEASING.md — the procedure (RC → smoke-test → promote) plus the non-obvious gotchas (publish with bun, regen the lockfile after every version bump, runtime imports must be dependencies, verify a real external install in npm and pnpm).
Never hardcode the npm publish set. scripts/publish-set.mjs is the single source of
truth for WHICH @metaobjectsdev/* packages a release publishes and in WHAT ORDER; both
publish paths — scripts/release.mjs and .github/workflows/publish-npm.yml — read it, and
a new publish path must too. They used to answer the question separately (one derived, one
listed 13 directories) and drifted: @metaobjectsdev/docs-site is a runtime dependency of
@metaobjectsdev/cli and was missing from the workflow's list, so a release cut there would
have shipped a cli pinning a version nobody published. The derivation throws on an
untiered member, an inverted tier order, or a set not closed over its own sibling deps —
adding a package to the lockstep set is one explicit TIER_ORDER decision, because an
omission does NOT sort last (indexOf() === -1 sorts it first, ahead of its own
dependencies). Gated in the gates lane as publish-set parity, beside
check-publish-intent.sh — which enforces the same rule from the other side (a non-private
package off the lockstep line must be declared source-only).
See spec/roadmap.md for current and planned library work. (Consumer-adoption validation and the application-level prompt-pillar consolidation are pursued in adopter projects, not tracked in this repo.)
- [TECHNICAL] Field-type → Drizzle-column-type mapping table (needed for complete TS codegen coverage).
Closed, recorded so they are not re-proposed. jOOQ for OMDB — its OSS edition excludes
Oracle / SQL Server / DB2 (commercial licence required), which would paywall OMDB's
commercial-DB drivers in a public OSS project, and jOOQ generates code from a schema, the
inverse of metadata-is-the-spine. OMDB engine debt — FR-003 Plan 4 closed the three
anti-patterns; the Spring Boot 3 starter and OMDB autoconfiguration shipped. WARN
envelope-shape on expected-warnings.json — every runner asserts it; the legacy string-list
path is retired.
{ "metadata.root": { "package": "myapp::commerce", "children": [ { "object.entity": { "name": "Program", ... }}, { "object.entity": { "name": "Purchase", ... }} ] }}