Imported from metaobjectsdev/metaobjects (
agent-context/skills/metaobjects-audit/SKILL.md). Install upstream withnpx skills add metaobjectsdev/metaobjects --skill metaobjects-audit. Copyright stays with the author.
MetaObjects adoption audit
Thesis. Typed metadata is the durable spine; generated code is the disposable artifact. Hand-writing a layer the metadata could own creates a second source of truth for one fact β it will drift. This audit hunts those second sources of truth and proposes folding them into the spine.
Boundary β read-only. Deliverables: .metaobjects/adoption-audit.json (machine-readable
findings) + a rendered Markdown report. The audit never edits code, never authors
metadata β metadata_sketch per finding is a read-only proposal for human review.
Actual cutovers run through the existing skills mapped per finding tier (Β§ Bridge).
Phase 0 β Triage (fast, mechanical)
- MetaObjects present? (
metaobjects/dir, metadata sources,@metaobjectsdev/*/com.metaobjects:*/metaobjects/MetaObjects.*deps). - Count metadata source lines + all
@generated/DO NOT EDITfiles repo-wide. - Owned-generators check: does the project own generators at
codegen/generators/*(scaffold-and-own viameta init), or still import the deprecated package export (@metaobjectsdev/codegen-ts/generators)? Not owning is itself a finding. - Cross-language version consistency (silent-drift check). If the project uses MetaObjects in more than one language (e.g. a TS web client + a Java/Python/C# backend), enumerate EVERY MetaObjects package across ALL ecosystems (npm
@metaobjectsdev/*, Mavencom.metaobjects:*, PyPImetaobjects, NuGetMetaObjects.*) and record each version. The version-number LINES differ by ecosystem (npm/PyPI/NuGet0.x/1.xvs Maven7.x/8.x), so you CANNOT eyeball drift β a0.12next to a7.7looks fine but can be badly out of sync. Compare themetamodelVersioneach port reports (the shared spec version on the registry manifest): a mismatch is real cross-language drift and a finding β the ports disagree on vocabulary/wire behavior. Also flag any port not on the latest release for its ecosystem. (This is a known real-world failure mode: newest backend, stale client, invisible because the numbers differ.) - Classify: Greenfield (none/minimal) Β· Partial Β· Deep β choose path below.
Phase 1a β Greenfield path
- Shape inventory. Catalog modelable shapes: entities/tables, DTOs, validation schemas, routes, UI lists/forms, prompt sites.
- Pick wedge: one real entity (single-column PK, standard CRUD) to model first.
- From-zero roadmap:
meta initβ model the wedge βmeta genthe data layer β author a projection view β expand to routes/UI β add prompt pillar where LLM calls exist. Owning the generators from day 1 is part of the roadmap.
Phase 1b β Partial / Deep path
Census: generated output line/file counts + metadata/owned-generator lines.
Compute leverage ratio = generated_lines / (metadata_lines + generator_lines);
healthy = multi-Γ (example: ~4.7k spine β ~15.7k generated β 3.3Γ).
Coverage matrix: per entity/projection/value β query helpers? view? routes? UI? The gap between "modeled + query helpers" and "has view + route + UI" is the headline lopsidedness.
Surface review β 8 axes (independently runnable).
Work the full references/capability-checklist.md on every axis. Check calibration
guards (Β§ Calibration) before raising a finding. Verify, don't assume β read the
code behind a grep hit; a "duplicate" validator's divergence is the finding.
-
A. Codegen candidates β API / server routes. Catalog + classify every handler.
-
B. Codegen candidates β web / client. Pages, data layer (hooks, central fetch), grids/forms/filters vs
layout.dataGrid/ form generators / filter-allowlist. -
C. Drift hotspot β validators, mappers, runtime models. Hand validators / DTO-mappers / dataclasses shadowing a generated shape. Diff field-by-field; the divergence is the bug.
-
D. Prompt pillar. Every LLM prompt-construction site (see Β§ Prompt anti-patterns).
-
E. Owned generators & scaffold-and-own (see Β§ Owned-codegen assessment).
-
E2. Capability ledger β
requirement.*(seereferences/requirements.mdand the Requirement axis of the capability checklist). The one axis whose hunt is hand-written PROSE rather than hand-written code: aCAPABILITIES.md, a README features table, a conventions doc, a known-and-tolerated gap recorded as a TODO, or a rule that exists only as a comment above the field it governs. Prose goes stale in silence;@implementedByis a reference the loader resolves, so a rename that invalidates the claim is caught. Entirely opt-in β an absent ledger is NOT a defect (object coverage is a warning by design, and a real estate reports most of itself unclaimed on day one). Score the prose it would replace, and prefer ONE concrete claim an author can check over a whole-estate migration. First establish the estate LOADS. An older ledger fails the load outright, someta verifynever runs and everything inreferences/requirements.mdis unavailable:@violationand@verifiedBywere retired in 0.24.0 (no deprecation shim β ADR-0023 seals the registry); an index key declaring both@fieldsand@expris refused since 0.24.1; and 0.24.2 turned@status: abandoned | supersededintoretired. Runmeta upgrade(previews by default;--applywrites) before auditing, and audit the upgraded tree. It rewrites only what has one correct answer and refuses the rest, exiting non-zero β a refusal is itself a finding, naming the entries whose disposition nobody recorded. Then audit theretiredentries specifically. They are where the ledger earns its keep β agents proposing a retired capability's rebuild is the one failure this mechanism has controlled evidence against β and they are also where it decays quietly. Check that each@statementreads as a PROHIBITION rather than a diary entry ("X is never done", not "we used to do X"), and that@counterexampledescribes the REVIVAL. A retired entry whose statement narrates history is invisible to the reader it exists to stop. -
F. Drift-gate adoption. Is
meta verifywired into CI / pre-commit? Which subverbs (--codegen/--templates/--db)? Committed-codegen freshness gate? Advisories heeded? Routine--no-verifybypass? LoaderERR_*/ warnings addressed? Parse the stablecodefield, not message text (ADR-0009). Is any advisory half switched OFF? A gate can be wired, green, and muted. The requirements authoring lint is advisory and mutable β--no-requirement-lintorMETA_NO_REQUIREMENT_LINT=1silences it while the gate above it still runs and can still exit 1, so a green pipeline says nothing about the sevenWARN_REQUIREMENT_*authoring checks. Grep CI config and scripts for both spellings. Muted is not automatically a defect β a project that made a deliberate call is fine β but it moves those checks back onto the auditor (seereferences/requirements.md), so say which it is rather than reporting the ledger as lint-clean. Coverage completeness β "wired" is not "covers it all." A gate can be present yet blind to a whole artifact class. Confirm each subverb actually covers what the project HAS: (a) templates βverify --templateson a CLI before the #193 fix SKIPStemplate.output @kind=emailand does not body-check a documenttemplate.output's mustache, so a{{field}}that drifted from the payload sails through; a project with email/output templates that relies onverifyalone (nometa gen+git diff --exit-code, and no CLI past the fix) can ship broken β finding; (b) codegen β committed generated code needs--codegen(or gen+no-diff), else hand-edits/regeneration drift silently; (c) schema β--db(Nodemetaonly) for DB drift. Flag a gate that is WIRED BUT INCOMPLETE for the artifacts present, and recommend the specific missing coverage. -
F2. Over-generation β unnecessary generated files (codegen WASTE). Generated code is disposable, but that is NOT license to generate what nothing consumes. Every generated file is a merge/review/maintenance surface and inflates the leverage ratio falsely. Hunt OVER-generation (the inverse of A/B's under-generation):
- Dead generated files β an
@generatedfile that NOTHING imports or references repo-wide (grep the emitted symbol / module path for importers): delete it and stop generating it. - Artifacts an entity doesn't need β REST routes / TanStack grids / forms /
hooks emitted for an entity that has no such surface. The remedy is the
generator's own
filteroption (routesFile({ filter: (e) => e.name !== "Audit" }),tanstackQuery({ filter })), which is ANDed with the generator's built-in gates and so can only NARROW what emits; a grid additionally needs alayout.dataGridon the entity at all. Flag the unused artifact and name the filter that would suppress it. Never recommend an@emit*attribute β@emitRoutes,@emitTanstack,@emitForm,@emitGridand@emitAngularwere never registered vocabulary, so they passedmeta gen(open load) and FAILEDmeta verify(strict). A project carrying one is a finding, not an opt-out: report it and route tometa upgrade --applyplus the generator config above. - Generators wired but unconsumed β a generator in
metaobjects.config.tsgenerators: [...](or the per-port equivalent) whose whole output class no code imports: drop the generator rather than generate into the void. - Wrong target / duplicate output β the same logical artifact emitted to two
places (a mis-set per-target
outDir), one of which is orphaned. Recommend generating ONLY what is consumed β decide per generator: drop the ones whose whole output class nothing imports, and narrow the ones that over-emit with theirfilter. A smaller, fully-consumed generated surface beats a large one with dead files (which also make the leverage ratio lie; discount them from the census).
- Dead generated files β an
-
G. Runtime-contract anti-patterns. Module-global
dbvs context-as-parameter (ADR-0008); wire-canonicalization in the query path vs native in-process return types (ADR-0019); runtime reflection to resolve a type from FQN vs generated static imports / FQN registry (ADR-0001 / 0017); process-global registry vs per-loader (ADR-0014); code that mutates the loaded metadata tree (read-only after load); JVM/Kotlin missing startup validator; writes not routed to the@role: primarysource;own*()accessor reads of effective properties / own-only member iteration (ADR-0039 β see the active check below). -
G2.
own*()accessor discipline (ADR-0039 β CORRECTNESS DEFECT, not advisory). In any custom generator, metamodel provider, or runtime path (NOT the sanctioned cases below), flag every read of a field/node's effective property, or own-only member iteration, done through an own-only accessor β it silently drops everything inherited viaextends(a super-reference, not a flatten), corrupting codegen and runtime. This is exactly the class of bug that broke Kotlin's array-type derivation (a concrete field inheriting an array flag from an abstract parent generated a scalar) and, per the audit, is latent cross-port. Grep for the own-only accessors and verify each hit:- TS:
ownAttr(,ownChildren(,ownFields(, a rawisArrayfield flag read β should beattr(/children()/fields()unless emitting a subclass's own members. - Python:
own_children(,own_fields(, and the inverted bareattr((Pythonattr()is OWN; the resolving form isattrs().get() β flagattr(used to read an effective value. - Java / Kotlin:
getMetaAttr(name, false)(the,falseown overload), own-only child walks (e.g. an own-onlyfilterIsInstance<β¦>()source lookup that emits nothing for an entity inheriting its source). - C#: a native
IsArrayflag read,OwnChildren(), own attr reads.
Sanctioned (do NOT flag): (a) a generator emitting a generated subclass that iterates
ownFields()so inherited members aren't re-emitted (the generated base declares them β theclass Sub extends Base/ TPH pattern); (b) the own-mode canonical serializer + overlay-merge + super-resolution walks (library-internal); (c) the single deliberately-own attribute@dbColumnType(a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. - TS:
-
H. Authoring-correctness / ADR-conformance (deep). Invented/unregistered
@-attrs or post-bootstrap registration (ADR-0023 β custom attrs belong in a registered provider orattr.properties); retired source-v2 forms (source.dbTable/@name/@dbColumnβ usesource.rdb+@kind+@table/@column+@role, ADR-0007/0018); taxonomy impurity (entity over read-only primary source; read model that should beobject.projection;valuecarrying identity/source, ADR-0028); copy-pasted base-field blocks β or the same-named field re-declared across objects (drift signature 10) β instead of abstract +extends;@-prefixed YAML keys (ADR-0006); relative refs 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>+@dbColumnTypethat hides the real type (CORRECTNESS-ADJACENT finding, NOT advisory axis-I). The headline instance is a UUID column modeledfield.string+@dbColumnType: uuid: the DB column is uuid but the generated property is aString, so the code coercesStringβUUIDat every boundary and the native type is wrong everywhere the field is used.verify --dbcannot catch it (the column type matches), so it hides in plain sight. When it sits in a sharedBaseEntity/BaseAuditedEntity, every inheritingid/tenantId/FK is wrong β count the blast radius (grep everyfield.stringpaired with@dbColumnType: uuid; it is often hundreds of fields). This is a real finding, not a modernization nudge: recommendfield.uuidand flag it as a staged migration (re-typingid/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 inmetaobjects-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 failing finding.
Phase 4 β Synthesize into the tiered roadmap and populate both artifacts.
Vocabulary hygiene / modernization (axis I β ADVISORY)
Per ADR-0037, vocabulary expansion follows ONE ordered test (derivable β derive;
physical-only β @dbColumnType; logical: different native type β subtype, same kind +
modifier β attribute). This axis surfaces authoring that predates or contradicts that
framework. All findings here are advisory β modernization opportunities scored as
such, surfaced in the roadmap, but non-failing (the code works; the form is dated).
Already-retired / deprecated forms β recommend the canonical form:
@dbColumnType: uuid_array/@dbColumnType: text_array(a physical array column type) βisArray: trueon the base subtype. Array-ness is logical and derivable; the array column type is retired.- The
@kind: texthack (forcing text via a kind override) β barefield.string(text is the default; no override needed). @dbColumnType: uuid_arraywas covered above.field.string+@dbColumnType: uuidis NOT advisory β it is a real mismodeling finding (see axis H). It generates aStringwhere the code uses/wants a nativeUUID, forcingStringβUUIDcoercions at every boundary;verify --dbpasses (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 recommendfield.uuid.@dbColumnType: timestamp_with_tz(ADR-0036 Wave 2) β drop it.field.timestampis instant / timezone-aware by default now; thetimestamp_with_tzcolumn-type override is retired. Timezone-awareness lives infield.timestamp+ the@localTimeopt-out.- A bare
field.timestampthat is semantically a wall-clock value (a store-open time, a birthday-with-time, a recurring local schedule) β recommend@localTime: true(naivetimestamp without time zone), or confirm it is genuinely meant to be an instant. Defaultfield.timestampis an instant; only flag when the field's meaning is clearly wall-clock. - A
validator.regex(or a plainfield.string) validating an email shape β recommend@stringFormat: emailon thefield.string(ADR-0036 Wave 3). The native type staysstring; the per-port codegen emits the idiomatic email check β don't hand-roll the regex. - A
field.stringthat holds a URL / URI β recommendfield.uri(nativeURI/Uri, URL validation) β a distinct native type + behavior is a subtype, not a validated string. - A
field.stringthat holds an IP address β recommendfield.inet(native IP type; Postgresinetcolumn).
Custom-provider vocabulary (adopters who register their own types/attrs): check
new/custom vocab against the ADR-0037 procedure (advisory) β e.g. a custom subtype
that differs from an existing one only by a property should be an attribute, not a
subtype; an email/hostname string-validation is an attribute (@stringFormat,
native type unchanged), while a URL or IP is a native type (field.uri /
field.inet, a subtype). Recommend re-shaping against the ordered test.
Hand-rolled reverse-query repository methods (ADR-0038) β recommend the generated reverse
FK finder. Reverse navigation β "find all the rows that reference this one" β is now
codegen. Flag a hand-written reverse-query method (a findByParentId / findBy<Parent>
repository finder, or a manual WHERE fk = ? query helper β exactly what a JVM adopter
hand-writes in its SceneRepository) and recommend the generated reverse FK finder
instead: codegen now emits find<Source>By<FkField> plus a batched β¦In(ids) variant from
the FK metadata, idiomatic per port (Spring repository finder / EF query method / Python or
TS query function). It is performant (one indexed query, no N+1) and framework-free
(no lazy collections / proxies). When an entity has two FKs to the same target, the codegen
emits two distinct finders (named by the FK field) automatically β no annotation needed; the
reverse finder is a codegen feature, not an attribute (there is no reverse-nav @-attr
to author). Advisory severity β a modernization opportunity, not a failing finding.
New-vocabulary OPPORTUNITY (the inverse hunt β advisory). The checks above audit custom vocabulary the adopter already registered; this one hunts where the app should register vocabulary but hand-coded the pattern instead. Smells (grep-then-verify; require a recurring, closed set β never flag a one-off):
- N parallel hand-written integration modules sharing a payload shape and a config
pattern (a closed set of channels / providers / export targets, each a near-copy with
a different transport) β candidate for ONE project-registered subtype whose variants
sit behind a closed structural-variant discriminator (the
source.rdb@kindpattern), plus a small owned generator emitting the per-variant wiring. - An ad-hoc string discriminator steering code switches β a string column whose
values select per-variant behavior/config scattered across the codebase. If the values
are just a closed symbol set, that is
field.enum(@values), NOT new vocabulary. It earns a subtype only when the discriminated concept owns behavior or attributes of its own (ADR-0037 step 2a). - A provider-shaped pattern re-implemented per instance β repeated registration / config blocks restating a shape one declared node per instance could carry, with codegen emitting the repetition.
- A downstream integration modeled entirely in code (an outbound webhook / notifier /
queue publisher / tool wiring beyond
template.toolcall) where registered vocabulary + a small generator would own the payload wiring, the names-only fail-closed config check, and drift detection.
Run the ADR-0037 ordered test before proposing: derivable β derive; differs from an
existing subtype only by a property β an attribute; a plain validated value β an
attribute; one-off author-supplied properties β the attr.properties bag. Only a concept
with its own behavior/attributes earns a subtype, and transport/protocol is never the
subtype axis (keep the node protocol- and address-free; variants behind the discriminator).
Converge before inventing β check shipped vocabulary AND the planned/chartered names
(see CALIBRATION "planned, not shipped"); never claim a chartered name for a project-local
type. Recommendation: propose a project-registered provider (explicit loader wiring,
ADR-0023 β never a loosened strict free-ride), a metadata_sketch of the node, and the
owned generator that retires the duplication; a second independent consumer needing the
same concept is a consumerβcore promotion candidate (ADR-0011 β file an upstream issue).
Verdict: VOCAB CANDIDATE (advisory) β a modeling opportunity, never a failing finding,
never a tier gate; bias to under-flagging (the >15% false-positive kill criterion applies
with full force). Full guidance: docs/features/downstream-metadata-decisions.md.
Classification scheme (every surface; classify on codegen AND runtime)
| Class | Meaning | Action |
|---|---|---|
| GENERATED | Driven by metadata (regenerable). | Confirm it regenerates clean. |
| OWNED-GENERATOR | codegen/generators/* file the project owns. |
Confirm clean regen; flag drift from reference template. |
| CODEGEN CANDIDATE (high) | Standard CRUD/list/form over a modeled or modelable entity. | Author the view + generate; parity-gate. |
| CODEGEN CANDIDATE (partial) | Generatable data layer, bespoke presentation. | Generate data layer; keep viz hand-written. |
| DYNAMIC-RUNTIME CANDIDATE | Behavior that could be metadata-driven at runtime. | Assess runtime-metadata feasibility. |
| BESPOKE (keep) | Genuine custom: irreducible SQL (recursive CTEs, window functions, set ops β NOT plain count/sum/avg rollups, which are origin.aggregate on a projection), graph, SSE, auth, search, viz. |
Leave hand-written β still import generated types. |
| VOCAB CANDIDATE (advisory) | A recurring, closed hand-coded pattern (parallel integration modules / an ad-hoc string discriminator) that project-registered vocabulary β a custom subtype/attr via a provider β plus a small owned generator would own. | Propose the provider + metadata_sketch; apply the ADR-0037 ordered test; advisory only. |
Gold-standard exception. A hand-written component that derives from generated metadata cannot drift β flag as good. A "bespoke" component hardcoding a shape metadata knows is a hidden candidate. Stub trap: demo-data routes have nothing to replace β classify "candidate (future) β not DB-backed".
Drift signatures (highest-value; grep-then-verify)
Per finding: file:line β what β generated-equivalent exists? β recommendation.
- Hand validators shadowing a generated schema β diff field-by-field; divergence is the bug.
- Field-by-field serialize / deserialize / DTOβmodel / row mappers β silently drops a field when metadata grows one.
- camelCaseβsnake_case / bodyβcolumn maps maintained beside a generated view that already renames.
- Drift-admitting comments β grep:
"keep in sync with"/"mirrors the"/"matching the". - Runtime schema patching (
ALTER TABLE β¦ ADD COLUMN IF NOT EXISTS,_ensure_schema()) β N schema owners. - N declarations of one shape β same entity as Drizzle table + Zod schema + Pydantic model + hand dataclass; target is 1 + N generated.
own*()accessor read of an effective property (ADR-0039) βownAttr/ownFields/own_children/ bare Pythonattr(/getMetaAttr(name, false)/ nativeIsArrayused to read a value or iterate members outside the sanctioned subclass-emit / own-serializer /@dbColumnTypecases β silently dropsextends-inherited values. A correctness defect (axis G2), not advisory.- Hand-written
CREATE VIEW/ read-only SQL standing in for a projection or entity read-view (view-necessity test). Grep migrations, checked-in.sql, and repository/query code forCREATE [OR REPLACE] [MATERIALIZED] VIEW, and for hand-rolled read-only queries that mirror a read model β a pure-SELECTrepository/service method with joins orGROUP BYfeeding a DTO, or a raw-SQL escape (db.execute(sqlβ¦),FromSqlRaw, a JPA @Query with hand-written SQL). For each, run the necessity test β can origins express this shape? A column is derivable when it is (a) a base-entity or relationship-joined column βorigin.passthrough(@from/@via), (b) a count/sum/avg/min/max over related rows βorigin.aggregate(@agg/@of/@via), or anEXISTS/array_aggβorigin.aggregateany/all/collectβ any of them optionally row-scoped with@filter, (d) a computed scalar / non-aggregate expression column βorigin.computed(@expr, #195), (e) one related row's column picked by an ordering β an argmax /DISTINCT ON β¦ ORDER BY/ correlatedORDER BY β¦ LIMIT 1βorigin.first(@of/@via/@orderBy, #195), or (f) a column borrowed viaextendsβ and the joins follow declared relationships /identity.referenceFKs.- Entity-shaped (
SELECT own.* + derived) β an entity read-view, NOT a projection (#214). The single most common legacy view is an entity's OWN columns plus a joined/derived extra (SELECT o.*, c.name AS customer_name). This is theOrderentity with a read route, not an exposure contract β route it to an entity read-view: keep the writable@role: primary@kind: tablesource and add a non-primary@role: replica@kind: viewsource, declaring only the extra as a derivedorigin.*field on the entity (the own field set already coverso.*). Reads route to the replica view, writes to the table. Reach for a projection instead only when the view renames base columns or row-filters (WHERE status='active', soft-delete) β the latter is a projection with an object-level@filter(#207) that lowers to the outerWHERE. - Exposure contract, expressible β CODEGEN CANDIDATE (high): a subset / renamed / versioned / multi-base read model β convert to an
object.projectionwith a read-onlysource.rdb@kind: viewchild, letmeta migrateemit theCREATE VIEW, and consume the generated read-only query β the hand-written view is a second source of truth for a derivable shape. Parity-gate: the generated view returns row-identical results before the hand-written SQL is deleted. - Not expressible β carry it in
@sqlor@unmanaged, never a hand-edited migration (#208, ADR-0043). When a NAMED irreducible construct blocks origin authoring β recursive CTE, window function /OVER,UNION/INTERSECT/EXCEPT, lateral join β the body still belongs in the metadata: carry the hand-written SQL in thesource.rdb@sqlescape β a read-only-@kindbody the tool REGISTERS, fingerprints, and drift-checks (adopt a pre-existing view withmeta migrate --allow adopt-view);@sqlforbidsorigin.*children (two sources of truth). A DB object whose DDL is owned entirely elsewhere (Flyway / a hand-migration) β mark its source@unmanaged: true(legal on any@kindincl.table);meta migratenever creates/drops/drift-checks it andverify --dbreports it as external.@sqland@unmanagedare mutually exclusive. Only a view left undeclared β neither modeled, nor@sql, nor@unmanagedβ is truly unmanaged, invisible tometa verify --db, so this audit is the only gate that sees it. "It's an aggregation" is NOT an irreducibility justification (plain count/sum/avg/min/max rollups areorigin.aggregate); nor is aDISTINCT ONpick-one-row (origin.first) or a non-aggregate expression column (origin.computed).
- Entity-shaped (
- A closed variant-set hand-modeled per instance β N sibling modules / classes / config blocks, one per channel / provider / target, sharing a payload + config shape and diverging only by transport. Grep for sibling-file families and switch-on-a-string dispatch; verify the set is closed and recurring (never a one-off). β axis I "New-vocabulary OPPORTUNITY" (VOCAB CANDIDATE, advisory).
- N declarations of one FIELD across objects (same-name-field census) β the field-level sibling of signature 6. Census field names across
object.*nodes (grep -rn 'name: <field>'the metadata dir); a name recurring in β₯2 objects where a canonical owner exists β one whose name the field embeds (<owner><Field>:wizardIdβWizard.id,orderTotalβOrder.total) or whose type+constraints it matches β is provenance loss βextends: Owner.field(dotted child targets, ADR-0029). VERIFY by diffing the copies' attrs: a@maxLength/@required/ validator divergence across them is drift already shipping β cite it. Evidence multiplier:extendsalready used elsewhere in the repo raises confidence. Do NOT flag: generic names on unrelated concepts (id/name/statuswith no owner-embedding name and no matching constraints); required per-node attrs a loader forces (e.g.payloadRef/formaton siblingtemplate.promptnodes β a product constraint, not a copy). - A physical table / column / schema name spelled as a literal outside its metadata declaration β in a hand-written repository, raw SQL, a migration script, a log line, or a hand-maintained body-to-column map. Every port emits a per-object names artifact (
<Entity>Names/<entity_snake>_names.py) from the declaration, so a literal is a second spelling of the same fact; and because the physical column is free-form, deriving it from the field name is a guess that fails silently. No verify subverb sees this β--codegendiffs generated files,--dbcompares schema to metadata β so this audit is the only gate. Remedy: reference the constant; on the JVM the names generator is opt-in, so an un-wired pom is the first finding and wiringSpringNamesGenerator/KotlinNamesGeneratorthe first remedy. Do NOT flag: a typed ORM handle in its place (a Drizzle column object, an ExposedColumn, an EF property β replacing one with a string is a regression); a physical name inside asource.rdbhand-written SQL body (a metadata document cannot import a constant, which is why that body is fingerprinted); a flattened value-object composite column (it belongs to no single field of either object); a write-through entity's replica view name (the artifact holds the PRIMARY source's); a relationship-synthesized foreign-key column (derived, never declared). A hand-written repository that imports<Entity>Namesis the gold standard β call it out as good.
Owned-codegen & scaffold-and-own assessment
- If config imports deprecated
@metaobjectsdev/codegen-ts/generatorsinstead of ownedcodegen/generators/*, recommend the scaffold-and-own migration (meta init). - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates β
intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk
that a declarative
scope+outputPatterncould replace? (d) bespoke shape better as atemplateGeneratorthan a forked generator? - Authoring ladder: built-in fits β use it Β· close β own + customize (the default) Β· new shape β author a declarative template-spec / custom generator from the metadata Β· genuinely un-modelable β hand-write (still import the generated types).
- Generator-gap check: missing generators that block the biggest wins? Recommend per gap: own + customize / author a template-spec / fix upstream / stopgap.
- Verify the DB artifact, not just the types β computed view columns may appear in the contract but be dropped from the view DDL; the contract may lie.
- An undeclared hand-authored DB view is invisible to
meta verify --db. A view that is neither modeled nor carried in thesource.rdb@sql/@unmanagedescapes is unmanaged (informational only β never actionable drift, never auto-dropped), so a hand-written view standing in for an expressibleobject.projection/ entity read-view can never be outsourced to the drift gate; hunt it here (drift signature 8, below). Once carried in@sql(#208) it IS registered, fingerprinted, and drift-checked β no longer audit-only. - Version skew: check actually-resolved package versions, not declared; consuming a fix requires a coordinated lockstep bump, not a source-file copy.
Prompt anti-patterns (hunt per site; classify: fully-modeled / partial / fully-inline)
- Inline prompt strings (triple-quoted / template-literal constants in service code).
- Untyped payloads (
str.format(**dict)/ f-strings / ad-hoc dicts) β payload should be a declared shape: anobject.value(caller-supplied fields;origin.passthroughonly β FR-015 parameter lineage) or, when fields derive byaggregate/computed/first, a sourcelessobject.projectioncarrying those origins (#210 β assembly origins on anobject.valuefail load withERR_SUBTYPE_RULE_VIOLATION;@payloadRefaccepts the sourceless projection). - Silent-degradation hack (
try/except KeyErroror?? ''around formatting) β flag every instance. - Hand-rolled output parsing (regex / XML / ad-hoc JSON) vs a declared responding
template.prompt(one carrying@responseRef) + generatedparse*/safeParse*/extract*parser β generated in all five ports (Java's generated<Name>Parserowns the JacksonreadValue); flag a hand-rolled parser in a non-generated file where a respondingtemplate.promptexists. ADR-0052: atemplate.outputis outbound only and generates no parser, so it is not the node to look for here. - Engine-side formatting breaking byte-identical render (prompt-cache exact-prefix hits depend on byte-stability).
template.toolcallcandidates: LLM tool schemas hand-defined per call vs modeledtoolcall @toolName/@payloadRef.@responseRef+ AI-trace: hand-parsed responses with no typed response shape; note thatvoRequest/voResponsejsonb columns must be authoredfield.objectβ the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024).- No
meta verify --templatesgate; no declared@maxChars/@maxTokensbudget. - Template-text duplication. Hash every external template file (
md5sumthe template dir) and diff within groups sharing apayloadRef. A byte-identical cluster, or Nβ₯3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times β a wording fix must be applied N times and WILL be missed. Recommend a shared{{> group/partial }}include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-nodepayloadRef/@formatrepetition acrosstemplate.promptnodes (loader-forced, not a copy); templates meant to diverge independently β require Nβ₯3 and structural identity before flagging.
Semantic-constraint ratification (prevents over-modeling)
When folding hand validators into metadata, apply human judgment per constraint. A constraint enters shared metadata only if it is a true cross-language domain invariant; a one-consumer preference stays in a thin local refinement layer.
Cross-field rules are modelable (comparison / atLeastOne / requiredWhen /
presentIff); ratification decides which belong in shared metadata. Output a
ratification table: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale β
human-approved, never applied silently. Distinguish required from has-a-safe-default
(@default often fixes the over-requiring bug). A core attr ripples cross-port; for a
one-consumer need, read it codegen-locally.
Scoring & maturity model β three surfaces (no single global score; bands not decimals)
- Headline MATURITY TIER β Greenfield β Partial β Deep β Exemplary; worst-of with
prerequisite gating (a missing pillar can't be averaged away); rendered with the single
next unmet check ("you're Partial; the next rung needs
verifyin CI"). - Per-pillar breakdown (never rolled into one number) β
pillar | tier | top gapover codegen / runtime / drift-gate / prompts. This is the core deliverable. - Binary CI drift gate β prominent and separate: "Is
meta verifydrift detection wired into CI?" It is binary because the risk is binary.
Coarse bands only (none / some / most / all). Worst-of within a pillar. On re-run, grade the delta. Lead with gaps, not the grade.
Vocabulary hygiene (axis I) is advisory β it surfaces as modernization opportunities in the roadmap, scored as such, and never gates a tier or fails the audit. Dated-but-working vocabulary is a quality nudge, not a defect.
Report
Two artifacts: .metaobjects/adoption-audit.json + rendered Markdown.
Markdown sections (lead with Scorecard): 0. Scorecard (tier + pillar table + CI gate) Β·
- Triage + census Β· 2. Coverage matrix Β· 3. Per-surface classification tables Β· 4. Drift findings (active bugs first) Β· 5. Owned-codegen + generator gaps Β· 6. Drift-gate adoption Β·
- Runtime-contract + authoring-correctness (axes G+H) Β· 8. Semantic-constraint ratification Β·
- Prompt-pillar assessment Β· 10. Prioritized roadmap: Tier 1 drift kill β Tier 2 existing generators β Tier 3 new generators/projections β Tier 4 dynamic-runtime/prompts/cross-port. Each roadmap item: LOC retired, prerequisite, parity-gate before deleting hand-written code.
Each finding in .metaobjects/adoption-audit.json:
| Field | Content |
|---|---|
id |
stable kebab id (e.g. handwritten-crud-route, manual-zod-validator) |
title |
"you hand-wrote X that metadata can generate / model" |
pillar |
codegen / runtime / drift / prompt |
surface |
entity / route / validator / repository / dto / hooks / prompt / migration |
capability |
the capability-checklist capability this maps to (e.g. field.currency, relationship.@through) |
locations[] |
exact file:line spans |
impact |
LOC eliminated + N call-sites + drift-risk (high/med/low) |
effort |
trivial / small / medium / large |
confidence |
bias to under-flagging (false-positive rate >15% is a kill criterion) |
metadata_sketch |
metadata you'd author to replace it β read-only proposal only; never applied |
next_command |
the exact command / skill that performs the cutover (see bridge below) |
parity_gate |
the specific check proving behavior-equivalence |
tier |
1β4 |
Within each tier, sort by impact Γ· effort (quick wins first). Tier 1 leads.
Audit β action bridge
The audit never edits code. Pattern: dry-run β review the diff β apply.
- Propose metadata β
metaobjects-authoring+ brainstorming flow (human reviews). - Generate β
meta gen;meta gen --dry-runis the review-the-diff step β skill:metaobjects-codegen. - Prove parity β
meta verify --codegenis the drift gate β skill:metaobjects-verify. - Routes / runtime / web β skill:
metaobjects-runtime-ui. - Prompts β skill:
metaobjects-prompts. - Cut over one surface at a time, one commit each.
- A separate guided-cutover skill (not this one) reads
adoption-audit.jsonand walks findings one tier/surface at a time with human approval at each step.
Guardrails
- Adoption direction β metadata follows the code. This is a brownfield project: existing
code and the live schema are the spec. Every
metadata_sketchmust reproduce the code's existing native types, names, and nullability (modelfield.uuidwhere the code usesUUID, 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.
- Don't let one bespoke action block generating the entity β generate CRUD; mount the custom action alongside.
- Consumption β a dist copy across versions β bump + rebuild lockstep and install.
Calibration β port gaps & non-defects (do NOT flag these as adopter fault)
- Filter-operator route codegen β the CORE grammar ships in all five ports. The
?filter[field][op]=valuegrammar (all 9 operatorseq/ne/gt/gte/lt/lte/in/like/isNull, implicit-AND across params, the generated<Entity>FilterAllowlist+invalid-field/invalid-op/in-over-cap 400s) is generated in every port (JavaSpringControllerGenerator, C#RoutesGenerator, Pythonrouter_generator, KotlinKotlinSpringControllerGenerator, TS) β gated by the api-contract corpus in BOTH lanes. Flag hand-rolled filter parsing anywhere. Only the richer surface is genuinely TS-only: free-text?search=, the explicitfilter[or][N]/filter[and][N]nested boolean combinators (+ their nesting-depth cap), and leading-wildcard gating β do NOT flag the absence of those in a non-TS port. - Output-parser codegen ships in all five ports β Java's
SpringOutputParserGeneratorgenerates the<Name>Parser(the JacksonreadValuelives inside that generated file). A hand-rolled parser in a non-generated file where a respondingtemplate.prompt(@responseRef) exists IS a finding. Per ADR-0052 atemplate.outputemits no parser. - Python still hand-wires the FastAPI router + repository impl around a generated
APIRouter; relationship / non-tablesource-kind /field.object flattenedcodegen is partial. - C# has no ObjectManager runtime tier (EF Core is the runtime) β hand services over the generated
DbContextare expected. - Cut subtypes β
field.byte/field.short/field.classare removed; never recommend them. - TS/web-only β
view.*widget subtypes exist only for TS/web consumers; onlyview.base/view.currencyare cross-port-gated. - Planned, not shipped β
api.*/operation.*/binding.*(FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - Cross-port version-NUMBER skew is by design β TS/C#/Python
0.xvs Java/Kotlin7.xMaven is correct; never flag the number lines differing. But that is exactly why you can't eyeball cross-language drift: comparemetamodelVersion(Phase 0 cross-language consistency item), not the package numbers. AmetamodelVersionMISMATCH across ports is a finding; so is a port lagging its ecosystem's latest release. Also flag intra-port drift (mixed versions within one port, or a runtime package indevDependencies). - Stale upstream prose β "hand-write the Spring controller" (Java/Kotlin) is out of date; trust
meta gen --list, not stale prose.
For this project's port specifics and the exhaustive capability checklist, read every references/*.md in this skill's directory.