Skip to content

fix(cli): scaffold outDir defaults to src/generated, not ./src/db - #1

Closed
dmealing wants to merge 1 commit into
mainfrom
fix/scaffold-outdir-default
Closed

fix(cli): scaffold outDir defaults to src/generated, not ./src/db#1
dmealing wants to merge 1 commit into
mainfrom
fix/scaffold-outdir-default

Conversation

@dmealing

@dmealing dmealing commented Jun 6, 2026

Copy link
Copy Markdown
Member

What

meta init scaffolds metaobjects.config.ts with outDir: "src/generated" instead of "./src/db".

Why

The old default put generated files in src/db/ right next to the src/db.ts the user must hand-create for their DB instance — same path stem, two different things. Worse, the generated routes' import { db } from "../db" (from dbImport: "../db") can resolve to the generated barrel src/db/index.ts instead of the user's src/db.ts. docs/ports/typescript.md already uses src/generated; this aligns the scaffold with the docs so generated code and the user's db module stay clearly separated.

Found while building the five-minute quickstart demo end-to-end against published 0.9.0 (the demo otherwise works great — meta init → gen → Fastify → curl returns 201/200 JSON + 400 Zod validation).

Changes

  • cli/src/commands/init.ts — scaffold template outDirsrc/generated.
  • cli/test/init.test.ts — new test asserting the scaffolded outDir (and that ./src/db is not used).

Test

bun test packages/cli → 276 pass, 0 fail.

🤖 Generated with Claude Code

`meta init` scaffolded `outDir: "./src/db"` alongside `dbImport: "../db"`,
which puts generated files in src/db/ right next to the src/db.ts the user
must create for their DB instance — same path stem, two different things —
and the generated routes' `import { db } from "../db"` can resolve to the
generated barrel (src/db/index.ts) instead of the user's src/db.ts. Default
to `src/generated` (matches docs/ports/typescript.md) so generated code and
the user's db module stay clearly separate. Found while building the
five-minute quickstart demo against published 0.9.0.

Adds an init test asserting the scaffolded outDir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dmealing dmealing closed this Jun 9, 2026
@dmealing
dmealing deleted the fix/scaffold-outdir-default branch June 9, 2026 22:43
dmealing added a commit that referenced this pull request Jun 25, 2026
…om the migration binding (#1)

The meta init scaffold conflated a generic invariant with a project-specific
binding in the always-injected AGENTS.md/CLAUDE.md, and guessed the binding:

1. The "never hand-write" principle listed only code artifacts (FKs, CRUD,
   validators, finders) and never named the DB schema/migrations as
   metadata-derived — so an agent would hand-write a raw ALTER TABLE against a
   generated schema, silently reintroducing the exact drift meta verify exists
   to catch.
2. The stack line asserted "migrations are TS" — a guessed binding that's wrong
   for adopters who consume meta migrate output as Flyway/Liquibase/etc.

Fix (tool states invariants; project states bindings; tool never guesses):
- Extend the principle to name the database schema and migrations as
  metadata-derived, tool-agnostically: "The schema is a disposable, generated
  artifact: change the metadata and regenerate, never hand-write SQL."
- Drop the "; migrations are TS" clause from the assembled stack line.

Deliberately NOT adding a persistence/migrations config field (issue's optional
"last point"): the generic invariant stops the agent hand-writing migrations,
and a project that needs a specific line states it in a hand-written region of
AGENTS.md (three-way merge already preserves those) — no new config surface.
The always-installed metaobjects-verify/migration reference fragment already
carries the project-agnostic how.

Regenerated the 4 cross-stack conformance fixtures; agent-context conformance +
root/sdk byte-identity gates green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
dmealing added a commit that referenced this pull request Jun 25, 2026
…idator removal (#73)

* fix(agent-context): hedge the JVM startup-validator claim in verify skill (invariant vs binding)

Audit of the agent-context corpus (5 skill bodies + 16 reference fragments +
README) for the issue #1 defect class — conflating a generic invariant with a
project-specific binding, or guessing a binding — found it broadly clean:
per-stack reference fragments correctly carry their own stack's binding without
guessing a second one, LLM callers are explicitly bring-your-own, and the
"schema/migrations are metadata-derived" framing is intact across all ports.

One consistency fix: verify/SKILL.md asserted that on the JVM ports "a runtime
startup validator catches generated-table drift at app boot" as a flat fact —
but whether an adopter wires that validator is an opt-in project binding.
migration.md already hedges the same capability ("can catch"); this aligns
verify/SKILL.md to it: "can catch ... an optional complementary check (if your
project wires one)". Regenerated the 4 cross-stack conformance fixtures (the
skill body is installed for every stack). Conformance + byte-identity gates green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky

* no-mistakes(document): sync Java migration/drift docs to ADR-0015 validator removal

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
dmealing added a commit that referenced this pull request Jun 25, 2026
Lockstep npm release of the @metaobjectsdev/* packages (13 publish candidates).
Ships the agent-friendly meta CLI (--format/TOON output, deploy-all agent-context
references, #71) plus the agent-context scaffold fixes (#1, #73): the injected
AGENTS.md/CLAUDE.md now name the database schema + migrations as metadata-derived
and no longer guess the migration binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
dmealing added a commit that referenced this pull request Jun 30, 2026
…ime (all 5 ports) (#126)

* feat: ADR-0036 Wave 2 (TS) — field.timestamp instant-by-default + @localTime

Flip field.timestamp to instant / timezone-aware BY DEFAULT (Postgres
timestamptz) and add a boolean @localTime attr on field.timestamp as the rare
naive / wall-clock opt-out (timestamp without time zone). Retire the
@dbColumnType: timestamp_with_tz escape hatch entirely — the @dbColumnType legal
set shrinks to { uuid, jsonb }. ADR-0036 Wave 2, TS reference port. This
establishes the canonical golden + canonical Postgres schema + shared fixtures
the other four ports byte-match.

Metamodel (metadata):
- db-constants.ts: add FIELD_ATTR_LOCAL_TIME; drop timestamp_with_tz from
  DB_COLUMN_TYPE_VALUES + DB_COLUMN_TYPE_LEGAL_SUBTYPES (now [uuid, jsonb]).
- spec/metamodel/db.json: new boolean @localTime on field.timestamp; updated
  @dbColumnType description/allowedValues. Regenerated db-definition.embedded.ts.

Schema / codegen:
- migrate-ts expected-schema.ts: field.timestamp → timestamptz by default,
  @localTime:true → TIMESTAMP; removed the timestamp_with_tz override branch.
- codegen-ts column-mapper.ts: field.timestamp → timestamp({ withTimezone: true })
  by default, @localTime:true → withTimezone:false; removed timestamp_with_tz.

Shared cross-port fixtures (other ports byte-match these):
- persistence-conformance meta.fitness.json: dropped @dbColumnType on the two
  tz fields (recordedAt, tsTzVal — now bare = tz); marked the wire-naive fields
  (createdAt, observedAt, tsVal) @localTime:true to preserve their naive wire
  form. Canonical schema.postgres.sql + every roundtrip/query expected VALUE are
  byte-identical (regenerated, no diff).
- flattened-kitchen-sink conformance: dropped timestamp_with_tz on updatedAt
  (now bare tz) across input + expected + expected-effective.
- Regenerated registry-conformance golden (expected-registry.json +
  coverage-report.json) and metamodel-docs.

ADR-0019: temporal clause updated — field.timestamp instant/tz-aware by default,
@localTime the naive opt-out.

Tests: field-definition/db-definition-completeness, attr-schema-validate,
expected-schema, column-mapper, golden, sqlite-roundtrip (now builds expected
with dialect:"sqlite" so SQLite's tz-blind introspection matches). All affected
TS suites green; bun run --filter '*' build green. Live-PG integration tests are
CI/Testcontainers-gated (not run locally).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n

* docs(adr): ADR-0036 decision #1 — @localTime attribute (not field.localDateTime subtype)

Corrected per ADR-0037: timezone-awareness is a same-kind orthogonal modifier of
field.timestamp, so it's a boolean @localTime attribute (default false = instant),
not a sibling subtype. Matches the Wave-2 implementation.

* feat: ADR-0036 Wave 2 (Python) — field.timestamp instant-by-default + @localTime

Port the TS reference (fa40542) to Python. field.timestamp is now an absolute
instant / timezone-aware BY DEFAULT; the rare naive / wall-clock case opts out
with the boolean @localTime attr. The @dbColumnType: timestamp_with_tz escape
hatch is retired — the legal @dbColumnType set shrinks to { uuid, jsonb }. The
canonical golden + shared persistence fixtures are TS-owned and unchanged; this
makes the Python port byte-match them.

Metamodel:
- db_constants.py: add FIELD_ATTR_LOCAL_TIME; drop DB_COLUMN_TYPE_TIMESTAMP_TZ
  from VALID_DB_COLUMN_TYPES (now [uuid, jsonb]); tombstone the retired constant.
- db_provider.py: register @localTime (boolean, NO allowed_values) on
  field.timestamp only; description sourced from the embedded spec_metamodel/db.json
  by apply_spec_descriptions (single-source). @dbColumnType allowed_values shrinks
  via VALID_DB_COLUMN_TYPES.
- spec_metamodel/db.json: re-copied from the TS-updated repo-root spec/metamodel/db.json
  (byte-identity gate) — new boolean @localTime on field.timestamp + slimmed
  @dbColumnType allowedValues/description.

Loader:
- validation_passes.py: drop timestamp_with_tz from the @dbColumnType legal
  pairing map (now uuid/jsonb→field.string only). A timestamp_with_tz value now
  fails Rule 1 (unrecognized value) → still ERR_BAD_ATTR_VALUE, so the shared
  error-dbcolumntype-illegal-pairing fixture stays green (trigger shifts from
  illegal-pairing to unknown-value).

Runtime (the core native-type flip):
- object_manager.py write codec: default field.timestamp → tz-aware datetime
  (UTC tzinfo → driver binds timestamptz); @localTime:true → naive datetime
  (no tzinfo → driver binds plain timestamp). Inverts the prior default. The
  normalization boundary (tests/integration/normalization.py) already keys
  tz-aware↔Z / naive↔no-Z off tzinfo, so it is unchanged.

Codegen/schema: Python is pure data-access (ADR-0015) — no DDL/schema emitter
and the Pydantic type map is datetime.datetime for both modes, so no codegen
column-type branch existed to flip.

Tests: registry-conformance byte-match green; spec_metamodel embed byte-match
green; full conformance (301) green incl. the shared illegal-pairing fixture;
test_field_uuid_dbcolumntype updated for the retirement + a new @localTime-ok
case. Native-type rule unit-verified (default aware / @localTime naive). The
persistence op:roundtrip (tsVal naive / tsTzVal tz-aware) needs pg8000 +
Testcontainers Postgres — CI-gated, not run locally. The 2 failing
test_cli_staleness_nudge tests are pre-existing (unrelated agent-docs message
wording), failing identically on the base branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n

* feat: ADR-0036 Wave 2 (JVM: Java + Kotlin) — field.timestamp instant-by-default + @localTime

Flip field.timestamp to instant / timezone-aware BY DEFAULT (Postgres timestamptz,
java.time.Instant) and add a boolean @localTime attr on field.timestamp as the rare
naive / wall-clock opt-out (timestamp without time zone, java.time.LocalDateTime).
Retire the @dbColumnType: timestamp_with_tz escape hatch entirely — the @dbColumnType
legal set shrinks to { uuid, jsonb }. Java + Kotlin share metadata, so both ports are
flipped in lockstep. Byte-matches the TS-established golden + shared fixtures (NOT
modified here): expected-registry.json now carries @localTime (boolean, no
allowedValues) on field.timestamp and @dbColumnType allowedValues=[uuid, jsonb].

Metadata (shared Java metadata module, used by both ports):
- CoreDBMetaDataProvider: add LOCAL_TIME constant; drop DB_COLUMN_TYPE_TIMESTAMP_TZ
  from VALID_DB_COLUMN_TYPES (now [uuid, jsonb]); refresh Javadoc.
- TimestampField: register @localTime (boolean) on field.timestamp (manifest
  description/allowedValues sourced from the shared spec/metamodel/db.json the TS
  commit already updated — read off the classpath by SpecMetamodelReader).
- ValidationPhase.validateDbColumnType: drop the timestamp_with_tz legal pairing;
  the retired value now trips Rule 1 (unrecognized value) → still ERR_BAD_ATTR_VALUE.

Native type binding flip (INVERTS the pre-Wave-2 timestampWithTzOptIn logic):
- Java SpringTypeMapper: default field.timestamp → java.time.Instant; @localTime:true
  → java.time.LocalDateTime. Helper timestampWithTzOptIn → localTimeOptIn (reads
  @localTime).
- Java OMDB JdbcCodecs.TimestampCodec: default binds/reads as a UTC instant
  (setObject TIMESTAMP_WITH_TIMEZONE); @localTime binds/reads naive (setTimestamp(UTC
  Calendar)). Helper isTimestampTz → isLocalTime. SimpleMappingHandlerDB /
  ColumnDef: drop the dead COLTYPE_TIMESTAMP_TZ hint (timestamp tz is no longer a
  @dbColumnType hint — the codec reads @localTime off the field directly).
- Kotlin KotlinTypeMapper: default → Instant + Exposed instantWithTimeZone(...) /
  TIMESTAMP WITH TIME ZONE; @localTime:true → LocalDateTime + Exposed datetime(...).
  Helper timestampWithTzOptIn → localTimeOptIn; usesInstantWithTimeZone now true for
  the DEFAULT (drives the per-package instantWithTimeZone support file).

Tests (JVM):
- metadata: registry-conformance byte-matches the golden (Java + Kotlin both green);
  DbColumnTypeValidationTest rewired (retired-tz-value-rejected, @localTime-legal,
  illegal-pairing message on uuid-on-timestamp).
- codegen-spring/codegen-kotlin: SpringTypeMapperTest / KotlinTypeMapperTest flipped;
  SpringDtoGeneratorTest + the Kotlin Exposed/projection/snapshot tests updated
  (regenerated single-entity-primitives snapshot: createdAt → Instant /
  instantWithTimeZone + new MetaInstantWithTimeZoneColumnType.kt support file).
- OMDB codec round-trip (Derby): meta.codec.json tsVal + the raw-codec probe marked
  @localTime so the naive (Derby-compatible) path is exercised; the instant/tz default
  is Postgres-only and gated by persistence-conformance.
- Testcontainers AllTypes op:roundtrip GREEN both ports (Java 24/24, Kotlin 24/24):
  tsVal (@localTime naive LocalDateTime) + tsTzVal (default tz Instant) write+read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n

* feat(csharp): ADR-0036 Wave 2 — field.timestamp default flip to instant/tz-aware + @localTime

Port the C# implementation of ADR-0036 Wave 2 to match the TS-established golden
registry + shared fixtures:

- Register @localTime (boolean, no allowedValues) on field.timestamp only, via the
  db provider (DbConstants.FIELD_ATTR_LOCAL_TIME + DbSchema.LocalTimeSchema +
  DbProvider field.timestamp Extend). Description byte-matches the golden.
- Retire @dbColumnType: timestamp_with_tz — drop it from VALID_DB_COLUMN_TYPES
  (legal set is now [uuid, jsonb]) and from the ValidateDbColumnType pairing; update
  the @dbColumnType descriptions in DbSchema.cs + SpecMetamodel/db.json (copied from
  spec/metamodel/db.json) to byte-match the golden. Exempt @dbColumnType from the
  generic allowedValues check (Check 3) so a retired-value still emits a single
  ERR_BAD_ATTR_VALUE (mirrors the TS Check-3 exemption), not a duplicate.
- CLR-type binding (the core change): default field.timestamp → DateTimeOffset (an
  absolute instant); field.timestamp @localTime:true → DateTime (naive). CSharpNaming
  gains IsLocalTime + the field-aware ScalarForField; the entity generators emit the
  conditional CLR type.
- EF/DDL column mapping: default field.timestamp → `timestamp with time zone`
  (timestamptz); @localTime:true → `timestamp without time zone`. Removed the
  timestamp_with_tz override branch.
- Regenerated the committed integration fixtures (AllTypes/Asset/AppDbContext): tsTzVal
  + Asset.RecordedAt now DateTimeOffset/timestamptz; tsVal + observedAt stay
  DateTime/timestamp via @localTime.
- Integration test-harness: DateTimeOffset write coercion (UTC) + EntityRow
  pass-through + Normalization UTC formatter (fix the double-Z) for the persistence
  roundtrip; the api-contract generated server provisions createdAt as timestamptz +
  interprets an offset-less instant body as UTC so the EF write succeeds on any CI
  host timezone.

The registry-conformance byte-match passes; @localTime + the slimmed @dbColumnType
allowedValues byte-match the golden. All C# suites green (Conformance 668, Codegen
251, Render 290, CLI 42, IntegrationTests 82 incl. the AllTypes roundtrip + api-contract
generated lane against Testcontainers PG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n

* fix(jvm): ADR-0036 Wave 2 — api-contract harness + Kotlin controller emit Instant for createdAt

`Author.createdAt` is a bare `field.timestamp`, which under ADR-0036 Wave 2
generates as `java.time.Instant` (instant/tz-aware default). Align the JVM
api-contract test harnesses (Java + Kotlin) — and close one genuine Wave-2
generator gap — so the generated-controller lane compiles and round-trips.

createdAt wire form is now an absolute UTC instant (`yyyy-MM-ddTHH:mm:ssZ`).
Offset-less corpus seed/scenario strings are interpreted as UTC (append `Z`),
mirroring the C# lane.

Java:
- generated/GeneratedAuthorControllerHarness: DTO ctor reflection signature
  `LocalDateTime.class` → `Instant.class`; build createdAt via `Instant.parse`
  (UTC-interpreting); register a UTC Instant Jackson deserializer so offset-less
  request bodies deserialize into the generated DTO's `Instant` field.
- generated/InMemoryAuthorRepositorySource: `AuthorDto.createdAt` is `Instant`;
  filter comparison parses operands as UTC instants; refreshed stale comment.
- AuthorApiServer (reference lane): `createdAt` column → `timestamp with time
  zone`; read/write/filter via UTC `OffsetDateTime`/`Instant`; emit `...Z` wire.

Kotlin:
- generated/GeneratedAuthorControllerHarness: register a UTC Instant Jackson
  deserializer for the generated `Author.createdAt: Instant`.
- AuthorApiServer (reference lane): `AuthorTable.createdAt` → Exposed
  `timestamp()` (Instant); parse/format/filter via UTC instants; emit `...Z`.
- KotlinSpringControllerGenerator (generator gap): a default `field.timestamp`
  column is `Column<Instant>`, but the controller imported only `LocalDateTime`
  and the timestamp filter-coercer produced a `LocalDateTime` — a missing import
  + a ClassCast at the `p.value as Instant` dispatch cast. Now import
  `java.time.Instant` when an Instant-typed timestamp column is in the filter
  surface, and emit an Instant-producing (UTC-interpreting) coercer for it.
  Instant-free entities stay byte-identical (snapshot golden unchanged).

Verified against Testcontainers Postgres (Docker available): all Java + Kotlin
api-contract integration tests green (53/53 each, incl. generated/TPH/jsonb/m2m
lanes); codegen-kotlin 255/255 (snapshot golden byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n

---------

Co-authored-by: Doug Mealing <noreply@anthropic.com>
dmealing added a commit that referenced this pull request Jul 14, 2026
…#3, #11

FR-036 made the router validate POST/PATCH against the read/entity model, which was
wrong for a create/patch WIRE body. Introduce dedicated wire-shaped validation
models (entity_model.py) and bind them in the router:

- New <Entity>Create model for POST: excludes the auto-gen PK (identity.primary
  @generation increment|uuid) [#2] and @readonly fields; makes a @required field
  that is SERVER-FILLED — ANY @default (literal OR sql-expr now()/gen_random_uuid())
  or @autoset — OPTIONAL [#1, the regression: a normal POST omitting createdAt
  @required @default:"now()" no longer 400s]; keys by the WIRE name field.name [#3]
  (was @column, so a column-renamed entity's constraints checked the wrong key —
  bypass or spurious 400); carries the field constraints. Mirrors the TS InsertSchema
  (fieldWillBeOptional / autoGenPkFieldNames).
- <Entity>Patch now excludes the PK [#11] and keys by the wire name [#3].
- The read model <Entity> (@column-named) is unchanged — no consumer break. FR-035
  tristate + the repo create/update dict seam are unchanged.

Router: POST validates <Entity>Create(**dto) / per-subtype <Sub>Create(**dto);
PATCH validates <Entity>Patch(**dto).

Gated by 4 new unit tests (server-filled optional, auto-gen PK excluded, wire-name
validation, patch excludes PK). Regenerated 3 goldens. Verified: entity-model +
validation-conformance + generated api-contract lanes 54/0 (the only broader-suite
failures are the 2 pre-existing, unrelated cli-staleness-nudge tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
dmealing added a commit that referenced this pull request Aug 19, 2026
…can load its input

The Python port shipped both halves of the AI trace stack and not the metadata in
between. `runtime/llm_recorder.py` was there. The `trace-helper` generator was there
and registered for the CLI. But there was no `library/` package and no `libraries`
loader option, so `metaobjects::ai::LlmCallBase` could not be loaded on this port at
all: the documented `extends: metaobjects::ai::LlmCallBase` failed with
ERR_UNRESOLVED_SUPER. A generator shipped without its input.

The more useful part is why nothing caught it. The codegen suite hand-builds its own
abstract `LlmCallBase` — exactly what ADR-0024 already recorded ("the green tests pass
only because they bypass the shipped base with bespoke entities"). A test that builds
its own fixture proves nothing about the path an adopter follows, so the port's docs
could describe a path that could not run.

No new vocabulary — no type, subtype, or attribute — so expected-registry.json is
untouched. This mirrors the TypeScript design rather than inventing a second one: same
package names, same refs (path under library/ minus .yaml), same on-disk-first order.

  library/            library_sources(packages) returns a FileSource when the repo-root
                      library/ tree is reachable, so editing the canonical YAML takes
                      effect immediately, and falls back to the generated embed
                      otherwise (the wheel-in-site-packages case).
  scripts/            regenerates the embed. Embedded as a .py module, not shipped as
                      package data, so no build-backend config can silently drop it.
  libraries=[...]     on from_directory (hence load_directory). Opt-in and lazily
                      imported: a load requesting no libraries neither pays the import
                      nor gets extra names in its model. Sources are prepended for a
                      deterministic, TS-matching order — NOT because resolution needs
                      it; resolve_supers runs once after every root merges.
  config `libraries`  threaded into the CLI's load path. The option first landed only
                      on the loader — which is also all TypeScript exposes — so
                      `metaobjects gen` still could not load the metadata the registered
                      trace-helper generator exists to consume. The generator was
                      reachable from the command line while its input was not. The TS
                      CLI still lacks the key; that is a parity follow-up, not drift
                      introduced here.

An unknown package name is a ConfigError naming the valid ones when it comes from a
config file, and a silent skip when it comes through the API — matching TypeScript.
A name a human typed is a mistake worth failing on; an API caller asking for a package
this version does not ship should still load its own metadata.

Four gates ship with it, because each of these failed silently before:

  * the embed is byte-compared against the canonical YAML (the drift pattern already
    used for spec/metamodel/), so a stale generated module cannot reach a wheel;
  * `extends` is asserted to FAIL without the opt-in and resolve 18 inherited fields
    with it — the negative half is what proves the opt-in is doing the work;
  * ADR-0024 FIX #1 is now enforced: build_llm_call_row's keys equal LlmCallBase's
    effective fields, both directions. The ADR asked for this gate; it did not exist;
  * the acceptance test RUNS the generated helper against a capturing recorder and
    asserts every key it writes is a field the entity declares. The first version of
    that test asserted the strings voRequest/voResponse appeared in the emitted source,
    against a fixture declaring neither column — so it passed while blessing a helper
    that raises on its first write. That is the same bypass ADR-0024 warns about,
    reappearing inside a test written to prevent it. A substring assertion over
    generated code is not an end-to-end test.

Not addressed, and documented rather than changed: the opt-in also brings the library's
own concrete LlmCall entity (table llm_call) alongside the abstract base, so it appears
in codegen output and in a schema diff unless filtered. library/ai/llm-call.yaml is
shared by every port, so splitting it is a cross-port decision.

1639 tests pass; ruff clean on the changed files; no new mypy errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018k3CqBZLFkbP4Qs4FZhh96
dmealing added a commit that referenced this pull request Aug 21, 2026
…nsumes it has an input (#332)

The Java port shipped `LlmTraceHelperGenerator` and no way at all to load the metadata it
exists to consume: no `libraries` loader option, no embed of the canonical `library/` tree.
A JVM adopter following the documented `extends: "metaobjects::ai::LlmCallBase"` path could
not get past load. A generator shipped without its input.

What let that survive is the more useful half. Every test of that generator declares its
OWN `LlmCallBase` inline under a different package — the bypass ADR-0024 already names, "the
green tests pass only because they bypass the shipped base with bespoke entities" — so the
suite could not tell a world where the library loads from one where it does not exist.
A hand-copied base also drifts silently: the copies stay green while the shipped file moves.

Three pieces, each mirroring TS and Python rather than inventing a third shape:

- `EmbeddedLibrary` — a generated CLASS of string constants, not a `src/main/resources`
  copy. A resource can be dropped or mangled by build configuration (resource filtering,
  shading, repackaging) and the failure would surface much later as ERR_UNRESOLVED_SUPER
  against the adopter's own metadata; a class constant cannot go missing without the class
  going missing. Same rationale Python records for embedding as a source module. Emitted by
  the EXISTING `scripts/generate-embedded-library.ts` rather than a second script — two
  scripts walking one tree are two things that can drift, and an embed's whole job is to be
  byte-identical to its source.
- `LibrarySources` — on-disk-first (a checkout picks up edits to the canonical YAML with no
  regeneration), embedded fallback (every consumer of the published jar). An unrecognised
  package contributes no sources and is NOT an error here: that is the cross-port contract
  for the programmatic door, so a caller asking for a package this version does not ship can
  still load its own metadata.
- The opt-in: `MetaDataLoader.setLibraries(...)`, a `fromDirectory(..., libraries)` overload,
  `LoaderConfiguration.getLibraries()` (a `default` method — this interface is the build-tool
  seam and an implementor outside this repo must keep compiling), and a pom
  `<loader><libraries><library>ai</library></libraries>`. Libraries load BEFORE the project's
  own sources: super resolution is order-independent, so that is determinism rather than
  correctness, but a load order that differs per port is the kind of difference that only
  shows up in someone else's bug report.

An unknown name in a POM is a HARD failure listing the packages this version ships, matching
what the TS and Python config readers do. The asymmetry with the programmatic door is
deliberate and is the same one they draw: a name a human typed into a build file is a
mistake worth failing on, because skipped it resurfaces as ERR_UNRESOLVED_SUPER pointing at
the module's OWN metadata — the wrong place to send someone looking.

Gated by three things a green suite could not previously distinguish:

- `LibraryLoadTest` — the positive arm, the NEGATIVE arm (without the opt-in the same model
  must still fail, and the assertion names `LlmCallBase` rather than merely requiring
  non-empty errors: the loader wraps the real diagnostic in a "Failed to load from directory
  <path>" envelope that names nothing, and the first draft of this assertion passed on that
  envelope), and the freshness gate.
- `TraceHelperOnShippedLibraryTest` — runs the generator against the SHIPPED base, with
  ADR-0024 FIX #1 asserted BOTH directions. One direction alone is worthless: "every row key
  is a field" passes on a recorder that writes nothing, "every field is a row key" passes on
  one that writes the whole world, and only the equality says the two agree. It is also the
  claim a hand-copied base can never make, because it compares a copy against itself.
- The freshness gate was proven by BREAKING it — appending a line to `library/ai/llm-call.yaml`
  turns it red with "EmbeddedLibrary is stale for ref …; run: bun run
  scripts/generate-embedded-library.ts", then restored.

metadata + maven-plugin + codegen-spring: 202 + 34 + all green, 0 failures. C# has the same
gap and is not addressed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant