Skip to content

feat(fr-023): metadata dependencies — declare, sync, and build on a shared model (Phase 1a) - #366

Merged
dmealing merged 62 commits into
mainfrom
feat/fr-023-metadata-dependencies
Sep 12, 2026
Merged

dmealing merged 62 commits into
mainfrom
feat/fr-023-metadata-dependencies

Conversation

@dmealing

Copy link
Copy Markdown
Member

What & why

FR-023 Phase 1a — metadata dependencies. One project publishes its typed metadata as a flattened, hash-pinned artifact; another declares it, syncs a committed snapshot, and builds on it — extending, overlaying and referencing its nodes — while being protected from silent upstream drift.

TypeScript and Python only. Java, Kotlin and C# do not read dependencies yet; that is Phase 2.

What ships:

  • Declare + sync. dependencies in .metaobjects/config.json (path transport; npm/python refused as deferred). meta deps sync validates the publisher's manifest, re-hashes its artifact, copies it to a committed snapshot under .metaobjects/deps/<name>/, and pins it in a sha256 deps.lock.json. meta deps list reports the lock.
  • Load + scope. Dependency artifacts load first, under dep:<name>/<artifact> source ids, so bases precede the declarations that amend them. Imported packages are excluded from every action surface — codegen, migrate, and the requirements ledger — unless a scope.include names their package. One predicate, composed once and threaded through all three.
  • Refuse what would fail silently. ERR_DEPENDENCY_PACKAGE_NOT_OWNED catches a local node declared into a dependency's package, which the package-keyed exclusion would otherwise drop from its own project's output with no error.
  • Publish. sharedModelFile() emits the artifact plus its manifest. Registered and discoverable, deliberately not ejectable — it produces a contract whose bytes consumers verify.
  • Detect drift. meta deps check and verify --deps re-hash the installed artifact and fail on divergence. An advisory overlay lint reports an unflagged cross-file redeclaration.
  • Python port with the shared cross-port corpus runner, and a meta init cleanup that stops scaffolding the dead v0.3 package.meta.json and tracks the new dependency files.

No metamodel change: metamodelVersion stays 1.0 and expected-registry.json is untouched.

Checklist

  • Tests added/updated and passing locally (TDD — test first)
  • For metamodel changes: a conformance fixture was added/updated so all five ports verify it — N/A: no metamodel change. metamodelVersion unchanged at 1.0; expected-registry.json untouched; the gates lane's metamodel-version bump check reports no diff.
  • No new metamodel attribute invented — none added (ADR-0023)
  • Cross-language wire format & vocabulary preserved — the dependency corpus (fixtures/dependency-conformance/) runs on both consuming ports, 24 assertions each
  • Named constants used for metamodel strings; no any (TS)
  • Public-repo hygiene — verified by grep over the full diff and by the gates lane's leak-scan
  • Docs updated — new docs/features/metadata-dependencies.md plus seven existing pages, three agent-context skills (corpus regenerated), CHANGELOG and roadmap

Notes for reviewers

Gate status. All three lanes green on this exact tree: --only ts (build+typecheck, conformance, unit, mutation gate, two real-PG suites, integration), --only python (2098 + 110), --only gates (30/30).

Process. Each of the 20 tasks (one CUT) was individually reviewed for spec compliance and quality; 19 fix rounds ran. A whole-branch review then found 1 Critical and 2 Important that per-task review structurally could not see, all fixed in one wave and re-verified.

One known residual, deliberately not fixed here. meta docs --site can still fail for a project that both mixes a fresh declaration and an imported-node overlay in the same file — violating the documented "overlay in its own file" convention — and names a dependency that sorts after its own source directory alphabetically. docs-site's loader re-sorts source dirs alphabetically and only rescues overlay-only files. This is strictly narrower than before (that path was wholly broken for any dependency); gen and verify are unaffected, since Collection.files unconditionally leads with artifacts. Two remedies, and the choice is a design call: harden the convention into a requirement, or make docs-site share Collection.files' dependency-leads ordering.

Four findings surfaced that are not this branch's to fix:

  1. scripts/ci-local.sh prints LOCAL CI FAILED — N gate(s) red and exits 0. A genuinely red gate on this branch nearly passed as green; it was caught only by reading the summary text. Any automation trusting that exit status gets a false pass.
  2. Five --print-only assertions in the CLI tests use toContain, which can detect a missing forecast entry but never a spurious one — the blind spot is directional. One such stale entry shipped and was found by review, not by the suite.
  3. bin/meta.ts has no .catch(), so any throw from a command surfaces as an unhandled rejection rather than a diagnostic and an exit code.
  4. Nothing gates a source file that git classifies as binary. A stray NUL byte in a TypeScript source made git treat it as binary, blanking the file out of its own review diff and out of the pre-commit hygiene scanner's reach.

Worth a reviewer's attention: the two-pass migrate-ts schema partition (declaredSchemas derived from the remainder) and the docs --site snapshot-dir resolution are the subtlest pieces. The view.text corpus exemption that earlier deferred two Python cases has been discharged — both ports now assert the same 24.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4

dmealing and others added 30 commits September 11, 2026 13:15
Twenty-three TDD tasks for TypeScript, Python, the dependency-conformance
corpus and the docs/skills, executed from the committed design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…sk sections

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…s, and nine error codes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…t package (FR-023)

A shared document is one canonical-JSON metadata.root with NO root package,
whose top-level nodes each carry their own explicit package, so it re-loads to
the same resolution keys in every port. Each node is its own-layer canonical
form (extends preserved, attrs alphabetized, FR-016 rewrite applied); top-level
nodes sort by resolution key, children keep their authored order, and the body
key order is name, package, then the canonical rest. A node with no package is
refused.

- TS: serializeSharedDocument + packageOfResolutionKey, exported from the index.
- Python: serialize_shared_document + package_of_resolution_key, byte-identical
  (both reproduce the pinned dependency-conformance artifact acme-common-v1.json).
- fixtures/conformance/xpkg-node-level-package-no-root-package: the artifact as
  loader input and as expected canonical output; green in TS, Python, Java,
  Kotlin and C#.
- The metamodel fixture count in docs/CONFORMANCE.md, AGENTS.md and README.md
  moves 313 -> 314.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…inned artifact

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
fixtures/dependency-conformance/ is a shared corpus, so the stated corpora count
moves 21 -> 22 wherever it is typed (docs/CONFORMANCE.md, AGENTS.md, README.md),
and CONFORMANCE.md gains its row. The row states the corpus's actual state: one
case so far, a TypeScript runner that is red by design until the resolver lands,
no Python runner yet, and Java/Kotlin/C# deferred to Phase 2.

The llms mirrors carry the metamodel fixture count 314, and the site payload is
regenerated through `bun run site:payload` (counts only: fixtures 314, corpora 22).
scripts/site/counts.test.ts and the payload --check are green again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…ance)

new FileSource(path, { id? }) (TS) and FileSource(path, format=None, id=None)
(Python) accept an explicit source id, defaulting to basename(path) as before.
The parser stamps source.files with whatever id a node's FileSource carries
(ADR-0009), so a dependency artifact loaded later with
id: "dep:<name>/<artifact>" will record where it really came from.
Default behavior for every existing caller (single positional path argument)
is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… in TS

Python's id property used `self._id or self._path.name`, so an explicit
`id=""` fell through to the basename default — diverging from TS, where
`??` (nullish coalescing) already honours `id: ""`. Switched to an explicit
None-check (`self._id if self._id is not None else self._path.name`) and
pinned the empty-string case in both ports' tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…ort (FR-023)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… and Python neutral reader (FR-023)

Task 5 of FR-023 phase 1a. Adds the `dependencies` key (DESIGN §3.1): a
declared name, exactly one transport (`path` | `npm` | `python`, the
latter two with an optional `dir`), and a `mode` (`reference` | `own`,
default `reference`). Strict at every level, mirroring the existing
`sources`/`SourceSpec` pattern: an unknown key, a second transport, no
transport, a malformed name, a bad mode, or a duplicate name all refuse
to parse.

- `sdk/src/dependencies.ts`: the DESIGN-pinned constants (`DEPS_DIR`,
  `LOCK_FILE`, `LOCAL_OVERRIDE_FILE`, `MANIFEST_FILE`, `ARTIFACT_SUFFIX`,
  `DEPENDENCY_SOURCE_ID_PREFIX`, `INTEGRITY_PREFIX`, `DEPENDENCY_MODES`,
  `DEFAULT_DEPENDENCY_MODE`), `DependencySpec`/`DependencyMode` types, and
  `DependencySpecSchema` (zod), exported from the package barrel.
- `sdk/src/config.ts`: `ConfigSchema.dependencies` (default `[]`,
  unique-names refine) plus the two-direction compile-time parity guard
  between the schema and the hand-written `DependencySpec`, matching the
  existing `SourceSpec` guard.
- `server/python/.../config/dependencies.py`: the same constants.
- `server/python/.../config/neutral_config.py`: `NeutralConfig.dependencies`
  and validation matching the TS schema's refusals exactly, coded
  `ERR_COLLECTION_NOT_FOUND` (this reader's existing shape-error code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…me option cut

The re-cut left six rulings open. All are now settled, and the design and plan
are corrected to match.

- Exclusion is keyed on the lock's `packages`, not its `nodes`. The design
  argued `nodes` on a single empirical claim: that legacy consumers declare new
  top-level nodes into a dependency's package. That was checked against the
  estate and no instance distinguishes the two keys — the one legacy consumer
  declaring into a shared framework package adds no class the framework does not
  already declare, because it amends rather than adds, and an amendment is an
  overlay. The claim is false as written; §2.4 and §2.7 are corrected and §11.5
  records the evidence.
- The one failure mode a package-keyed rule introduces — a consumer node in a
  dependency package silently never generating — becomes a loud
  `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`, raised after load. An overlay merges into
  a node whose FQN is in the lock's `nodes`, so it passes untouched; only a
  genuinely new object is refused. `nodes` is retained in the manifest and lock
  for artifact integrity, collision detection and that refusal.
- A wildcard `include` does not opt a dependency in; only a literal package
  prefix does.
- Task 16, the ObjectManager scope predicate, is cut: "runtime" meant what
  metadata gets loaded, which the collection resolver already does. The task
  number is kept so the progress ledger's references stay resolvable.
- `verify --deps` fails when the publisher is unreachable, and stays out of the
  default verify run.
- Python keeps applying only the import rule, not the user's full `scope`, to
  its own objects — confirmed against the tree, where no caller of the scope
  grammar exists.

Error codes after Task 6 become seven. `metamodelVersion` is unaffected: the
gate reads the registry manifest, and an error code is not registered
vocabulary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…d error codes (FR-023 §11)

Task 6 of FR-023 phase 1a. The B+ re-cut (DESIGN §11) supersedes v2's
mode: reference | own and the D7/D6(b) machinery it depended on;
this task is the pruning pass, plus one addition, plus two carried
minors. No new feature.

- `sdk/src/dependencies.ts` + `config/dependencies.py`: remove
  `DEPENDENCY_MODES`, `DEFAULT_DEPENDENCY_MODE`, `DependencyMode`,
  `LOCAL_OVERRIDE_FILE`, and the `mode` member of `DependencySpec` /
  the Mode zod field on every schema arm. `DependencySpec` is now
  `{ name } & (path | npm+dir? | python+dir?)` with no mode — a
  `mode` key of ANY value (including "reference", the old default)
  is now an unrecognized-key schema error, since explicit
  `scope.include` replaces it (DESIGN §11.1 item 2).
- `neutral_config.py` `_validate_dependency_spec`: drops `mode`
  handling and its allowed-key entry to match.
- `fixtures/conformance/ERROR-CODES.json` + `errors.ts` + `errors.py`:
  remove `ERR_DEPENDENCY_OVERLAY_IMPLICIT`, `ERR_DEPENDENCY_SCHEMA_NOT_OWNED`,
  `ERR_DEPENDENCY_BREAKING_CHANGE` (superseded — §11.3); add
  `ERR_DEPENDENCY_PACKAGE_NOT_OWNED` (raised by a later task — the
  package-keyed exclusion rule of §11.5 needs a loud refusal when a
  local top-level node lands in a dependency's package); reword
  `ERR_DEPENDENCY_UNRESOLVED` to drop the retired local-override
  clause. All three registries move together (`errors.test.ts` /
  `test_errors.py` assert parity) — verified with both green.
- `fixtures/dependency-conformance/`: the corpus's case schema drops
  `localOverrides` / `expectOverrides` / `expectForeign` /
  `expectGoverned` / `classify`; adds `expectImported` /
  `expectSelected` / `expectMigrateGoverned`, each the EXHAUSTIVE set
  over every loaded top-level object admitted by
  `collection.imported` / `collection.inScope` /
  `collection.inMigrateScope` respectively (DESIGN §11.2). The TS
  runner is rewritten to match; `collection.imported` doesn't exist
  on `Collection` until a later task, so the corpus's one case now
  fails on that TypeError — expected, left failing per the plan.
- Two carried minors from earlier reviews: Python `matches_scope`
  now uses `re.fullmatch` (bare `re.match` lets `$` match just before
  a trailing newline, a genuine JS/Python semantic divergence — a
  literal, non-wildcarded pattern isolates it, since a wildcard
  segment's `[^:]*` legitimately consumes a literal newline as
  content either way); the TS corpus runner writes the lock file
  under `LOCK_FILE` imported from `dependencies.ts` rather than the
  inlined `"deps.lock.json"` string.

TDD: config.test.ts / test_neutral_config.py / test_scope_conformance.py
edited to fail first (mode still parsed; re.match still matched),
then made to pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… arms

Two corrections from the Tasks 6-21 conflict scan, made before Task 6 ran and
committed after it so its diff stayed scoped to its own files.

- The ownership refusal must fire on BOTH of loadMemory's arms. As first
  written it covered only the caller-supplied `files` arm, so every embedder
  calling loadMemory(repoRoot) with no files would have silently lost it.
  collectionLoadOptions now returns all four fields.
- The codegen runner's options type is RunGenOpts, not RunGenOptions,
  verified against the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…n bug

Task 6's RED-phase example was wrong twice: compile_scope takes positional
sequences, not a dict (passing a dict compiles its keys, silently yielding a
stub pattern), and the wildcard it used compiles to a character class that
admits a newline — so match and fullmatch agreed and the assertion could
never have failed against the defect it was written for. The literal pattern
is the only form where the two diverge. Measured both ways before correcting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Task 7 (Phase 1a): the data contracts for `metaobjects.pkg.json`
(ManifestSchema) and `.metaobjects/deps.lock.json` (LockSchema/LockEntrySchema),
plus sha256 content hashing, pure and with no consumer yet — the collection
resolver that folds a dependency's artifact into the loaded tree, and the
package-based exclusion key, land next.

- `packages`/`nodes` are both required and both validated sorted on every
  schema, per the 2026-09-11 ruling (§11.5): `packages` is the exclusion key,
  `nodes` stays for sync's artifact check, node-collision detection and the
  package-ownership refusal.
- No `mode` field anywhere (Task 6 removed it; a resurrected `mode` key is a
  hard parse error via `.strict()` in TS and an unknown-key check in Python).
- `writeLock` sorts dependency keys, 2-space indent, trailing newline, no
  timestamps or host paths — byte-identical across machines.
- Python hand-validates (no schema library), raising `ParseError` with
  `ERR_DEPENDENCY_MANIFEST_INVALID` / `ERR_DEPENDENCY_SNAPSHOT_STALE`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…ourceId

Fix round 1 on Task 7 review (Important finding): readLock/writeLock (TS)
and read_lock/dependency_source_id (both ports) shipped with zero test
coverage. Adds a writeLock→readLock round-trip asserting the exact on-disk
bytes (sorted dependency keys, 2-space indent, trailing newline, no host
paths or timestamps) against a hand-written expected literal captured from
a real run, plus direct assertions for dependencySourceId/dependency_source_id
and read_lock's absent-file, present-file, malformed-JSON and shape-violation
cases. No production code changed — the functions were already correct; every
added test passed on first run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…poses the default-exclusion scope (FR-023 §11)

`resolveCollection` now verifies a project's committed snapshot against
`.metaobjects/deps.lock.json`, leads its file list with the dependency
artifacts (name order, then the project's own files), and composes the two
predicates every downstream surface acts on:

  inScope(fqn)        = matchesScope(fqn, scope)
                        && (!imported(fqn) || explicitlyIncludes(scope.include, pkg))
  inMigrateScope(fqn) = (declaredMigrateScope?.(fqn) ?? true)
                        && (!imported(fqn) || explicitlyIncludes(migrate.scope, pkg))

`imported` keys on the lock's `packages` (DESIGN §11.5), not its `nodes`.
`explicitlyIncludes` requires a pattern to NAME a package literally, so
`acme::common::**` opts in and `**` does not: a project that writes a
wildcard to mean "all of my model" must not thereby generate, and migrate,
somebody else's.

`inMigrateScope` stays `undefined` exactly when the project declares no
`migrate.scope` AND resolves no dependencies — the untouched path that leaves
migrate's expected schema alone. A project with no dependencies resolves as it
always did; `nodes` is retained for one reader, the new post-load
`ERR_DEPENDENCY_PACKAGE_NOT_OWNED` refusal, which tells an overlay of an
imported node from a new local declaration in its package.

`loadMemory` gains `fileIds` (so an artifact's nodes carry `dep:<name>/<artifact>`
provenance rather than a local-looking basename) plus the imported sets, and
runs the refusal AFTER the loader's own errors on BOTH arms — the
caller-supplied file list and the self-resolving one, so an embedder calling
`loadMemory(repoRoot)` does not silently lose it. The nine CLI load sites now
thread all four through one helper, `collectionLoadOptions`.

Corpus: 15 cases appended to fixtures/dependency-conformance (resolution,
scope/migrate-scope composition, the ownership refusal and its overlay
counter-case, five staleness arms, node collision, metamodel-major
incompatibility, and a native-source-surface layout). The corpus is fully
green; the sanctioned `collection.imported is not a function` failure ends
here.

Also fixes one pre-existing break this task's cli typecheck was the first
command to see: the sdk root barrel exports two types named `Manifest` (the
agent-context one via `export *`, FR-023's explicitly), and an explicit export
shadows a star one, so `agent-context-staleness.ts` was resolving the wrong
type once `dist/` was rebuilt. It now imports from the `/agent-context`
subpath, as `init.ts` already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…; the requirements line's dependency count is conditional (FR-023)

Two adjudicated fixes from the Task 8 review.

1. `verify`'s requirements summary appends `, N from dependencies` only when
   there ARE dependencies. A project that declares none prints the sentence it
   printed before metadata dependencies existed, ending at `counted over N
   metadata file(s).` — the byte-identical no-dependency guarantee covers this
   surface like any other. The covering assertion now includes the trailing
   period, so it can tell the two sentences apart; without it the test passed
   just as happily on the ungated output.

2. FR-023's manifest type is `DependencyManifest` / `DependencyManifestSchema`.
   The package barrel already published an agent-context `Manifest` through an
   `export *`, and an explicit export shadows a star one, so the bare name
   silently re-typed every consumer asking the root barrel for the
   agent-context manifest. FR-023's is the newcomer, and the longer name reads
   right beside its own siblings (`DependencySpec`, `LockEntry`, `Lock`,
   `ResolvedDependency`). The previous commit's subpath fix in
   `agent-context-staleness.ts` stands on its own and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…t in the docs

Two controller rulings from Task 8's concerns, synced into the documents that
later tasks read.

- The ownership refusal names the object and the package, not the dependency.
  The load-time option shape carries flat package and node sets with no
  package-to-dependency-name map, and the package is what a user acts on since
  scope.include takes a package. The design claimed otherwise; the code was
  right and the design was wrong.
- The dependency manifest type is DependencyManifest / DependencyManifestSchema
  after the rename that ended the sdk barrel's two-Manifest collision. The plan
  and the design still named the old symbol, and the design is a living spec a
  later task reads. Prose naming the manifest FILE is deliberately unchanged, as
  is the unrelated readManifestDir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…ft gate (FR-023)

Appends 7 load-time failure cases to fixtures/dependency-conformance/cases.json
(DESIGN §2.5's table, plus the sibling whole-object-overlay case and the
malformed-artifact case from §11.1 item 4) — no source change. Every one
demonstrates that when an upstream dependency's snapshot changes underneath a
consumer, an existing loader error (ERR_OVERLAY_NO_TARGET, ERR_UNRESOLVED_SUPER,
ERR_UNRESOLVED_OBJECT_REF, ERR_EXTENDS_TARGET_MISMATCH, ERR_MERGE_CONFLICT,
ERR_MALFORMED_JSON) already fires — proven by running each case, not merely
asserted. README documents the mapping table and the confirmed load-order
guarantee (loader errors before the package-ownership refusal) the
whole-object-overlay case depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Pre-flight check before the codegen-selection task: the plan's file list named
only one of renderSharedEnumsFile's two call sites. The other is the reference
template meta init copies eagerly into every scaffolded project, so threading
the selection predicate through the library path alone would leave every
consumer's own generated copy emitting enums for imported entities that the
library path correctly excludes. Both call sites must honour it, and a diff
updating only one is an Important finding rather than a nit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
The task's file list said six while its own step enumerates six bullets whose
first carries an embedded sibling case — seven in total. The implementer
caught the disagreement and followed the step, which was right; the file line
would have misled the next reader the same way it misled me when I summarised
it into the dispatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…de names their package (FR-023 §11)

Task 10: `meta gen`'s existing scope wiring (Task 8) already narrows per-entity
output, but the shared-enums module renders once over the whole loaded root
and never runs a generator's per-entity filter — so it needed its own `select`
knob threaded through GenContext/RunGenOpts to honour the same default
exclusion of imported metadata. Also adds a `meta gen <Name>` pre-check that
refuses (exit 2) a positional naming only objects imported from a dependency
and excluded from output, distinct from the existing "no entities matched"
warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…call sites

Pre-flight check before the schema-tooling task: its file list names four CLI
sites, but only three of them call scopeExpectedSchema directly. The offline
migrate pipeline reaches it through planOffline, and verify --db reaches it
through computeDriftFromActual. Threading the option only at the direct sites
would leave both of those performing no import exclusion at all, with every
test still passing — that task's own Postgres test calls the function directly
and cannot see the gap. Both option bags now carry it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…t files

Its step ran three test paths that are not in the repo. The implementer
trusted the source over the example and ran the real analogs, which was
right. Corrected to the real paths, including the integration subdirectory a
flat listing of the test folder hides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
The codegen-selection task told an implementer to build its consumer scaffold
the way a test does that is not in the repo — a pattern to copy rather than
just a command to run, so it would have sent someone hunting. Now names the
real gen test that scaffolds a config with the owned entity and barrel
generators, verified to contain that shape before the edit was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…nd never widen its schema scope (FR-023 §11)

A dependency's nodes are loaded so the consumer's own model can resolve against
them. Nobody governs them here, so `migrate` and `verify --db` must exclude them
on BOTH sides: out of the expected schema (no create, no alter) and suppressed on
the actual side via `unmanagedNames` (no drop). Doing only the first half is
strictly worse than doing nothing — the publisher's table exists in the shared
database, so removing it from `expected` alone turns it into a proposed DROP
TABLE against data this consumer does not own.

The correction that makes it safe is an ORDERING one. `scopeExpectedSchema` pins
`diff`'s schema scope to the unscoped model on purpose ("a scope narrows objects,
never schemas"), which is right for `migrate.scope` — the same model declared into
that schema — and wrong for an import: the consumer never declared into the
publisher's schema, so leaving it pinned makes every table the publisher did NOT
export a drop candidate. So an excluded import now leaves the expected side BEFORE
`declaredSchemas` is computed, and the value is derived from the remainder.
Everything `migrate.scope` excluded is still in that remainder, so the older rule
is untouched.

The predicate reaches all five CLI paths, including the two that call
`scopeExpectedSchema` one layer down: `PlanOfflineArgs` (the offline pipeline, the
default `meta migrate`) and `ComputeDriftOptions` (`verify --db`, both dialect
paths). Threading only the direct sites would leave those performing no import
exclusion while every test still passed.

Also: the "your migrate.scope matched nothing" refusal now reads the DECLARED
scope rather than the composed predicate. The composed one also excludes imports,
so a consumer whose only table-declaring objects are imported — and who declared
no scope at all — would have been refused, citing patterns that do not exist.

`scopeExpectedSchema` without the new option is byte-identical: same snapshot
object, no `declaredSchemas`, and the CLI supplies no predicate at all for a
project with no dependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…call sites

Pre-flight check before the ledger task: its read-first line cited a line
number off by over two hundred, and described coverableEntities as a single
call site when it has two. Gating one and not the other would leave imported
entities still counted, with every test green — the third time this plan has
named one call site where there are several.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…sk's step

Its first step named a test file that does not exist, showed a builder call
with the wrong signature, and then talked itself out of its own example
mid-sentence before giving the right one. A step an implementer is told to
follow verbatim cannot contain the author's discarded first attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
The schema task's second step told an implementer to assert the note on
stderr. Under the default text format it goes through the info logger, which
writes to standard output; only the non-text branch uses the error stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
dmealing and others added 28 commits September 11, 2026 22:29
… axis

The guarantee promised identical behaviour for any project with no
dependencies, on every surface, without qualification. That holds for the
dependency axis it was written about, but the design deliberately routes the
requirements denominator through the same predicate that applies a project's
own declared scope — so a project that declares one, with no dependencies at
all, does see its denominator narrow. Intended behaviour, overreaching
wording; qualified so a later reader does not read it as a contradiction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…red-model artifact and manifest (FR-023)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…upe key with the \0 escape (FR-023)

A literal 0x00 control byte in the closure-check dedupe key made git treat the file
as binary — diffs showed no content, and the public-repo-hygiene pre-commit grep
never enumerated its added lines, silently bypassing that guard for this file. The
`\0` escape produces the identical runtime string with byte-identical source. Also
adds a targeted test for a top-level node whose own explicit `package` differs from
its file's default: the closure check's effectivePackage()-derived referrer package
agrees with the loader's own threaded reference resolution here (fileDefaultPackage
is captured at parse time from the nearest PACKAGED ancestor, not the file root), so
this closes the reviewer-flagged unknown rather than a live defect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Three reference defects found while resuming execution at Task 13. Each was
verified against the tree before editing; the source won in every case.

- Task 13's file list named `RunGenOptions.sourceFiles?`. The exported type is
  `RunGenOpts` (runner.ts:45) and `RunGenOptions` exists nowhere in the
  workspace. The plan had already diagnosed this name in Task 10's notes and
  left the wrong one standing here.

- Tasks 6 and 21 both grep for deleted vocabulary expecting no output, using a
  bare `OVERLAY_IMPLICIT`. That pattern also matches `WARN_OVERLAY_IMPLICIT` —
  the diagnostic code Task 17 introduces, and which Task 20 then documents under
  docs/features and agent-context, both of which Task 21's grep covers. As
  written, the gate would fail on the plan's own deliverables. Both are anchored
  to `ERR_DEPENDENCY_OVERLAY_IMPLICIT`, which is what the deletion check means.
  The remaining alternatives are unambiguous: `SCHEMA_NOT_OWNED` does not match
  the live `ERR_DEPENDENCY_PACKAGE_NOT_OWNED`.

No behaviour change; plan text only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… sha256 lock (FR-023)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…lp, dedupe hash8, cover the name filter (FR-023)

Fix round 1 on Task 14: catch readLock's throw in `deps sync`/`deps list`
(a corrupted committed lock previously crashed via an unhandled rejection —
the same failure class already guarded for a bad config.json); add `deps`
to help-lists-every-flag.test.ts's COMMANDS table so its flags are actually
gated going forward; export one `hash8` helper from dependency-sync.ts
(built on INTEGRITY_PREFIX) instead of three independent inlined copies;
and add coverage for `meta deps sync <name>`'s carry-forward + cross-
dependency collision seeding (no defect found — confirms the existing
`planSync` behavior).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…ing itself (FR-023)

Fix round 2 on Task 14: the round-1 name-filter test exercised real code but
could not fail under a mutant that deletes filtering entirely (targets =
[...specs] unconditionally) — the untargeted dependency's untouched publisher
made "carried forward" and "re-resolved fresh" produce identical results.
Adds a test that deletes the untargeted dependency's publisher directory
outright before a filtered sync, so re-resolving it (the mutant's behavior)
fails while correct filtering succeeds. Verified against the mutant by hand:
fails under it, passes reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Task 14's file list specified `DEPS_OPTIONS = { "dry-run": boolean, format:
string }`. `--format` is a GLOBAL flag: `index.ts` extracts it from anywhere in
argv before any command parser runs, and validates it once for every command. No
command's option table declares a `format` key — the apparent counter-example,
`MIGRATE_OPTIONS`, matches only the unrelated `"migration-format"`.

`parseDepsArgs` also runs `parseArgs` with `strict: true`, so the two facts have
to hold together: listing `format` there would be dead at best, and omitting it
is only safe because the global extraction happens first. Both verified before
correcting.

Found by the Task 14 implementer against the source, not by the plan's author.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…dependency differs from the lock (FR-023)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… re-hash leans on (FR-023)

Task 15 fix round 1: readManifestDir already guarantees
sha256Integrity(artifactContent) === manifest.integrity before checkDependencies's
own re-hash can run, so the re-hash cannot currently observe a divergence. Comment
only — records the invariant and the risk if readManifestDir's check is ever
relaxed, so a future reader isn't left to trace it themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…as an overlay authoring finding (FR-023 §11)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…me way the real parser does

Reuses parser-core.ts's expandPackageForPath (now exported) instead of using an
own `package` verbatim, so a `::`-prefixed package on a root-level declaration
resolves to the same key MetaData.resolutionKey() produces on the loaded side.
Fixes a silent mis-grouping risk in the overlay lint (Task 17 fix round 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Both found while executing Task 17; both verified against the tree before
editing.

- Task 17's Step 2 asserted "NO existing test pins the ordering guarantee
  itself" for the overlay-last source partition. False:
  `sdk/test/order-independence.test.ts` permutes all six orderings of three
  real files — overlay-before-base included — through the real loader, and its
  own comment records that disabling the partition makes three of the six throw
  ERR_OVERLAY_NO_TARGET. The earlier sweep missed it by scoping the search to
  `metadata/test` when the test lives in `sdk/test`; the plan's own suggested
  discovery command would have found it at repo scope.

- Task 20 told the implementer to regenerate the agent-context corpus with
  `node scripts/bundle-agent-context.mjs`, citing `agent-context/bundle.test.ts`
  as the assertion. Wrong on three counts: that path does not exist from the
  repo root, it is a different script that does not refresh the corpus, and the
  cited test is a ten-line existence check that cannot detect stale prose. The
  gate that pins prose is `agent-context-conformance.test.ts`, regenerated by
  `bun scripts/regen-agent-context-conformance.ts` from the sdk package.

Plan text only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…gen unless scope.include names their package (FR-023 §11)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…iminating ordering test (FR-023 §11 fix round 1)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…no longer scaffolds package.meta.json (FR-023)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…--print-only forecast (FR-023 task 19 fix round 1)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
… when upstream moves (FR-023 §11)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…-any-generator claim (task 20 fix round 1)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Both asserted that every port reads `dependencies` and loads dependency
snapshots. Only TypeScript and Python do, in Phase 1a — Java, Kotlin and C#
don't read `dependencies` at all; that is Phase 2. Verified by grep: the
dependency-resolution code exists in `sdk/src/dependencies.ts` and Python's
`config/dependencies.py`, with zero occurrences under `server/java`,
`server/csharp`, or any Kotlin source.

This wording almost certainly caused the same overclaim to ship in the
documentation, where a review caught it across nine sites — including five
regenerated agent-context goldens that ship to Java, Kotlin and C# projects.
A contracts document is a fact source its readers reproduce, errors included.

The correction keeps the design intent and pins the phase, rather than
flattening "every port" into "two ports" — dependencies are meant to be read
at every rung by every port; only two implement it today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Task 10 taught the canonical reference `entityFile` to pass `select` into
`renderSharedEnumsFile`, so a whole-root render excludes enums used only by an
imported, out-of-scope entity. Its three owned copies were never re-synced.

That is not bookkeeping: this template is what `meta init` copies into every
scaffolded project's `codegen/generators/`, so the library path had the fix
while every newly scaffolded project kept silently emitting shared enums the
library correctly excludes.

Re-synced through `bun scripts/sync-owned-template-copies.ts`, never by hand.
All three copies received a byte-identical change, and the gate now reports 12
of 12 current.

Found by the full `gates` lane at the end of the plan. Per-task run lists are
scoped to the packages a task touches, so nothing ran this gate for the ten
tasks between its introduction and here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Task 21's Step 2 grep ran a vocabulary-DELETION check over `docs/features` and
`agent-context` with `--include=*.md`. That collided with Task 20's own
mandated deliverable: its brief requires the new page to carry a `## Deferred`
section naming the local override, the classifier and the npm/python
transports, so the gate flagged prose that is correct and required.

Same shape as the earlier unanchored `OVERLAY_IMPLICIT` pattern, which would
have matched Task 17's own `WARN_OVERLAY_IMPLICIT`. An absence check belongs on
the surface where the thing would actually live; prose legitimately names a cut
feature in order to say it does not exist.

Scoped to code paths, with the prose rule stated separately beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
generateSite runs its own independent loadModel over sourceDirs rather
than reusing the already-loaded collection root, and
collection.sourceRoots deliberately excludes dependency artifacts. A
consumer that extends an imported abstract or overlay:trues an
imported node — the two constructs entities.md and
abstracts-and-inheritance.md recommend for this exact boundary — hit
ERR_UNRESOLVED_SUPER / ERR_OVERLAY_NO_TARGET and --site exited 1.

Append each resolved dependency's snapshot dir to the dirs passed to
generateSite; loadModel picks it up like any other source dir since a
snapshot dir holds exactly the one artifact file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Python implements only the import-exclusion conjunct of inScope (its
own docstring already says so) — it has never applied matchesScope to
a project's own objects. The doc presented one unqualified formula
that reads as cross-port; state both, and note in the corpus README
that the two scope-bearing expectSelected cases both use includes
under which every own object already matches, so neither can catch
this divergence today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…pendencies

runOverlayLintAdvisory() is called unconditionally in verify.ts and
lintOverlays() iterates collection.files, which for a zero-dependency
project is simply its own files — so a plain multi-file local overlay
(no dependencies at all) triggers the same advisory finding. The
metaobjects-verify skill said the lint only runs "if this project
declares dependencies"; correct it and regenerate the five agent-context
golden copies in the same commit.

Also: AGENTS.md's own "Optional layered overlay pattern" example (three
files sharing one package+name) carried no overlay: true, so every
project following the repo's own documented pattern would now get the
advisory finding — add the flag to the example.

Record the second carve-out from "no dependencies behaves identically"
in the CHANGELOG: new WARN_OVERLAY_IMPLICIT advisory output on
meta verify for zero-dependency projects, alongside the existing
ledger-denominator carve-out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
Two of 23 cases used view.text — a TS-web-presentation-only view
subtype Python does not register — purely as incidental overlay
content, so Python asserted only the documented ERR_UNKNOWN_SUBTYPE
load failure instead of the full imported/selected/governed
assertions the corpus defines. view.text appears in neither of the
three pinned artifacts, only in two inline tree strings, so swapping
it for view.currency (the one concrete view.* subtype registered
cross-port per expected-registry.json) has zero effect on any pinned
hash.

That mattered because of which cases were exempted: the positive
overlay case and the primary upstream-removal (ERR_OVERLAY_NO_TARGET)
case — overlaying a dependency's node is the central authoring move
this feature documents, and it was asserted by nothing on Python.

Remove the now-unnecessary _VIEW_TEXT_NOT_REGISTERED_IN_PYTHON
allowlist; both cases run normally. Both ports' runners pass (TS 24/24,
Python 24/24) with no behavioural difference surfaced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
codegen-ts can't import sdk (the dependency runs the other way), so
shared-model-file.ts redefines MANIFEST_FILE, ARTIFACT_SUFFIX and
INTEGRITY_PREFIX locally, guarded only by a "Keep in sync" comment
— the same shape as the drift that shipped earlier in this branch
(a canonical template changed; its three owned copies went stale for
ten tasks, caught only by the final gate).

Export the three constants from shared-model-file.ts (re-exported,
aliased, from the generators barrel) and add a cli test — cli is the
one package depending on both sdk and codegen-ts — asserting
byte-equality against sdk's canonical copies. Verified by mutation:
changing ARTIFACT_SUFFIX's local value to ".metaobjects.json.bak"
turns the test red; reverting turns it green again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
…p ledger)

1. src/reference/entity.ts's ctx.select call site — the exact file
   meta init copies into every project's codegen/generators/entity.ts,
   the default entity-module path — had no end-to-end test. The
   underlying renderSharedEnumsFile({select}) primitive was already
   unit-tested (shared-enums-imported.test.ts), but nothing proved
   runGen actually threads opts.scope through to ctx.select, or that
   this file's own ternary reads it. Add an end-to-end runGen test
   through the literal reference/entity.ts generator: a scope
   excluding an enum's only consumer suppresses enums.ts; no scope
   still materializes it.

2. No test covered "scope declared, zero dependencies" — the one
   behaviour change this branch deliberately records in the
   CHANGELOG (Collection.inScope composes matchesScope(fqn, scope)
   regardless of imports, so scope.include narrows the requirements
   ledger's denominator even with no dependencies at all).
   verify-requirements-imported.test.ts only covered the WITH-a-
   dependency half of the predicate. Add a zero-dependency fixture
   (two of the project's own entities, no dependency anywhere) that
   asserts scope.include narrows the ledger denominator from 1/2 to
   1/1 and drops the excluded entity out of the count entirely.

Both mutation-verified: dropping ctx.select in the reference
generator, and dropping `coverable: collection.inScope` in verify.ts,
each turn their respective new test red; reverting turns them green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQWqotP1jaKs85XhKVXG4
@dmealing
dmealing merged commit 83c66ee into main Sep 12, 2026
1 check passed
@dmealing
dmealing deleted the feat/fr-023-metadata-dependencies branch September 12, 2026 11:56
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