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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions agent-context/skills/metaobjects-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ code behind a grep hit; a "duplicate" validator's *divergence* is the finding.
in committed canonical JSON (ADR-0032); DB-type-as-logical-subtype (ADR-0013); per-port
migration engine where schema is Node-`meta`-owned (ADR-0015).

- [ ] **H2. Wrong native type — `field.<x>` + `@dbColumnType` that hides the real type
(CORRECTNESS-ADJACENT finding, NOT advisory axis-I).** The headline instance is a **UUID
column modeled `field.string` + `@dbColumnType: uuid`**: the DB column is uuid but the
generated property is a **`String`**, so the code coerces `String↔UUID` at every boundary
and the native type is wrong everywhere the field is used. `verify --db` **cannot** catch it
(the column type matches), so it hides in plain sight. When it sits in a shared
`BaseEntity`/`BaseAuditedEntity`, **every inheriting `id`/`tenantId`/FK is wrong** — count the
blast radius (grep every `field.string` paired with `@dbColumnType: uuid`; it is often
hundreds of fields). This is a **real finding**, not a modernization nudge: recommend
`field.uuid` and flag it as a **staged migration** (re-typing `id`/FK ripples through
repositories, finders, and call sites) — tier by blast radius, not buried as advisory. The
ONLY non-finding is a field the code genuinely handles as a *string* over a uuid column
(explicitly justified). Report the total pair count so the migration has a completion
criterion (see the CI ratchet gate in `metaobjects-verify`).

- [ ] **I. Vocabulary hygiene / modernization (ADVISORY).** Flag already-retired or
deprecated authoring patterns and recommend the canonical form (see § Vocabulary
hygiene). Advisory severity — scored as modernization opportunities, **never a
Expand All @@ -133,9 +148,12 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is
the array column type is retired.
- The `@kind: text` hack (forcing text via a kind override) → **bare `field.string`**
(text is the default; no override needed).
- `@dbColumnType: uuid` where a native UUID type is actually wanted → **`field.uuid`**
(a distinct native type is a subtype, not a physical override). Keep `@dbColumnType:
uuid` ONLY for the deliberate string-over-uuid-column case.
- `@dbColumnType: uuid_array` was covered above. **`field.string` + `@dbColumnType: uuid`
is NOT advisory — it is a real mismodeling finding (see axis H).** It generates a `String`
where the code uses/wants a native `UUID`, forcing `String↔UUID` coercions at every
boundary; `verify --db` passes (the column really is uuid), so the schema gate can't see it.
The genuine string-over-uuid-column case (code truly handles the value as text) is the ONE
legitimate use and must be explicitly justified — otherwise recommend **`field.uuid`**.
- `@dbColumnType: timestamp_with_tz` (ADR-0036 Wave 2) → **drop it.** `field.timestamp` is
instant / timezone-aware **by default** now; the `timestamp_with_tz` column-type override
is **retired**. Timezone-awareness lives in `field.timestamp` + the `@localTime` opt-out.
Expand Down Expand Up @@ -329,6 +347,15 @@ The audit never edits code. Pattern: **dry-run → review the diff → apply**.

## Guardrails

- **Adoption direction — metadata follows the code.** This is a brownfield project: existing
code and the live schema are the spec. Every `metadata_sketch` must **reproduce the code's
existing native types, names, and nullability** (model `field.uuid` where the code uses
`UUID`, carry over `@column`/`@table`/`@required`) and every cutover must **minimize churn to
code the generator is not replacing** — customize the codegen to match the existing shape
before proposing edits to working call sites. A sketch that would re-type or rename working
code the generator isn't replacing is modeling the wrong thing; when a choice is ambiguous,
flag it for the human rather than proposing the churnier option. (Full doctrine:
`metaobjects-authoring` → "Adopting onto an existing codebase".)
- **Parity-gate every cutover** — prove behavior-equivalent before deleting hand-written code; generated schemas are often looser.
- **Verify, don't assume** — read the code behind a grep hit.
- **Verify the DB artifact, not just the types** — the contract may claim a column the view DDL dropped.
Expand Down
74 changes: 73 additions & 1 deletion agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,56 @@ aggregate — that is almost always **metadata you have not declared yet.** In o
Rule of thumb: **if the metadata could describe it, declaring it is never the wrong
call** — even when a one-off hand-write would be faster today.

## Adopting onto an existing codebase — metadata FOLLOWS the code

The principle above is the **greenfield** default: declare the model, generate the
code. **Adoption reverses the direction.** When you are introducing MetaObjects into
a project that already has **working code and/or a live database** — a migration, not
a fresh start — the existing code and schema are the specification, and the metadata's
first job is to **reproduce them**. You are documenting a reality that already runs, not
redefining it. (The metadata is still the durable spine *going forward*; only the
*direction of fit on the way in* changes. Once adopted, the greenfield rules resume.)

**The observable predicate:** does working code or a populated schema already exist for
what you're modeling? If yes, you are in adoption mode and these rules apply.

**Author metadata to match what the code ALREADY IS — not what you'd design fresh.**
Read the existing code and schema *first*, then model to reproduce them:
- The **native types the code uses** are the spec — model `field.uuid` when the code
uses `UUID`, `field.decimal` when it uses `BigDecimal`, etc. Do **not** pick a
metadata shape whose generated type differs from the type already in use (that is the
exact mistake that turned a `UUID` column into a `String` and forced coercions across
hundreds of fields — see the UUID rule below).
- The existing **column names, table names, nullability, and field shapes** are the
spec — carry them over (`@column`, `@table`, `@required`, `@maxLength`) so the
generated schema matches the live one and `verify --db` is clean.

**Customize the CODEGEN to match the existing code before you change the existing code.**
If generated output doesn't match the code's shape (naming, file layout, imports,
signatures), **tune the generator/template/config to reproduce it** — that is the
intended adoption path (owned generators, `outputPattern`, naming strategy — see the
`metaobjects-codegen` skill), **not a hack**. Reshaping working call sites to satisfy
the generator's defaults is the *last* resort, not the first.

**Minimize churn to code the generator is not replacing.** The ONLY existing code that
should change is the hand-written layer codegen now **owns** (the hand-rolled
CRUD/DTO/validator/mapper you're deleting) — parity-gate it, then delete it; that is the
point of adopting. Everything else — call sites, business logic, adjacent modules —
stays untouched. **If a metadata choice would force a wide edit across code the
generator isn't replacing, treat that as a signal the metadata is modeling the wrong
thing** and re-check it against the code, rather than editing the code to fit the
metadata.

**When a modeling choice is genuinely ambiguous, ask — don't pick the churnier option.**
If two metadata shapes both fit the existing code and they imply different amounts of
existing-code change, surface the tradeoff to the user rather than choosing silently.
**Default to the choice that changes the least existing code.**

Do NOT: change metadata, regenerate, and then work through the resulting compile/type
errors in the existing code as if they were bugs. On an adoption those "errors" are the
metadata failing to match the code — fix the *metadata* (or the codegen customization),
not the code.


## The fused-key encoding (non-negotiable)

Expand Down Expand Up @@ -224,7 +274,7 @@ Canonical form for common field needs — reach for these before inventing anyth

| Need | Author it as | Note |
|---|---|---|
| IDs / unique keys | `field.uuid` | native UUID; use `@dbColumnType: uuid` only to force a string-typed value over a uuid column on purpose |
| IDs / unique keys / **any UUID column** | `field.uuid` | native UUID type. **NEVER `field.string` + `@dbColumnType: uuid`** — see the smell callout below |
| Money | `field.currency` | integer minor units; never a float |
| Closed set of symbols | `field.enum` | `@values` required |
| Instant / event time (created/updated) | `field.timestamp` | instant / tz-aware by default (Postgres `timestamptz`; native `Instant`/`DateTimeOffset`/aware `datetime`) |
Expand All @@ -237,6 +287,28 @@ Canonical form for common field needs — reach for these before inventing anyth
| IP address | `field.inet` | native IP type; Postgres `inet` column |
| Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` |

**UUID columns are `field.uuid` — `field.string` + `@dbColumnType: uuid` is a forbidden smell.**
A UUID column is modeled with the **`field.uuid`** subtype (native `UUID` / `Guid` /
`uuid.UUID`, canonical lowercase-hex on the wire). Do **not** reach for `field.string` +
`@dbColumnType: uuid`: that pairing makes the *DB column* a uuid but generates a **`String`
property in code**, so every consumer must coerce `String ↔ UUID` at every boundary. It reads
"correct" because `verify --db` passes (the column really is uuid) — the defect is invisible to
the schema gate and only shows up as wrong native types rippling through the code. Left in a
`BaseEntity`, it is inherited by every `id`/`tenantId`/FK — hundreds of fields across a repo, a
staged multi-PR migration to undo. So:

```json
{ "field.uuid": { "name": "id" } } // ✅ native UUID
{ "field.string": { "name": "id", "@dbColumnType": "uuid" } } // ❌ generates String over a uuid column
```

The `field.string` + `@dbColumnType: uuid` form is legitimate **only** in the genuinely rare
case where your code truly wants a *string-typed* value stored in a uuid column (you handle the
uuid as text everywhere and never as a native UUID). That is an explicit, justified exception —
not a default, and never the way to model an identifier. When adopting an existing schema whose
code already uses `UUID`, `field.uuid` is the match-the-code choice (see "Adopting onto an
existing codebase" above).

**Timestamps — instant by default, `@localTime` for naive wall-clock (ADR-0036 Wave 2).**
`field.timestamp` is **instant / timezone-aware by default** (Postgres `timestamptz`;
native `Instant` / `DateTimeOffset` / aware `datetime`) — use it for created/updated/event
Expand Down
16 changes: 16 additions & 0 deletions agent-context/skills/metaobjects-codegen/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ the data access too.
`meta gen --list` prints every generator by stable name; the `generators` array in
`metaobjects.config.ts` is where you opt each one in or out.

### Adopting onto existing code — make codegen match the code, not the code match codegen

On a **brownfield adoption** (existing working code / live schema — see
`metaobjects-authoring` → "Adopting onto an existing codebase"), the goal of codegen is to
**reproduce the shape the code already has** so the generated output drops in with minimal
churn. When generated output doesn't match — different names, file layout, imports, or
signatures than the existing code — **customize the codegen to match the existing code first**,
using the à-la-carte layers, `outputPattern`/target layout, naming strategy, template
customization, and owned/custom generators described here. That is the intended adoption path,
**not a hack** — the whole point of owned generators + three-way merge is to shape output to
your codebase. Reshaping working call sites to fit the generator's defaults is the **last**
resort, and only for the layer codegen is actually replacing (the hand-rolled CRUD/DTO/mapper
you're deleting behind a parity gate). If matching the existing shape would require a genuinely
hacky generator contortion, that is the moment to **ask the human** which side should give —
don't silently churn the existing code.

## Write your own generators — the built-ins rarely fit an app exactly

The built-in generators (entity, queries, routes, form, grid, barrel) cover the
Expand Down
26 changes: 26 additions & 0 deletions agent-context/skills/metaobjects-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ and (for templates) the missing reference. **Bias toward trusting the tool** —
verify failure almost always means the metadata changed and a derived artifact
didn't follow.

## What `verify` can't catch — semantic mismodeling (add a CI ratchet lint)

The three subverbs check that derived artifacts *match the metadata*. They do **not**
check that the metadata *models the right thing* — so a semantically wrong metadata
choice that is internally consistent passes clean. The canonical case: a UUID column
modeled **`field.string` + `@dbColumnType: uuid`**. The generated property is a `String`,
the DB column is genuinely `uuid`, so **`verify --db` passes** while every consumer coerces
`String↔UUID` and the native type is wrong throughout the code (see `metaobjects-authoring`
→ the UUID smell). No drift subverb can see it, because nothing has drifted — the model
itself is wrong.

For semantic invariants like this, add a **project-local CI ratchet lint** over the
metadata sources — a grep-level gate is enough:

```
# fail the build if any field.string carries @dbColumnType: uuid (a UUID-column-as-string smell).
# Illustrative — tune the matcher to your source format (canonical JSON vs sigil-free YAML) and
# tighten to per-node scope if a coarse co-occurrence match is too broad for your files.
! grep -rEzl '"field\.string"[^}]*"@dbColumnType"[^}]*"uuid"' metaobjects/
```

Make it a **ratchet**: it can't go green until the last offending field is migrated to
`field.uuid`, so it doubles as the migration's completion criterion **and** a permanent
backstop against reintroducing the smell. The same pattern generalizes to any semantic
metadata rule your project wants enforced that `verify` structurally can't express.

## Schema migrations are the shared TypeScript engine — for every port

This is the load-bearing architectural fact (ADR-0015): **schema migrations are
Expand Down
1 change: 1 addition & 0 deletions agent-context/templates/always-on.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the dura
spine; generated code is the disposable artifact. Regenerate with `{{codegenCommand}}`.

## Principles
- **Adopting onto existing code? Metadata FOLLOWS the code.** On a migration (existing working code / live DB), author metadata + tune codegen to *reproduce* what the code already is — native types (`field.uuid` when the code uses `UUID`, not `field.string`), names, nullability — so regen changes as little existing code as possible. The only existing code that should change is the hand-written layer codegen replaces; ask when a modeling choice is ambiguous. (Greenfield: model-first, below.)
- Pattern-derivable from metadata = codegen, never hand-write — FKs, CRUD, validators, finders, and the database schema and migrations. The schema is a disposable, generated artifact: change the metadata and regenerate, never hand-write SQL.
- The **live database** is a derived artifact too — never hand-apply a schema change to a running DB (ad-hoc `psql`/console `ALTER`/`CREATE`/`DROP`), not even to preview a column or unblock a boot. Apply schema only through `meta migrate` (metadata → DDL). A hand-applied change drifts the live DB from the metadata + migration history and collides at the next migrate/boot ("column already exists") — a state no migration can reproduce. Run `meta verify --db` after any DB-touching work to catch that drift early.
- Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions).
Expand Down
Loading
Loading