From 94eb4c33168fc65a1581a5b7d54dbd2c3e3117cb Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 21:50:29 -0500 Subject: [PATCH 1/4] feat: a project's own provider class, wired in every port and loaded by every command A project that declares its own vocabulary (a new subtype, or attributes on a core type) writes a provider class in each language whose tools load the metadata and names it once in that port's config. Every metadata-loading entry point now loads it. - TypeScript: export, deps, fmt, types and upgrade now load the providers in metaobjects.config.ts; verify and prompt-snapshot report a config that fails to load instead of continuing without its providers. - Python: fmt reads configured providers (and gains --config); verify's --provider adds to the configured list instead of replacing it. The loader keeps a file whose only parse errors are unknown subtypes, so its unknown attributes are reported too. - C#: new csharp.providers [{assembly, type}] list in .metaobjects/config.json, loaded by gen, verify and fmt (ProviderLoader). - Java/Kotlin: Maven plugin list for generate/verify/fmt/editor, Spring MetaDataTypeProvider beans composed into the auto-configured loader, LoaderOptions.setProviders, and a providers parameter on metadata-ktx. ERR_UNKNOWN_ATTR is reported after ERR_UNKNOWN_SUBTYPE, as in the other ports. - Conformance: provider-custom-vocabulary-success and provider-custom-vocabulary-missing-provider-fails, run by every port. - Skills: metaobjects-authoring teaches it with a worked reference per language; metaobjects-codegen notes how a generator reads the attribute. - Docs: extending-with-providers.md has a worked example per language and drops the false SPI auto-discovery claim; port pages and recipe updated. --- AGENTS.md | 2 +- CHANGELOG.md | 31 ++ .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/csharp-providers.md | 85 +++++ .../references/java-providers.md | 93 +++++ .../references/kotlin-providers.md | 77 ++++ .../references/python-providers.md | 69 ++++ .../references/typescript-providers.md | 74 ++++ .../skills/metaobjects-codegen/SKILL.md | 5 + docs/CONFORMANCE.md | 7 +- docs/features/extending-with-providers.md | 354 +++++++++++++++--- docs/ports/csharp.md | 45 ++- docs/ports/java.md | 79 ++-- docs/ports/kotlin.md | 39 +- docs/ports/python.md | 36 +- docs/ports/typescript.md | 9 +- .../extending-metaobjects-with-providers.md | 22 +- examples/showcase/site-payload.json | 2 +- .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/java-providers.md | 93 +++++ .../references/kotlin-providers.md | 77 ++++ .../skills/metaobjects-codegen/SKILL.md | 5 + .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/java-providers.md | 93 +++++ .../skills/metaobjects-codegen/SKILL.md | 5 + .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/python-providers.md | 69 ++++ .../skills/metaobjects-codegen/SKILL.md | 5 + .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/typescript-providers.md | 74 ++++ .../skills/metaobjects-codegen/SKILL.md | 5 + .../skills/metaobjects-authoring/SKILL.md | 14 + .../references/choosing-a-shape.md | 11 + .../references/typescript-providers.md | 74 ++++ .../skills/metaobjects-codegen/SKILL.md | 5 + .../expected-errors.json | 29 ++ .../input/meta.contacts.json | 17 + .../providers.json | 1 + .../expected.json | 35 ++ .../input/meta.contacts.json | 17 + .../providers.json | 1 + .../CsharpProvidersTests.cs | 237 ++++++++++++ .../MetaObjects.Cli.Tests.csproj | 2 + server/csharp/MetaObjects.Cli/FieldLint.cs | 5 +- server/csharp/MetaObjects.Cli/FmtCommand.cs | 2 +- server/csharp/MetaObjects.Cli/Program.cs | 3 +- .../csharp/MetaObjects.Cli/VerifyCommand.cs | 10 +- .../CsharpProvidersCodegenTests.cs | 88 +++++ .../MetaObjects.Codegen.Tests.csproj | 2 + .../ConformanceAdapter.cs | 30 ++ .../MetaObjects.TestVocabulary.csproj | 12 + .../TestVocabularyProvider.cs | 34 ++ server/csharp/MetaObjects.sln | 6 + .../MetaObjects/Config/MetadataLocation.cs | 24 +- .../MetaObjects/Config/NeutralConfig.cs | 59 ++- .../MetaObjects/Config/ProviderLoader.cs | 91 +++++ .../spring/MetaDataLoaderConfiguration.java | 18 + .../spring/MetaDataLoaderProvidersTest.java | 65 ++++ .../metadata/test-custom-vocabulary.json | 11 + .../mojo/AbstractMetaDataMojo.java | 70 ++++ .../com/metaobjects/mojo/MetaDataFmtMojo.java | 7 +- .../metaobjects/mojo/MetaDataVerifyMojo.java | 2 + .../metaobjects/mojo/MojoProvidersTest.java | 169 +++++++++ .../com/metaobjects/metadata/ktx/Loader.kt | 31 +- .../metadata/ktx/ProviderVocabularyTest.kt | 93 +++++ .../com/metaobjects/loader/LoaderOptions.java | 27 +- .../metaobjects/loader/MetaDataLoader.java | 53 ++- .../parser/json/CanonicalJsonParser.java | 2 +- .../registry/RegistryManifest.java | 34 +- .../conformance/ConformanceTest.java | 5 + .../conformance/ConformanceTestProviders.java | 39 ++ .../conformance/YamlTwinConformanceTest.java | 3 + .../loader/LoaderProvidersTest.java | 97 +++++ server/python/src/metaobjects/cli.py | 40 +- .../metaobjects/loader/meta_data_loader.py | 10 +- .../codegen/test_cli_config_providers.py | 137 +++++++ .../tests/conformance/conformance_adapter.py | 37 ++ .../packages/cli/src/commands/export.ts | 23 +- .../cli/src/commands/prompt-snapshot.ts | 26 +- .../packages/cli/src/commands/types.ts | 16 +- .../packages/cli/src/commands/upgrade.ts | 15 +- .../packages/cli/src/commands/verify.ts | 10 + .../packages/cli/src/lib/dependency-load.ts | 10 +- .../packages/cli/src/lib/fmt-engine.ts | 23 +- .../cli/src/lib/load-metaobjects-config.ts | 17 + .../test/config-providers-everywhere.test.ts | 188 ++++++++++ .../metadata/test/conformance/adapter.ts | 38 +- server/typescript/packages/sdk/src/config.ts | 10 + .../packages/sdk/test/config.test.ts | 16 + 94 files changed, 3502 insertions(+), 229 deletions(-) create mode 100644 agent-context/skills/metaobjects-authoring/references/csharp-providers.md create mode 100644 agent-context/skills/metaobjects-authoring/references/java-providers.md create mode 100644 agent-context/skills/metaobjects-authoring/references/kotlin-providers.md create mode 100644 agent-context/skills/metaobjects-authoring/references/python-providers.md create mode 100644 agent-context/skills/metaobjects-authoring/references/typescript-providers.md create mode 100644 fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/java-providers.md create mode 100644 fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/kotlin-providers.md create mode 100644 fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/java-providers.md create mode 100644 fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/python-providers.md create mode 100644 fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md create mode 100644 fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md create mode 100644 fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/expected-errors.json create mode 100644 fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/input/meta.contacts.json create mode 100644 fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/providers.json create mode 100644 fixtures/conformance/provider-custom-vocabulary-success/expected.json create mode 100644 fixtures/conformance/provider-custom-vocabulary-success/input/meta.contacts.json create mode 100644 fixtures/conformance/provider-custom-vocabulary-success/providers.json create mode 100644 server/csharp/MetaObjects.Cli.Tests/CsharpProvidersTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/CsharpProvidersCodegenTests.cs create mode 100644 server/csharp/MetaObjects.TestVocabulary/MetaObjects.TestVocabulary.csproj create mode 100644 server/csharp/MetaObjects.TestVocabulary/TestVocabularyProvider.cs create mode 100644 server/csharp/MetaObjects/Config/ProviderLoader.cs create mode 100644 server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java create mode 100644 server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java create mode 100644 server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt create mode 100644 server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java create mode 100644 server/python/tests/codegen/test_cli_config_providers.py create mode 100644 server/typescript/packages/cli/test/config-providers-everywhere.test.ts diff --git a/AGENTS.md b/AGENTS.md index 31c4c7fa80..3a938b3efe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (423 fixtures; 35 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (425 fixtures; 35 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b13247ac0..828fc85440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ here.** ### Fixed +- **TypeScript: every `meta` command now loads the providers in `metaobjects.config.ts`.** + `meta export`, `meta deps`, `meta fmt`, `meta types` and `meta upgrade` loaded the core + vocabulary only, so a project's own subtype or attribute failed there while `meta gen` accepted + it. A config that exists but fails to load is now reported as its own error by `meta verify` + (exit 2, in every mode) and `meta prompt-snapshot`, instead of continuing without its providers + and reporting an unknown subtype. +- **Python: `metaobjects fmt` reads the configured providers, and `--provider` adds to them.** + `fmt` ignored `providers:` in `metaobjects.config.yaml` (it also gains `--config`), and + `metaobjects verify --provider` replaced the configured list instead of adding to it. - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body names the dropped column now blocks the `drop-column`, and the message names the view and the @@ -48,8 +57,30 @@ here.** new command. `examples/brownfield/` holds a runnable project for each, and the recipe ships in the `metaobjects-authoring` skill. +- **A project's own provider class, wired in every port's config, loaded by every command.** A + project that declares its own vocabulary (a new subtype, or attributes on a core type) writes a + provider class in each language whose tools load the metadata, and names it once: + `defineConfig({ providers })` in TypeScript, `providers: ["module:symbol"]` in + `metaobjects.config.yaml` for Python, the new `csharp.providers: [{ "assembly", "type" }]` list + in `.metaobjects/config.json` for C#, and for Java and Kotlin the new Maven plugin + `FQCN` list, `MetaDataTypeProvider` beans composed by + the Spring auto-configured loader, `LoaderOptions.setProviders(...)`, and a `providers` + parameter on the `metadata-ktx` loader. C#, Java and Kotlin had no project-level way to do this + before; `META-INF/services` still does not add a provider to the JVM loader. Two new conformance + fixtures, `provider-custom-vocabulary-success` and + `provider-custom-vocabulary-missing-provider-fails`, hold every port to loading a project + subtype plus attributes with its provider and refusing the same file without it. + `docs/features/extending-with-providers.md` now has a worked example per language, and the + `metaobjects-authoring` skill teaches it, with a reference file per language. + ### Changed +- **Python and Java report a missing provider's errors in the same order as the other ports.** + When a file holds both an unknown subtype and unknown attributes, Python used to drop the whole + file at the unknown subtype and never report the attributes; it now reports + `ERR_UNKNOWN_SUBTYPE` and then each `ERR_UNKNOWN_ATTR`. Java reported the attribute errors + before the subtype error; it now reports them after it, as TypeScript and C# do. A load that + failed still fails; only the list of errors differs. - **A prompt or output template that names a variable not on its payload now raises at render time.** Behaviour change: a generated render helper used to render `Hi {{nmae}}` as `Hi ` with no error when the payload declares `name`; it now raises `ERR_VAR_NOT_ON_PAYLOAD (nmae)` (`RenderError` in diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/agent-context/skills/metaobjects-authoring/references/choosing-a-shape.md b/agent-context/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/agent-context/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/agent-context/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/agent-context/skills/metaobjects-authoring/references/csharp-providers.md b/agent-context/skills/metaobjects-authoring/references/csharp-providers.md new file mode 100644 index 0000000000..3b4305b773 --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/csharp-providers.md @@ -0,0 +1,85 @@ +# Your own vocabulary in C# — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its C# tools load the metadata. + +A provider is a public class implementing `IMetaDataTypeProvider`, with a public parameterless +constructor: an `Id`, the provider ids it depends on, and a `RegisterTypes(registry)` body that +calls `Register` (a new subtype) and `Extend` (attributes on an existing one). Put it in its +own class library that references the `MetaObjects` package at the version your `dotnet meta` +tool runs. This one registers `layout.picker` and adds `@searchable` and `@valueLabels` to +`field.string`: + +```csharp +// vocabulary/AcmeVocabularyProvider.cs (vocabulary/Acme.Vocabulary.csproj: net8.0 class +// library with ) +using MetaObjects; +using MetaObjects.Meta; +using MetaObjects.Shared; + +namespace Acme.Vocabulary; + +public sealed class AcmeVocabularyProvider : IMetaDataTypeProvider +{ + public string Id => "acme-vocabulary"; + public IReadOnlyList Dependencies => ["metaobjects-core-types"]; + + public void RegisterTypes(TypeRegistry registry) + { + // A new subtype, with its own attribute (an own attribute may be required). + registry.Register(new TypeDefinition( + typeId: new TypeId("layout", "picker"), + description: "A picker over the owning object.", + childRules: [new ChildRule(BaseTypes.TYPE_ATTR, Structural.CHILD_RULE_WILDCARD, Structural.CHILD_RULE_WILDCARD)], + factory: (typeId, name) => new MetaLayout(typeId, name), + attributes: [new AttrSchema("displayField", "string", Required: false)])); + + // Attributes on a type another provider owns: always optional. + registry.Extend("field", "string", attributes: + [ + new AttrSchema("searchable", "boolean", Required: false), + new AttrSchema("valueLabels", "string", Required: false, IsArray: true), + ]); + } +} +``` + +Wire it once, in `.metaobjects/config.json`. `assembly` is the BUILT dll, relative to the +directory holding `.metaobjects/`; `type` is the provider's full type name: + +```json +{ + "schema_version": 1, + "csharp": { + "providers": [ + { "assembly": "vocabulary/bin/Debug/net8.0/Acme.Vocabulary.dll", "type": "Acme.Vocabulary.AcmeVocabularyProvider" } + ] + } +} +``` + +Build the vocabulary first (`dotnet build vocabulary`); a missing dll is refused with the path +and that instruction. Then `dotnet meta gen` and `verify --codegen` (through the project's +`codegen/` runner), `dotnet meta verify` (templates, requirements, field lint) and +`dotnet meta fmt` all compose the core providers plus this one. The `codegen/` project may +also reference the vocabulary project directly; the already-loaded assembly is reused. + +An application host that loads the model itself composes the registry in code: + +```csharp +var registry = Provider.ComposeRegistry([.. CoreTypes.LibraryProviders, new AcmeVocabularyProvider()]); +var load = MetaDataLoader.FromDirectory("metaobjects", registry, strict: true); +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `dotnet meta verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same id, same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/agent-context/skills/metaobjects-authoring/references/java-providers.md b/agent-context/skills/metaobjects-authoring/references/java-providers.md new file mode 100644 index 0000000000..70455cb22e --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/java-providers.md @@ -0,0 +1,93 @@ +# Your own vocabulary in Java — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Java tools load the metadata. + +A provider is a public class implementing `com.metaobjects.registry.MetaDataTypeProvider`, with +a public no-arg constructor: a provider id, the provider ids it depends on (the JVM ids: +`field-types` registers the field types, `layout-types` the layouts), and a `registerTypes` +body that calls `registerType` (a new subtype, backed by its own node class) and `extendType` +(attributes on an existing one). This one registers `layout.picker` and adds `@searchable` and +`@valueLabels` to `field.string`: + +```java +package com.acme.vocab; + +import com.metaobjects.attr.BooleanAttribute; +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.field.StringField; +import com.metaobjects.layout.MetaLayout; +import com.metaobjects.registry.MetaDataRegistry; +import com.metaobjects.registry.MetaDataTypeProvider; + +public final class AcmeVocabulary implements MetaDataTypeProvider { + /** The node class behind layout.picker. */ + public static final class PickerLayout extends MetaLayout { + public PickerLayout(String name) { super("picker", name); } + } + + @Override public String getProviderId() { return "acme-vocabulary"; } + @Override public String[] getDependencies() { return new String[] {"field-types", "layout-types"}; } + + @Override public void registerTypes(MetaDataRegistry registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.registerType(PickerLayout.class, def -> { + def.type("layout").subType("picker").inheritsFrom("layout", "base"); + def.optionalAttributeWithConstraints("displayField").ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + // Attributes on a type another provider owns: always optional. + registry.extendType(StringField.class, def -> { + def.optionalAttributeWithConstraints("searchable").ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); + def.optionalAttributeWithConstraints("valueLabels").ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); + } +} +``` + +Wire it once, in the `metaobjects-maven-plugin` configuration. The class must be on the +plugin's classpath: put the provider in its own module and add that module as a project +dependency or under the plugin's ``. A provider in the same module is not +compiled yet when `generate` runs at `generate-sources`. + +```xml + + com.metaobjects + metaobjects-maven-plugin + + + com.acme.vocab.AcmeVocabulary + + + + com.acmeacme-vocabulary1.0 + + +``` + +`metaobjects:generate` (with its docs generators), `metaobjects:verify`, `metaobjects:fmt` and +`metaobjects:editor` all compose the metamodel providers plus these. Listing a class in +`META-INF/services` does NOT add it to the loader: the strict loader composes a fixed +metamodel set plus the providers you name, so a jar on the classpath cannot change the +vocabulary by accident. + +In a Spring Boot app, declare the provider as a bean and the auto-configured loader composes +it: `@Bean MetaDataTypeProvider acmeVocabulary() { return new AcmeVocabulary(); }`. Code that +loads the model itself passes it on the options: + +```java +MetaDataLoader.fromUris("app", uris, + LoaderOptions.create(false, false, true).setProviders(List.of(new AcmeVocabulary()))); +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects:verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (the same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/agent-context/skills/metaobjects-authoring/references/kotlin-providers.md b/agent-context/skills/metaobjects-authoring/references/kotlin-providers.md new file mode 100644 index 0000000000..7eba7c3004 --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/kotlin-providers.md @@ -0,0 +1,77 @@ +# Your own vocabulary in Kotlin — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Kotlin tools load the +> metadata. + +Kotlin runs on the JVM registry, so a provider is a class implementing +`com.metaobjects.registry.MetaDataTypeProvider`, written in Kotlin: a provider id, the JVM +provider ids it depends on (`field-types`, `layout-types`), and a `registerTypes` body that +calls `registerType` (a new subtype, backed by its own node class) and `extendType` +(attributes on an existing one). This one registers `layout.picker` and adds `@searchable` and +`@valueLabels` to `field.string`: + +```kotlin +package com.acme.vocab + +import com.metaobjects.field.StringField +import com.metaobjects.layout.MetaLayout +import com.metaobjects.registry.MetaDataRegistry +import com.metaobjects.registry.MetaDataTypeProvider + +/** The node class behind layout.picker. */ +class PickerLayout(name: String) : MetaLayout("picker", name) + +class AcmeVocabulary : MetaDataTypeProvider { + override fun getProviderId() = "acme-vocabulary" + override fun getDependencies() = arrayOf("field-types", "layout-types") + + override fun registerTypes(registry: MetaDataRegistry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.registerType(PickerLayout::class.java) { def -> + def.type("layout").subType("picker").inheritsFrom("layout", "base") + def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle() + } + // Attributes on a type another provider owns: always optional. + registry.extendType(StringField::class.java) { def -> + def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle() + def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray() + } + } +} +``` + +Wire it once. Kotlin generators run through `metaobjects:generate`, so the build lists it in +the `metaobjects-maven-plugin` configuration, with the provider's module on the plugin's +classpath (a project dependency or the plugin's ``; a provider in the same +module is not compiled yet at `generate-sources`): + +```xml + + + com.acme.vocab.AcmeVocabulary + + +``` + +`generate`, `verify`, `fmt` and `editor` then all load it. `META-INF/services` does not add a +provider to the loader. In a Spring Boot app, a `MetaDataTypeProvider` bean is composed into +the auto-configured loader. Code that loads the model through the `metadata-ktx` facade passes +it directly: + +```kotlin +val result = loadUris("app", uris, LoaderOptions.create(false, false, true), providers = listOf(AcmeVocabulary())) +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects:verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (the same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/agent-context/skills/metaobjects-authoring/references/python-providers.md b/agent-context/skills/metaobjects-authoring/references/python-providers.md new file mode 100644 index 0000000000..36097ad436 --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/python-providers.md @@ -0,0 +1,69 @@ +# Your own vocabulary in Python — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Python tools load the +> metadata. + +A provider is a `metaobjects.provider.Provider`: an id, the provider ids it depends on, the +`TypeDefinition`s it adds (a new subtype), and an `on_register` hook that `extend`s existing +types. This one registers `layout.picker` and adds `@searchable` and `@valueLabels` to +`field.string`: + +```python +# acme_vocab.py (beside metaobjects.config.yaml) +from metaobjects.meta.presentation.layout.meta_layout import MetaLayout +from metaobjects.provider import Provider +from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition +from metaobjects.shared.base_types import TYPE_ATTR + +provider = Provider("acme-vocabulary", dependencies=("metaobjects-core-types",)) + +# A new subtype, with its own attribute (an own attribute may be required). +provider.add(TypeDefinition( + type="layout", sub_type="picker", factory=MetaLayout, + attrs=[AttrSchema("displayField", "string", required=False)], + child_rules=[ChildRule(TYPE_ATTR, "*")], +)) + +# Attributes on a type another provider owns: always optional. +provider.on_register(lambda registry: registry.extend( + "field", "string", + attributes=[ + AttrSchema("searchable", "boolean", required=False), + AttrSchema("valueLabels", "string", required=False, is_array=True), + ], +)) +``` + +Wire it once, in `metaobjects.config.yaml`, as `module:symbol`. The module resolves relative +to the config's directory, so no `PYTHONPATH` is needed: + +```yaml +metadata: metaobjects +providers: ["acme_vocab:provider"] +``` + +`metaobjects gen` (with its docs generators), every `metaobjects verify` mode and +`metaobjects fmt` read that list; `--provider module:symbol` adds to it for one run. Code that +loads the model itself passes the full list, core first, because `providers=` is exactly the +set composed: + +```python +from metaobjects.core_types import core_providers +from metaobjects.loader.meta_data_loader import MetaDataLoader + +result = MetaDataLoader.from_directory("metaobjects", providers=[*core_providers, provider], strict=True) +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same id, same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/agent-context/skills/metaobjects-authoring/references/typescript-providers.md b/agent-context/skills/metaobjects-authoring/references/typescript-providers.md new file mode 100644 index 0000000000..7546bd3b50 --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/typescript-providers.md @@ -0,0 +1,74 @@ +# Your own vocabulary in TypeScript — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its TypeScript tools load the +> metadata. + +A provider is an object implementing `MetaDataTypeProvider`: an `id`, the provider ids it +depends on, and a `registerTypes(registry)` body that calls `register` (a new subtype) and +`extend` (attributes on an existing one). This one registers `layout.picker` and adds +`@searchable` and `@valueLabels` to `field.string`: + +```ts +// codegen/vocabulary.ts +import { TypeId, MetaLayout, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { + TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, + ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN, +} from "@metaobjectsdev/metadata/constants"; + +export const vocabulary: MetaDataTypeProvider = { + id: "acme-vocabulary", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.register({ + typeId: new TypeId(TYPE_LAYOUT, "picker"), + description: "A picker over the owning object.", + factory: (typeId, name) => new MetaLayout(typeId, name), + childRules: [], + attributes: [ + { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }, + ], + }); + // Attributes on a type another provider owns: always optional. + registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, { + attributes: [ + { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." }, + { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." }, + ], + }); + }, +}; +``` + +Wire it once, in `metaobjects.config.ts`: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { vocabulary } from "./codegen/vocabulary"; + +export default defineConfig({ + generators: [/* ... */], + providers: [vocabulary], +}); +``` + +Every `meta` command that loads metadata reads that list: `gen` (with the docs generators and +the docs site), `verify`, `migrate`, `fmt`, `types`, `export`, `deps`, `upgrade`. `meta types +picker` then lists the new subtype. A config that fails to load is reported as its own error, +never as an unknown subtype. Code that loads the model itself passes the same provider: +`loadMemory(root, { providers: [vocabulary] })`. + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `meta verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same `id`, same vocabulary). +Full contract: `docs/features/extending-with-providers.md`. diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d7e9157567..a370165dc7 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 423 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 425 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -285,7 +285,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (423) +### `fixtures/conformance/` — metamodel loader + canonical serializer (425) | Fixture prefix | Feature doc | |---|---| @@ -309,6 +309,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. | `projection-*`, `error-projection-*`, `field-readonly-on-view-projection` | [features/source-kinds.md](features/source-kinds.md) (projections + the object taxonomy, ADR-0028) | | `requirement-*`, `error-unknown-attr-requirement` | [features/requirements.md](features/requirements.md) (vocabulary only — the gate has its own corpus, `requirement-check-conformance/`; see "Split coverage" above) | | `reporting-*`, `error-dimension-*`, `error-measure-*`, `error-ratio-*`, `error-segment-*`, `error-report-*`, `error-relative-date-*` | [features/reporting.md](features/reporting.md) (the `dimension` / `measure` / `segment` / `object.report` vocabulary and its load-time rules; design in the [FR-044 spec](superpowers/specs/2026-10-02-fr-044-core-reporting-design.md)) | +| `provider-extension-*`, `provider-custom-vocabulary-*` | [features/extending-with-providers.md](features/extending-with-providers.md) (a project's own provider: a new subtype plus attributes on an existing type load with it and are refused without it, every port writing the provider in its own language) | | `smoke-empty-metadata` | [features/entities.md](features/entities.md) | ### `fixtures/yaml-conformance/` (16) @@ -611,7 +612,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 423 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 425 + yaml 16 + verify 45 + render 18 + persistence 59 + api-contract 119 + source-resolution 25 + scope 10 + dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance` and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/extending-with-providers.md b/docs/features/extending-with-providers.md index 8fb3f3208e..be13b96183 100644 --- a/docs/features/extending-with-providers.md +++ b/docs/features/extending-with-providers.md @@ -11,6 +11,8 @@ This page covers: - The provider contract (what a provider declares) - How to register a new subtype or extend an existing one +- How to wire a project's own provider into each port (TypeScript, Python, C#, Java, + Kotlin), with a worked example per language, and which commands load it - The cross-port semantic rules (explicit providers bypass ambient discovery) - Stable error codes for composition failures @@ -176,52 +178,294 @@ provider. This is conformance-gated across all five ports (the ## Wiring providers into the loader -Each port has its own loader entry point. The contract is identical: -**provide an explicit list of providers, and the loader composes them in -dependency order, skipping any ambient discovery.** +A provider is code, so it lives in the language of the tools that load the metadata, and it +is wired in that port's own config. Each port then loads it at **every** entry point that +reads metadata, not only codegen. The contract is the same everywhere: + +> **The loader composes the core vocabulary plus exactly the providers you name, in +> dependency order.** Nothing is discovered from the classpath, the bundler's import order or +> an installed package. The same provider list and the same metadata give the same registry. + +**Every port whose tools load the metadata needs its own copy of the provider.** A provider +registered only in TypeScript is invisible to the Java build, the Python CLI or the C# tool, +and they refuse the same file with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Give each copy +the same `id` and the same vocabulary; the shared conformance fixtures +`provider-custom-vocabulary-success` and `provider-custom-vocabulary-missing-provider-fails` +hold every port to that. + +The examples below all add the same vocabulary: a new `layout.picker` subtype with its own +`@displayField` attribute, and two optional attributes on the core `field.string`, +`@searchable` (boolean) and `@valueLabels` (string array). The metadata then uses it like +core vocabulary: + + +```json +{ "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Contact", "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "field.string": { "name": "status", "@valueLabels": ["Active", "On hold"] } }, + { "layout.picker": { "name": "contactPicker", "@displayField": "name" } } + ]}} +]}} +``` + +The agent-context skill carries the same example per language +(`agent-context/skills/metaobjects-authoring/references/-providers.md`). + +### TypeScript ```ts -// TypeScript -import { loadMemory } from "@metaobjectsdev/sdk"; -import { exampleToolcallProvider } from "./codegen/providers"; +// codegen/vocabulary.ts +import { TypeId, MetaLayout, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { + TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, + ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN, +} from "@metaobjectsdev/metadata/constants"; -const root = await loadMemory("./", { - providers: [exampleToolcallProvider], // composed AFTER core providers -}); +export const vocabulary: MetaDataTypeProvider = { + id: "acme-vocabulary", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + registry.register({ + typeId: new TypeId(TYPE_LAYOUT, "picker"), + description: "A picker over the owning object.", + factory: (typeId, name) => new MetaLayout(typeId, name), + childRules: [], + attributes: [ + { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }, + ], + }); + registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, { + attributes: [ + { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." }, + { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." }, + ], + }); + }, +}; ``` -```csharp -// C# -var loader = MetaDataLoader.FromDirectory("./metadata", registry); -// where `registry` is built via Provider.ComposeRegistry(providers) +Wire it in `metaobjects.config.ts`: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { vocabulary } from "./codegen/vocabulary"; + +export default defineConfig({ generators: [/* ... */], providers: [vocabulary] }); ``` +**Loaded by:** `meta gen` (including the docs generators and the docs site), `meta verify` +(every mode), `meta migrate`, `meta fmt`, `meta types`, `meta export`, `meta deps`, +`meta upgrade` and `meta prompt-snapshot`. A config that fails to load is reported as its own +error, never misreported as an unknown subtype. Programmatic loads pass the list: +`loadMemory(root, { providers: [vocabulary] })` composes the default core set plus yours; +`replaceDefaults: true` composes yours alone (rare). + +### Python + ```python -# Python -loader = MetaDataLoader.from_directory("./metadata", providers=[example_toolcall_provider]) +# acme_vocab.py (beside metaobjects.config.yaml) +from metaobjects.meta.presentation.layout.meta_layout import MetaLayout +from metaobjects.provider import Provider +from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition +from metaobjects.shared.base_types import TYPE_ATTR + +provider = Provider("acme-vocabulary", dependencies=("metaobjects-core-types",)) +provider.add(TypeDefinition( + type="layout", sub_type="picker", factory=MetaLayout, + attrs=[AttrSchema("displayField", "string", required=False)], + child_rules=[ChildRule(TYPE_ATTR, "*")], +)) +provider.on_register(lambda registry: registry.extend( + "field", "string", + attributes=[ + AttrSchema("searchable", "boolean", required=False), + AttrSchema("valueLabels", "string", required=False, is_array=True), + ], +)) ``` +Wire it in `metaobjects.config.yaml` as `module:symbol`; the module resolves relative to the +config's directory: + +```yaml +metadata: metaobjects +providers: ["acme_vocab:provider"] +``` + +**Loaded by:** `metaobjects gen` (including the docs generators), every `metaobjects verify` +mode and `metaobjects fmt`. `--provider module:symbol` adds to the configured list for one +run. Programmatic loads pass the full set, core first, because `providers=` is exactly what +is composed: +`MetaDataLoader.from_directory("metaobjects", providers=[*core_providers, provider], strict=True)` +(`core_providers` from `metaobjects.core_types`). + +### C# + +```csharp +// vocabulary/AcmeVocabularyProvider.cs, in a class library referencing the MetaObjects +// package at the version your `dotnet meta` tool runs. +using MetaObjects; +using MetaObjects.Meta; +using MetaObjects.Shared; + +namespace Acme.Vocabulary; + +public sealed class AcmeVocabularyProvider : IMetaDataTypeProvider +{ + public string Id => "acme-vocabulary"; + public IReadOnlyList Dependencies => ["metaobjects-core-types"]; + + public void RegisterTypes(TypeRegistry registry) + { + registry.Register(new TypeDefinition( + typeId: new TypeId("layout", "picker"), + description: "A picker over the owning object.", + childRules: [new ChildRule(BaseTypes.TYPE_ATTR, Structural.CHILD_RULE_WILDCARD, Structural.CHILD_RULE_WILDCARD)], + factory: (typeId, name) => new MetaLayout(typeId, name), + attributes: [new AttrSchema("displayField", "string", Required: false)])); + + registry.Extend("field", "string", attributes: + [ + new AttrSchema("searchable", "boolean", Required: false), + new AttrSchema("valueLabels", "string", Required: false, IsArray: true), + ]); + } +} +``` + +Wire it in `.metaobjects/config.json`. `assembly` is the built dll, relative to the directory +holding `.metaobjects/`; `type` is the provider's full type name, and the class needs a public +parameterless constructor: + +```json +{ + "schema_version": 1, + "csharp": { + "providers": [ + { "assembly": "vocabulary/bin/Debug/net8.0/Acme.Vocabulary.dll", "type": "Acme.Vocabulary.AcmeVocabularyProvider" } + ] + } +} +``` + +**Loaded by:** `dotnet meta gen` and `verify --codegen` (through the project's `codegen/` +runner), `dotnet meta verify` (templates, requirements, field lint) and `dotnet meta fmt`. +Build the vocabulary first; a missing dll, a missing type or a type that is not a provider is +refused with an error naming `csharp.providers`. If the `codegen/` project also references +the vocabulary project, the already-loaded assembly is reused. Programmatic loads compose the +registry themselves, and `ComposeRegistry` takes exactly the set given: +`Provider.ComposeRegistry([.. CoreTypes.LibraryProviders, new AcmeVocabularyProvider()])`, +then `MetaDataLoader.FromDirectory(dir, registry, strict: true)`. + +### Java + ```java -// Java — SPI auto-discovery is the default; META-INF/services lists every provider -// (no API call required). When a consumer genuinely needs extra vocabulary -// composed with the full metamodel provider set — so its metadata still -// strict-loads against the spec contract instead of getting the weaker -// classpath-SPI registry — the sanctioned seam is: -loader.setTypeRegistry(RegistryManifest.composeMetamodelRegistry(List.of(myProvider))); -// Calling MetaDataRegistry.compose(...) directly does NOT run spec scoping — -// use composeMetamodelRegistry(extras), not a raw compose() call. +package com.acme.vocab; + +import com.metaobjects.attr.BooleanAttribute; +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.field.StringField; +import com.metaobjects.layout.MetaLayout; +import com.metaobjects.registry.MetaDataRegistry; +import com.metaobjects.registry.MetaDataTypeProvider; + +public final class AcmeVocabulary implements MetaDataTypeProvider { + /** The node class behind layout.picker. */ + public static final class PickerLayout extends MetaLayout { + public PickerLayout(String name) { super("picker", name); } + } + + @Override public String getProviderId() { return "acme-vocabulary"; } + @Override public String[] getDependencies() { return new String[] {"field-types", "layout-types"}; } + + @Override public void registerTypes(MetaDataRegistry registry) { + registry.registerType(PickerLayout.class, def -> { + def.type("layout").subType("picker").inheritsFrom("layout", "base"); + def.optionalAttributeWithConstraints("displayField").ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + registry.extendType(StringField.class, def -> { + def.optionalAttributeWithConstraints("searchable").ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); + def.optionalAttributeWithConstraints("valueLabels").ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); + } +} ``` -The semantic rule is identical across ports: +The JVM provider ids differ from the other ports': depend on `field-types` and +`layout-types` (the providers that register those types), not `metaobjects-core-types`. + +Wire it in the `metaobjects-maven-plugin` configuration. The class must be on the plugin's +classpath, so put it in its own module and add that module as a project dependency or under +the plugin's ``; a provider in the same module is not compiled yet when +`generate` runs at `generate-sources`. + +```xml + + com.metaobjects + metaobjects-maven-plugin + + + com.acme.vocab.AcmeVocabulary + + + + com.acmeacme-vocabulary1.0 + + +``` -> **Explicit `providers` declarations bypass any ambient discovery.** The -> set you pass is exactly the set the loader composes. This guarantees -> determinism — same providers list + same metadata → same registry, no -> classpath order surprises, no bundler import-order surprises. +**Loaded by:** `metaobjects:generate` (including the docs generators), `metaobjects:verify`, +`metaobjects:fmt` and `metaobjects:editor` (`-Dmeta.providers=...` on the command line works +too). A Spring Boot app declares the provider as a bean, and the auto-configured loader +composes every `MetaDataTypeProvider` bean: +`@Bean MetaDataTypeProvider acmeVocabulary() { return new AcmeVocabulary(); }`. +Programmatic loads pass it on the options: +`LoaderOptions.create(false, false, true).setProviders(List.of(new AcmeVocabulary()))`. +The loader composes the full metamodel set plus these +(`RegistryManifest.composeLoaderRegistry(extras)`). + +**Listing a class in `META-INF/services` does not add it to the loader.** The strict loader +composes a fixed metamodel set plus the providers you name, so a jar on the classpath cannot +change the vocabulary by accident. + +### Kotlin + +Kotlin runs on the JVM registry, so the provider is the same interface written in Kotlin, and +it is wired the same way: the Maven plugin's `` list (Kotlin generators run +through `metaobjects:generate`), a Spring bean, or `providers = listOf(...)` on the +`metadata-ktx` loader: + +```kotlin +class PickerLayout(name: String) : MetaLayout("picker", name) + +class AcmeVocabulary : MetaDataTypeProvider { + override fun getProviderId() = "acme-vocabulary" + override fun getDependencies() = arrayOf("field-types", "layout-types") + + override fun registerTypes(registry: MetaDataRegistry) { + registry.registerType(PickerLayout::class.java) { def -> + def.type("layout").subType("picker").inheritsFrom("layout", "base") + def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle() + } + registry.extendType(StringField::class.java) { def -> + def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle() + def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray() + } + } +} -Every port's loader defaults to `[...coreProviders, ...callerProviders]` when the -caller supplies `providers`. Pass `{ replaceDefaults: true }` (TS) or the per-port -equivalent to skip the defaults entirely — rare, but supported. +val result = loadUris("app", uris, LoaderOptions.create(false, false, true), providers = listOf(AcmeVocabulary())) +``` + +### Without the provider + +The same file, loaded strictly without the provider, fails with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on each of `@searchable` and `@valueLabels`, in that +order, in every port. A lax load accepts the unknown attributes but still refuses the unknown +subtype. + +### The TypeScript forge provider **`forgeTypesProvider` is TypeScript-only, and is no longer in any default composition.** It was listed here as part of the default composition for "the TS / C# / @@ -249,7 +493,8 @@ Composition surfaces these codes consistently across all ports (see | Code | When | Conformance fixture | |---|---|---| | `ERR_UNKNOWN_TYPE` | YAML/JSON references a `type` not registered by any provider — including a BARE wrapper key naming an unregistered type, which is an unknown type and not a missing subType | `error-unknown-bare-child-type` | -| `ERR_UNKNOWN_SUBTYPE` | `type` is known but `subType` is not | `provider-extension-missing-provider-fails` | +| `ERR_UNKNOWN_SUBTYPE` | `type` is known but `subType` is not | `provider-extension-missing-provider-fails`, `provider-custom-vocabulary-missing-provider-fails` | +| `ERR_UNKNOWN_ATTR` | A strict load meets an attribute no provider declared on that `(type, subType)` | `provider-custom-vocabulary-missing-provider-fails` | | `ERR_CHILD_NOT_OBJECT` | A `children` entry's body is not an object, so there is no node to read — checked AFTER the wrapper key resolves | `error-child-not-object` | | `ERR_PROVIDER_DUPLICATE_ID` | Two provider objects report the same `id` | `provider-extension-duplicate-id` | | `ERR_PROVIDER_MISSING_DEPENDENCY` | Provider declares dep on an unregistered id | `provider-extension-missing-dependency` | @@ -257,9 +502,13 @@ Composition surfaces these codes consistently across all ports (see | `ERR_PROVIDER_ATTR_CONFLICT` | Two providers declare the same attr on the same `(type, subType)` | (covered by negative tests in core) | Each conformance fixture's `providers.json: string[]` lists the provider ids to -compose for that test — the same wire format consumers use at runtime. The -fixtures are run in every port (TS / C# / Python today; Java SPI version -forthcoming), so any future port automatically inherits the contract. +compose for that test — the same wire format consumers use at runtime. Each +port's conformance adapter maps those ids to its own provider objects, and the +fixtures run in every port (TypeScript, C#, Java, Kotlin, Python), so any future +port inherits the contract. `provider-custom-vocabulary-success` loads a +project-defined subtype plus attributes on a core type with the provider; +`provider-custom-vocabulary-missing-provider-fails` refuses the same file +without it. ## When to write a provider vs. inline metadata @@ -384,15 +633,14 @@ overlay semantics, common patterns). core vocabulary.** Example: `source.rdb` is a persistence-paradigm split — there will eventually be `source.kv`, `source.graph`, etc., and they all need first-class subtype identity for cross-port codegen routing. -- **You need load-time error detection when the provider is missing.** The - loader treats undeclared `@-attrs` as **open policy** — they're silently - accepted, no error or warning (`attr-schema-validate.ts:15`). Only the - *subtype* itself is validated against the registry. So if your concept's - correctness depends on the provider being loaded (e.g., a custom generator - must run when the metadata is present), subtype registration is the only - mechanism that catches "consumer forgot to wire the provider" at load - time via `ERR_UNKNOWN_SUBTYPE`. With pure attr-extension, missing - providers silently no-op. +- **You need load-time error detection even in a lax load.** A strict load + (the default for every tool) refuses an undeclared `@-attr` with + `ERR_UNKNOWN_ATTR`, so a missing provider is caught either way. A lax load + (`--lax`, `strict: false`) accepts undeclared attrs and refuses only an + unknown *subtype*. So if your concept's correctness depends on the provider + being loaded even where a project loads laxly, subtype registration is the + mechanism that still catches "consumer forgot to wire the provider", via + `ERR_UNKNOWN_SUBTYPE`. If the only argument for a new subtype is "the name reads nicer," add an attr instead. A subtype is a permanent commitment in every port's registry; @@ -419,13 +667,13 @@ configuration rich. ## Cross-port parity status -| Port | API | Status | -|---|---|---| -| TypeScript | `loadMemory(repoRoot, { providers })` + `defineConfig({ providers })` | Shipped | -| C# | `Provider.ComposeRegistry(providers)` → `MetaDataLoader.FromDirectory(dir, registry)` | Shipped | -| Python | `MetaDataLoader.from_directory(dir, providers=...)` | Shipped | -| Java | SPI via `META-INF/services/com.metaobjects.registry.MetaDataTypeProvider` | Shipped (SPI auto-discovery; programmatic `compose()` factory is a follow-up) | -| Kotlin | Inherits Java SPI through `metadata-ktx` | Shipped | +| Port | Project config | Programmatic | Status | +|---|---|---|---| +| TypeScript | `defineConfig({ providers })` in `metaobjects.config.ts` | `loadMemory(root, { providers })` | Shipped | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml`, plus `--provider` | `MetaDataLoader.from_directory(dir, providers=[*core_providers, ...])` | Shipped | +| C# | `csharp.providers: [{ assembly, type }]` in `.metaobjects/config.json` | `Provider.ComposeRegistry(...)` → `MetaDataLoader.FromDirectory(dir, registry)` | Shipped | +| Java | Maven plugin ``; Spring `MetaDataTypeProvider` beans | `LoaderOptions.setProviders(...)` | Shipped | +| Kotlin | Maven plugin ``; Spring beans | `metadata-ktx` `loadUris(..., providers = listOf(...))` | Shipped | All five ports compose providers in **dependency order** (Kahn's algorithm) and emit the same stable error codes when composition fails. @@ -466,10 +714,10 @@ root-level), so you do **not** hand-walk the tree in a `validate` hook. ## See also - [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) — hands-on walkthrough -- [`../ports/typescript.md`](../ports/typescript.md), [`../ports/csharp.md`](../ports/csharp.md), [`../ports/python.md`](../ports/python.md), [`../ports/java.md`](../ports/java.md) — per-port quickstarts +- [`../ports/typescript.md`](../ports/typescript.md), [`../ports/csharp.md`](../ports/csharp.md), [`../ports/python.md`](../ports/python.md), [`../ports/java.md`](../ports/java.md), [`../ports/kotlin.md`](../ports/kotlin.md) — per-port quickstarts - [`templates-and-payloads.md`](templates-and-payloads.md) — the core `template.prompt` + `template.output` subtypes that a custom `template.toolcall` slots alongside - [`source-kinds.md`](source-kinds.md) — the `source.rdb` example of `extend`-style provider use (adds `@table`, `@kind`, `@role`, `@schema` to a core subtype) -- [`../../fixtures/conformance/`](../../fixtures/conformance/) — `provider-extension-*` fixtures +- [`../../fixtures/conformance/`](../../fixtures/conformance/) — `provider-extension-*` and `provider-custom-vocabulary-*` fixtures - [`../../fixtures/conformance/ERROR-CODES.json`](../../fixtures/conformance/ERROR-CODES.json) — stable error code dictionary diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 01a0b3687e..02f28069b3 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -120,27 +120,40 @@ does and does not do is in ### Custom providers (optional) -If your app needs a metamodel subtype the core doesn't ship, declare an -`IMetaDataTypeProvider` and compose it into the registry before loading: +When the project needs a subtype or an attribute the core does not ship, write a public class +implementing `IMetaDataTypeProvider` (with a public parameterless constructor) in its own +class library, and list the built assembly in `.metaobjects/config.json`: -```csharp -using MetaObjects; -using MetaObjects.Loader; +```json +{ + "schema_version": 1, + "csharp": { + "providers": [ + { "assembly": "vocabulary/bin/Debug/net8.0/Acme.Vocabulary.dll", "type": "Acme.Vocabulary.AcmeVocabularyProvider" } + ] + } +} +``` -var registry = Provider.ComposeRegistry(new IMetaDataTypeProvider[] { - CoreTypes.CoreTypesProvider, - yourProvider, // adds your custom subtype/attrs -}); +`assembly` is relative to the directory holding `.metaobjects/`. Build the vocabulary first; +a missing dll, a missing type or a type that is not a provider is refused with an error +naming `csharp.providers`. `dotnet meta gen` and `verify --codegen` (through the `codegen/` +runner), `dotnet meta verify` and `dotnet meta fmt` then all compose the core providers plus +these. An application that loads the model itself composes the registry, and +`ComposeRegistry` takes exactly the set it is given: -var loader = MetaDataLoader.FromDirectory("./metaobjects", registry); +```csharp +var registry = Provider.ComposeRegistry([.. CoreTypes.LibraryProviders, new AcmeVocabularyProvider()]); +var load = MetaDataLoader.FromDirectory("./metaobjects", registry, strict: true); ``` -The provider object has the same four-member contract (`Id`, `Dependencies`, -`Description`, `RegisterTypes(registry)`) as TS / Python. Composition errors -surface `ERR_PROVIDER_DUPLICATE_ID`, `ERR_PROVIDER_MISSING_DEPENDENCY`, -`ERR_PROVIDER_DEPENDENCY_CYCLE` — codes match the cross-port contract. See -[`../features/extending-with-providers.md`](../features/extending-with-providers.md) -for the full reference and +The provider object has the same contract (`Id`, `Dependencies`, `RegisterTypes(registry)` +calling `Register` and `Extend`) as the other ports. Composition errors surface +`ERR_PROVIDER_DUPLICATE_ID`, `ERR_PROVIDER_MISSING_DEPENDENCY` and +`ERR_PROVIDER_DEPENDENCY_CYCLE`. If another port's tools load the same metadata, that port needs its own copy of the +provider (same id, same vocabulary); a provider registered in one language is invisible to +the others. See [`../features/extending-with-providers.md`](../features/extending-with-providers.md) for the full reference with a worked +`AcmeVocabularyProvider`, and [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) for a worked example. diff --git a/docs/ports/java.md b/docs/ports/java.md index 6e594802b9..05c15aef69 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -209,50 +209,61 @@ auto-load of the snapshot and `verify` drift for dependencies are separate, late ### Custom providers (optional) -Java uses **SPI auto-discovery** for type providers — drop your provider -class on the classpath, list its FQCN in -`META-INF/services/com.metaobjects.registry.MetaDataTypeProvider`, and -`MetaDataRegistry.getInstance()` will compose it in dependency order -alongside the core providers: +When the project needs a subtype or an attribute the core does not ship, write a class +implementing `com.metaobjects.registry.MetaDataTypeProvider` (public, with a public no-arg +constructor) and name it explicitly. The loader composes the full metamodel provider set plus +exactly the providers you name; **listing a class in `META-INF/services` does not add it to +the loader**, so a jar on the classpath cannot change the vocabulary by accident. ```java -// src/main/java/com/example/providers/ExampleToolcallProvider.java -package com.example.providers; +package com.acme.vocab; -import com.metaobjects.registry.MetaDataTypeProvider; -import com.metaobjects.registry.MetaDataRegistry; - -public class ExampleToolcallProvider implements MetaDataTypeProvider { - @Override public String getProviderId() { return "example-template-toolcall"; } - @Override public String[] getDependencies() { return new String[] { "core-types" }; } +public final class AcmeVocabulary implements MetaDataTypeProvider { + public static final class PickerLayout extends MetaLayout { + public PickerLayout(String name) { super("picker", name); } + } + @Override public String getProviderId() { return "acme-vocabulary"; } + // JVM provider ids: field-types and layout-types register those types. + @Override public String[] getDependencies() { return new String[] {"field-types", "layout-types"}; } @Override public void registerTypes(MetaDataRegistry registry) { - // registry.register(...) — see the cross-port contract + registry.registerType(PickerLayout.class, def -> { // a new subtype: layout.picker + def.type("layout").subType("picker").inheritsFrom("layout", "base"); + def.optionalAttributeWithConstraints("displayField").ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + registry.extendType(StringField.class, def -> { // optional attrs on field.string + def.optionalAttributeWithConstraints("searchable").ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); + def.optionalAttributeWithConstraints("valueLabels").ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); } } ``` -``` -# src/main/resources/META-INF/services/com.metaobjects.registry.MetaDataTypeProvider -com.example.providers.ExampleToolcallProvider +Wire it in the plugin configuration, with the provider's module on the plugin's classpath (a +project dependency or the plugin's ``; a provider in the same module is not yet +compiled at `generate-sources`): + +```xml + + + com.acme.vocab.AcmeVocabulary + + ``` -The provider contract is structurally identical to TS / C# / Python (id + -dependencies + description + `registerTypes` body); the loader composes -all providers via Kahn's algorithm and emits the same stable error codes -on failure (`ERR_PROVIDER_DUPLICATE_ID`, `_MISSING_DEPENDENCY`, -`_DEPENDENCY_CYCLE`). - -For callers who want to bypass SPI auto-discovery — or compose extra -consumer vocabulary on top of the full metamodel provider set so it still -strict-loads against the spec contract (no `--lax` fallback) — the -sanctioned seam is -`RegistryManifest.composeMetamodelRegistry(extraProviders)`, which composes -the core metamodel providers plus `extraProviders` and runs the full -spec-description + provenance-safe attr-scoping pipeline (hand the result -to `loader.setTypeRegistry(...)`). Raw `MetaDataRegistry.compose(...)` -composes only the explicit list, skips spec scoping, and is for -internal/test partial sets. The cross-port contract lives in -[`../features/extending-with-providers.md`](../features/extending-with-providers.md). +`metaobjects:generate` (with its docs generators), `metaobjects:verify`, `metaobjects:fmt` and +`metaobjects:editor` all load it. A Spring Boot app declares the provider as a +`MetaDataTypeProvider` bean and the auto-configured loader composes it. Code that loads the +model itself passes it on the options: +`LoaderOptions.create(false, false, true).setProviders(List.of(new AcmeVocabulary()))`, which +composes through `RegistryManifest.composeLoaderRegistry(extras)`. Raw +`MetaDataRegistry.compose(...)` composes only the explicit list, skips spec scoping, and is +for internal and test partial sets. + +Composition fails with the same stable codes as every other port (`ERR_PROVIDER_DUPLICATE_ID`, +`ERR_PROVIDER_MISSING_DEPENDENCY`, `ERR_PROVIDER_DEPENDENCY_CYCLE`). If another port's tools load the same metadata, that port needs its own copy of the +provider (same id, same vocabulary); a provider registered in one language is invisible to +the others. Full contract and +the other languages' versions: [`../features/extending-with-providers.md`](../features/extending-with-providers.md). ## Generate diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 108505e5c4..6f9def8b25 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -339,15 +339,36 @@ The data dict itself: [`codegen-data-shapes.md`](../features/codegen-data-shapes ### Custom providers (optional) -Kotlin inherits Java's SPI-based provider discovery directly — write a -`MetaDataTypeProvider` implementation (or its Kotlin DSL equivalent in -`metadata-ktx`), drop the FQCN into -`META-INF/services/com.metaobjects.registry.MetaDataTypeProvider`, and the -loader picks it up alongside the core providers. See the Java port's -[Custom providers section](java.md#custom-providers-optional) for the -mechanism; the -[`../features/extending-with-providers.md`](../features/extending-with-providers.md) -reference covers the cross-port contract. +Kotlin runs on the JVM registry, so a provider is a `MetaDataTypeProvider` written in Kotlin +and is wired exactly as in Java: the plugin's `FQCN` +list (Kotlin generators run through `metaobjects:generate`, so `generate`, `verify`, `fmt` +and `editor` all load it), a `MetaDataTypeProvider` bean in a Spring Boot app, or the +`metadata-ktx` loader's `providers` parameter: + +```kotlin +class PickerLayout(name: String) : MetaLayout("picker", name) + +class AcmeVocabulary : MetaDataTypeProvider { + override fun getProviderId() = "acme-vocabulary" + override fun getDependencies() = arrayOf("field-types", "layout-types") + override fun registerTypes(registry: MetaDataRegistry) { + registry.registerType(PickerLayout::class.java) { def -> + def.type("layout").subType("picker").inheritsFrom("layout", "base") + def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle() + } + registry.extendType(StringField::class.java) { def -> + def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle() + def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray() + } + } +} + +val result = loadUris("app", uris, LoaderOptions.create(false, false, true), providers = listOf(AcmeVocabulary())) +``` + +`META-INF/services` does not add a provider to the loader. See the Java port's +[Custom providers section](java.md#custom-providers-optional) for the plugin wiring, and +[`../features/extending-with-providers.md`](../features/extending-with-providers.md) for the cross-port contract. ### Exposed 1.x output (`exposedApi`) diff --git a/docs/ports/python.md b/docs/ports/python.md index e592bd61d8..ff6fdeb94b 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -99,23 +99,35 @@ metadata: ### Custom providers (optional) -If your app needs a metamodel subtype the core doesn't ship, declare a -`Provider` and pass it through `from_directory`: +When the project needs a subtype or an attribute the core does not ship, declare a +`metaobjects.provider.Provider` in a module beside `metaobjects.config.yaml` and list it as +`module:symbol`: + +```yaml +metadata: metaobjects +providers: ["acme_vocab:provider"] +``` + +The module resolves relative to the config's directory. `metaobjects gen` (with its docs +generators), every `metaobjects verify` mode and `metaobjects fmt` load that list, and +`--provider module:symbol` adds to it for one run. Code that loads the model itself passes the +full set, core first, because `providers=` is exactly what is composed: ```python -from metaobjects.provider import Provider -from metaobjects import MetaDataLoader -from .providers import your_provider +from metaobjects.core_types import core_providers +from metaobjects.loader.meta_data_loader import MetaDataLoader +from acme_vocab import provider -result = MetaDataLoader.from_directory("./metaobjects", providers=[your_provider]) +result = MetaDataLoader.from_directory("./metaobjects", providers=[*core_providers, provider], strict=True) ``` -The provider object has the same four-member contract (`id`, `dependencies`, -`description`, `register_types(registry)`) as TS / C#. Composition errors -surface `ERR_PROVIDER_DUPLICATE_ID`, `ERR_PROVIDER_MISSING_DEPENDENCY`, -`ERR_PROVIDER_DEPENDENCY_CYCLE` — codes match the cross-port contract. See -[`../features/extending-with-providers.md`](../features/extending-with-providers.md) -for the full reference and +The provider has the same contract (an id, its dependencies, `TypeDefinition`s added with +`add`, and an `on_register` hook that `extend`s existing types) as the other ports. +Composition errors surface `ERR_PROVIDER_DUPLICATE_ID`, `ERR_PROVIDER_MISSING_DEPENDENCY` and +`ERR_PROVIDER_DEPENDENCY_CYCLE`. If another port's tools load the same metadata, that port needs its own copy of the +provider (same id, same vocabulary); a provider registered in one language is invisible to +the others. See [`../features/extending-with-providers.md`](../features/extending-with-providers.md) for the full reference with a worked +provider, and [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) for a worked example. diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index c274d65c52..d762566dc2 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -260,9 +260,12 @@ export default defineConfig({ }); ``` -The CLI threads the list into every `meta gen` / `meta verify` / `meta -migrate` / `meta prompt-snapshot` invocation. At the SDK level the same -list is accepted directly: +Every `meta` command that loads metadata reads the list: `gen` (with the docs +generators and the docs site), `verify`, `migrate`, `fmt`, `types`, `export`, +`deps`, `upgrade` and `prompt-snapshot`. A config that fails to load is reported +as its own error, never as an unknown subtype. If another port's tools load the +same metadata, that port needs its own copy of the provider. At the SDK level +the same list is accepted directly: ```ts import { loadMemory } from "@metaobjectsdev/sdk"; diff --git a/docs/recipes/extending-metaobjects-with-providers.md b/docs/recipes/extending-metaobjects-with-providers.md index 643faaa44d..0d2cf8793e 100644 --- a/docs/recipes/extending-metaobjects-with-providers.md +++ b/docs/recipes/extending-metaobjects-with-providers.md @@ -202,18 +202,18 @@ That's the value: **the metamodel becomes self-checking**. The toolcall declarations and the provider that gives them meaning live in the same project, and any divergence is caught at the load boundary, not at runtime. -## C# / Python equivalents +## The other languages -The C# and Python ports use the same provider contract — the only difference -is the API surface for loader composition. See the per-port quickstarts: +Every port supports the same provider contract, wired in its own config: Python lists +`module:symbol` under `providers` in `metaobjects.config.yaml`; C# lists the built assembly +and type under `csharp.providers` in `.metaobjects/config.json`; Java and Kotlin list the +class under the Maven plugin's `` (or declare a Spring `MetaDataTypeProvider` +bean). A project whose metadata is loaded by more than one port's tools writes the provider +once per port, with the same id and vocabulary; a TypeScript-only provider leaves the Java, +Python and C# tools refusing the same file. -- [`../ports/csharp.md`](../ports/csharp.md) -- [`../ports/python.md`](../ports/python.md) - -The provider object itself is structurally identical across languages -(id + dependencies + description + a body that calls `registry.register` or -`registry.extend`). A C# port of `exampleProvider` reads exactly like the TS -version with C# spelling. +[`../features/extending-with-providers.md`](../features/extending-with-providers.md#wiring-providers-into-the-loader) +has a worked example per language and lists which commands load the provider in each. ## When NOT to write a provider @@ -237,4 +237,4 @@ metadata files, requires its own validation, and benefits from custom codegen. - [`../../fixtures/conformance/provider-extension-new-subtype-success/`](../../fixtures/conformance/provider-extension-new-subtype-success/) — the matching conformance fixture - The conformance fixture at [`../../fixtures/conformance/provider-extension-new-subtype-success/`](../../fixtures/conformance/provider-extension-new-subtype-success/) — - a runnable proof of this exact pattern across TS / C# / Python + a runnable proof of this exact pattern in every port diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 6e9ec3f63d..6ddb0b6b99 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.2" }, "counts": { - "fixtures": 423, + "fixtures": 425, "corpora": 35, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/java-providers.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/java-providers.md new file mode 100644 index 0000000000..70455cb22e --- /dev/null +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/java-providers.md @@ -0,0 +1,93 @@ +# Your own vocabulary in Java — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Java tools load the metadata. + +A provider is a public class implementing `com.metaobjects.registry.MetaDataTypeProvider`, with +a public no-arg constructor: a provider id, the provider ids it depends on (the JVM ids: +`field-types` registers the field types, `layout-types` the layouts), and a `registerTypes` +body that calls `registerType` (a new subtype, backed by its own node class) and `extendType` +(attributes on an existing one). This one registers `layout.picker` and adds `@searchable` and +`@valueLabels` to `field.string`: + +```java +package com.acme.vocab; + +import com.metaobjects.attr.BooleanAttribute; +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.field.StringField; +import com.metaobjects.layout.MetaLayout; +import com.metaobjects.registry.MetaDataRegistry; +import com.metaobjects.registry.MetaDataTypeProvider; + +public final class AcmeVocabulary implements MetaDataTypeProvider { + /** The node class behind layout.picker. */ + public static final class PickerLayout extends MetaLayout { + public PickerLayout(String name) { super("picker", name); } + } + + @Override public String getProviderId() { return "acme-vocabulary"; } + @Override public String[] getDependencies() { return new String[] {"field-types", "layout-types"}; } + + @Override public void registerTypes(MetaDataRegistry registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.registerType(PickerLayout.class, def -> { + def.type("layout").subType("picker").inheritsFrom("layout", "base"); + def.optionalAttributeWithConstraints("displayField").ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + // Attributes on a type another provider owns: always optional. + registry.extendType(StringField.class, def -> { + def.optionalAttributeWithConstraints("searchable").ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); + def.optionalAttributeWithConstraints("valueLabels").ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); + } +} +``` + +Wire it once, in the `metaobjects-maven-plugin` configuration. The class must be on the +plugin's classpath: put the provider in its own module and add that module as a project +dependency or under the plugin's ``. A provider in the same module is not +compiled yet when `generate` runs at `generate-sources`. + +```xml + + com.metaobjects + metaobjects-maven-plugin + + + com.acme.vocab.AcmeVocabulary + + + + com.acmeacme-vocabulary1.0 + + +``` + +`metaobjects:generate` (with its docs generators), `metaobjects:verify`, `metaobjects:fmt` and +`metaobjects:editor` all compose the metamodel providers plus these. Listing a class in +`META-INF/services` does NOT add it to the loader: the strict loader composes a fixed +metamodel set plus the providers you name, so a jar on the classpath cannot change the +vocabulary by accident. + +In a Spring Boot app, declare the provider as a bean and the auto-configured loader composes +it: `@Bean MetaDataTypeProvider acmeVocabulary() { return new AcmeVocabulary(); }`. Code that +loads the model itself passes it on the options: + +```java +MetaDataLoader.fromUris("app", uris, + LoaderOptions.create(false, false, true).setProviders(List.of(new AcmeVocabulary()))); +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects:verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (the same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/kotlin-providers.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/kotlin-providers.md new file mode 100644 index 0000000000..7eba7c3004 --- /dev/null +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/kotlin-providers.md @@ -0,0 +1,77 @@ +# Your own vocabulary in Kotlin — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Kotlin tools load the +> metadata. + +Kotlin runs on the JVM registry, so a provider is a class implementing +`com.metaobjects.registry.MetaDataTypeProvider`, written in Kotlin: a provider id, the JVM +provider ids it depends on (`field-types`, `layout-types`), and a `registerTypes` body that +calls `registerType` (a new subtype, backed by its own node class) and `extendType` +(attributes on an existing one). This one registers `layout.picker` and adds `@searchable` and +`@valueLabels` to `field.string`: + +```kotlin +package com.acme.vocab + +import com.metaobjects.field.StringField +import com.metaobjects.layout.MetaLayout +import com.metaobjects.registry.MetaDataRegistry +import com.metaobjects.registry.MetaDataTypeProvider + +/** The node class behind layout.picker. */ +class PickerLayout(name: String) : MetaLayout("picker", name) + +class AcmeVocabulary : MetaDataTypeProvider { + override fun getProviderId() = "acme-vocabulary" + override fun getDependencies() = arrayOf("field-types", "layout-types") + + override fun registerTypes(registry: MetaDataRegistry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.registerType(PickerLayout::class.java) { def -> + def.type("layout").subType("picker").inheritsFrom("layout", "base") + def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle() + } + // Attributes on a type another provider owns: always optional. + registry.extendType(StringField::class.java) { def -> + def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle() + def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray() + } + } +} +``` + +Wire it once. Kotlin generators run through `metaobjects:generate`, so the build lists it in +the `metaobjects-maven-plugin` configuration, with the provider's module on the plugin's +classpath (a project dependency or the plugin's ``; a provider in the same +module is not compiled yet at `generate-sources`): + +```xml + + + com.acme.vocab.AcmeVocabulary + + +``` + +`generate`, `verify`, `fmt` and `editor` then all load it. `META-INF/services` does not add a +provider to the loader. In a Spring Boot app, a `MetaDataTypeProvider` bean is composed into +the auto-configured loader. Code that loads the model through the `metadata-ktx` facade passes +it directly: + +```kotlin +val result = loadUris("app", uris, LoaderOptions.create(false, false, true), providers = listOf(AcmeVocabulary())) +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects:verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (the same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/java-providers.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/java-providers.md new file mode 100644 index 0000000000..70455cb22e --- /dev/null +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/java-providers.md @@ -0,0 +1,93 @@ +# Your own vocabulary in Java — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Java tools load the metadata. + +A provider is a public class implementing `com.metaobjects.registry.MetaDataTypeProvider`, with +a public no-arg constructor: a provider id, the provider ids it depends on (the JVM ids: +`field-types` registers the field types, `layout-types` the layouts), and a `registerTypes` +body that calls `registerType` (a new subtype, backed by its own node class) and `extendType` +(attributes on an existing one). This one registers `layout.picker` and adds `@searchable` and +`@valueLabels` to `field.string`: + +```java +package com.acme.vocab; + +import com.metaobjects.attr.BooleanAttribute; +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.field.StringField; +import com.metaobjects.layout.MetaLayout; +import com.metaobjects.registry.MetaDataRegistry; +import com.metaobjects.registry.MetaDataTypeProvider; + +public final class AcmeVocabulary implements MetaDataTypeProvider { + /** The node class behind layout.picker. */ + public static final class PickerLayout extends MetaLayout { + public PickerLayout(String name) { super("picker", name); } + } + + @Override public String getProviderId() { return "acme-vocabulary"; } + @Override public String[] getDependencies() { return new String[] {"field-types", "layout-types"}; } + + @Override public void registerTypes(MetaDataRegistry registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.registerType(PickerLayout.class, def -> { + def.type("layout").subType("picker").inheritsFrom("layout", "base"); + def.optionalAttributeWithConstraints("displayField").ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + // Attributes on a type another provider owns: always optional. + registry.extendType(StringField.class, def -> { + def.optionalAttributeWithConstraints("searchable").ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); + def.optionalAttributeWithConstraints("valueLabels").ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); + } +} +``` + +Wire it once, in the `metaobjects-maven-plugin` configuration. The class must be on the +plugin's classpath: put the provider in its own module and add that module as a project +dependency or under the plugin's ``. A provider in the same module is not +compiled yet when `generate` runs at `generate-sources`. + +```xml + + com.metaobjects + metaobjects-maven-plugin + + + com.acme.vocab.AcmeVocabulary + + + + com.acmeacme-vocabulary1.0 + + +``` + +`metaobjects:generate` (with its docs generators), `metaobjects:verify`, `metaobjects:fmt` and +`metaobjects:editor` all compose the metamodel providers plus these. Listing a class in +`META-INF/services` does NOT add it to the loader: the strict loader composes a fixed +metamodel set plus the providers you name, so a jar on the classpath cannot change the +vocabulary by accident. + +In a Spring Boot app, declare the provider as a bean and the auto-configured loader composes +it: `@Bean MetaDataTypeProvider acmeVocabulary() { return new AcmeVocabulary(); }`. Code that +loads the model itself passes it on the options: + +```java +MetaDataLoader.fromUris("app", uris, + LoaderOptions.create(false, false, true).setProviders(List.of(new AcmeVocabulary()))); +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects:verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (the same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/python-providers.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/python-providers.md new file mode 100644 index 0000000000..36097ad436 --- /dev/null +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/python-providers.md @@ -0,0 +1,69 @@ +# Your own vocabulary in Python — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its Python tools load the +> metadata. + +A provider is a `metaobjects.provider.Provider`: an id, the provider ids it depends on, the +`TypeDefinition`s it adds (a new subtype), and an `on_register` hook that `extend`s existing +types. This one registers `layout.picker` and adds `@searchable` and `@valueLabels` to +`field.string`: + +```python +# acme_vocab.py (beside metaobjects.config.yaml) +from metaobjects.meta.presentation.layout.meta_layout import MetaLayout +from metaobjects.provider import Provider +from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition +from metaobjects.shared.base_types import TYPE_ATTR + +provider = Provider("acme-vocabulary", dependencies=("metaobjects-core-types",)) + +# A new subtype, with its own attribute (an own attribute may be required). +provider.add(TypeDefinition( + type="layout", sub_type="picker", factory=MetaLayout, + attrs=[AttrSchema("displayField", "string", required=False)], + child_rules=[ChildRule(TYPE_ATTR, "*")], +)) + +# Attributes on a type another provider owns: always optional. +provider.on_register(lambda registry: registry.extend( + "field", "string", + attributes=[ + AttrSchema("searchable", "boolean", required=False), + AttrSchema("valueLabels", "string", required=False, is_array=True), + ], +)) +``` + +Wire it once, in `metaobjects.config.yaml`, as `module:symbol`. The module resolves relative +to the config's directory, so no `PYTHONPATH` is needed: + +```yaml +metadata: metaobjects +providers: ["acme_vocab:provider"] +``` + +`metaobjects gen` (with its docs generators), every `metaobjects verify` mode and +`metaobjects fmt` read that list; `--provider module:symbol` adds to it for one run. Code that +loads the model itself passes the full list, core first, because `providers=` is exactly the +set composed: + +```python +from metaobjects.core_types import core_providers +from metaobjects.loader.meta_data_loader import MetaDataLoader + +result = MetaDataLoader.from_directory("metaobjects", providers=[*core_providers, provider], strict=True) +``` + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `metaobjects verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same id, same vocabulary). Full +contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md new file mode 100644 index 0000000000..7546bd3b50 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md @@ -0,0 +1,74 @@ +# Your own vocabulary in TypeScript — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its TypeScript tools load the +> metadata. + +A provider is an object implementing `MetaDataTypeProvider`: an `id`, the provider ids it +depends on, and a `registerTypes(registry)` body that calls `register` (a new subtype) and +`extend` (attributes on an existing one). This one registers `layout.picker` and adds +`@searchable` and `@valueLabels` to `field.string`: + +```ts +// codegen/vocabulary.ts +import { TypeId, MetaLayout, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { + TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, + ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN, +} from "@metaobjectsdev/metadata/constants"; + +export const vocabulary: MetaDataTypeProvider = { + id: "acme-vocabulary", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.register({ + typeId: new TypeId(TYPE_LAYOUT, "picker"), + description: "A picker over the owning object.", + factory: (typeId, name) => new MetaLayout(typeId, name), + childRules: [], + attributes: [ + { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }, + ], + }); + // Attributes on a type another provider owns: always optional. + registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, { + attributes: [ + { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." }, + { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." }, + ], + }); + }, +}; +``` + +Wire it once, in `metaobjects.config.ts`: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { vocabulary } from "./codegen/vocabulary"; + +export default defineConfig({ + generators: [/* ... */], + providers: [vocabulary], +}); +``` + +Every `meta` command that loads metadata reads that list: `gen` (with the docs generators and +the docs site), `verify`, `migrate`, `fmt`, `types`, `export`, `deps`, `upgrade`. `meta types +picker` then lists the new subtype. A config that fails to load is reported as its own error, +never as an unknown subtype. Code that loads the model itself passes the same provider: +`loadMemory(root, { providers: [vocabulary] })`. + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `meta verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same `id`, same vocabulary). +Full contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..0c0a0ccd96 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -451,6 +451,20 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Your own vocabulary — a provider class, in every language + +A subtype or attribute the core does not ship (a `layout.picker`, `@searchable` on fields) is registered by a **provider class** in the project's own language — never a side file, never a loosened `strict`. Decide the shape first (`references/choosing-a-shape.md`, which also has the register/extend rules). List the provider once in the port's project configuration and every command that loads metadata — gen and its docs generators, verify, fmt — composes it: + +| Port | Where | Worked example | +|---|---|---| +| TypeScript | `providers` in `metaobjects.config.ts` | `references/typescript-providers.md` | +| Python | `providers: ["module:symbol"]` in `metaobjects.config.yaml` | `references/python-providers.md` | +| C# | `csharp.providers` in `.metaobjects/config.json` | `references/csharp-providers.md` | +| Java | `` on the Maven plugin; Spring beans | `references/java-providers.md` | +| Kotlin | the same Maven ``; `providers =` on `metadata-ktx` loaders | `references/kotlin-providers.md` | + +**Every port whose tools load the metadata needs its own copy**: a TypeScript-only provider leaves the Java, Python or C# side, and its docs build, failing with `ERR_UNKNOWN_SUBTYPE` / `ERR_UNKNOWN_ATTR`. Each port's `verify` loads strict, so it proves the copy is complete. Only this stack's examples are installed. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md index 7ae89655d9..ee3b6858a5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/choosing-a-shape.md @@ -162,6 +162,17 @@ or behavior is a **subtype**, and structural variants *within* such a subtype ar (including any `@kind` value-set) carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. +**What a provider does.** It names an `id` and the provider ids it depends on, then does one +or both of two things. It **registers** a new `(type, subType)` with its own attributes, which +may be required, and a node class (reuse the type's existing one unless the node has new +behaviour). It **extends** an existing `(type, subType)` with attributes, which must be +optional: a required one is `ERR_EXTEND_REQUIRED_ATTR` (ADR-0050), because removing your +provider must never weaken a type someone else owns. A new type allowed at the document root +also needs `extend` on `metadata.root` to admit it, or it fails with `ERR_CHILD_NOT_ALLOWED`. +An attribute no provider declares is `ERR_UNKNOWN_ATTR` under a strict load, and a subtype +nobody registered is `ERR_UNKNOWN_SUBTYPE`; that refusal is what keeps a typo from loading. +The per-language example and wiring is in the skill's `references/-providers.md`. + **Converge before inventing.** Search the shipped vocabulary first (`meta types `) and check the roadmap — if core already models (or plans) the concept, model yours in a fold-in-friendly shape. The type names `api`, `operation`, `surface`, and diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md new file mode 100644 index 0000000000..7546bd3b50 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/typescript-providers.md @@ -0,0 +1,74 @@ +# Your own vocabulary in TypeScript — a provider + +> Part of the `metaobjects-authoring` skill, "Your own vocabulary". Read it when the project +> needs a subtype or an attribute the core does not ship, and its TypeScript tools load the +> metadata. + +A provider is an object implementing `MetaDataTypeProvider`: an `id`, the provider ids it +depends on, and a `registerTypes(registry)` body that calls `register` (a new subtype) and +`extend` (attributes on an existing one). This one registers `layout.picker` and adds +`@searchable` and `@valueLabels` to `field.string`: + +```ts +// codegen/vocabulary.ts +import { TypeId, MetaLayout, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { + TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, + ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN, +} from "@metaobjectsdev/metadata/constants"; + +export const vocabulary: MetaDataTypeProvider = { + id: "acme-vocabulary", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + // A new subtype, with its own attribute (an own attribute may be required). + registry.register({ + typeId: new TypeId(TYPE_LAYOUT, "picker"), + description: "A picker over the owning object.", + factory: (typeId, name) => new MetaLayout(typeId, name), + childRules: [], + attributes: [ + { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }, + ], + }); + // Attributes on a type another provider owns: always optional. + registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, { + attributes: [ + { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." }, + { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." }, + ], + }); + }, +}; +``` + +Wire it once, in `metaobjects.config.ts`: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { vocabulary } from "./codegen/vocabulary"; + +export default defineConfig({ + generators: [/* ... */], + providers: [vocabulary], +}); +``` + +Every `meta` command that loads metadata reads that list: `gen` (with the docs generators and +the docs site), `verify`, `migrate`, `fmt`, `types`, `export`, `deps`, `upgrade`. `meta types +picker` then lists the new subtype. A config that fails to load is reported as its own error, +never as an unknown subtype. Code that loads the model itself passes the same provider: +`loadMemory(root, { providers: [vocabulary] })`. + +The metadata uses it like core vocabulary: + + +```json +{ "field.string": { "name": "name", "@searchable": true } } +{ "layout.picker": { "name": "contactPicker", "@displayField": "name" } } +``` + +Without the provider the same file fails `meta verify` with `ERR_UNKNOWN_SUBTYPE` on +`layout.picker` and `ERR_UNKNOWN_ATTR` on `@searchable`. If another port's tools load this +metadata too, that port needs its own copy of the provider (same `id`, same vocabulary). +Full contract: `docs/features/extending-with-providers.md`. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index a030b08545..36123e0d8f 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -430,6 +430,11 @@ template hack. See the decision procedure in the **`metaobjects-authoring`** ski array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary for a custom generator, the same ADR-0037 procedure decides whether it's a subtype, a `@kind` variant, or an attribute. +The vocabulary itself is a provider class, listed once in the port's project +configuration so `gen`, `verify --codegen` and the docs generators all load it (the +**`metaobjects-authoring`** skill, "Your own vocabulary", has the per-language example). +A generator then reads the custom attribute like any other: through the resolving +accessor on the node, never by re-parsing the file. ## Per-target output diff --git a/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/expected-errors.json b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/expected-errors.json new file mode 100644 index 0000000000..2b95372049 --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/expected-errors.json @@ -0,0 +1,29 @@ +{ + "errors": [ + { + "code": "ERR_UNKNOWN_SUBTYPE", + "source": { + "format": "json", + "files": ["meta.contacts.json"], + "jsonPath": "$['metadata.root'].children[0]['object.value'].children[2]['layout.picker']" + } + }, + { + "code": "ERR_UNKNOWN_ATTR", + "source": { + "format": "json", + "files": ["meta.contacts.json"], + "jsonPath": "$['metadata.root'].children[0]['object.value'].children[0]['field.string']" + } + }, + { + "code": "ERR_UNKNOWN_ATTR", + "source": { + "format": "json", + "files": ["meta.contacts.json"], + "jsonPath": "$['metadata.root'].children[0]['object.value'].children[1]['field.string']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/input/meta.contacts.json b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/input/meta.contacts.json new file mode 100644 index 0000000000..6f7d926d7b --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/input/meta.contacts.json @@ -0,0 +1,17 @@ +{ + "metadata.root": { + "package": "examples", + "children": [ + { + "object.value": { + "name": "Contact", + "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "field.string": { "name": "status", "@valueLabels": ["Active", "On hold"] } }, + { "layout.picker": { "name": "contactPicker", "@displayField": "name" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/providers.json b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/providers.json new file mode 100644 index 0000000000..8ec88fc091 --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-missing-provider-fails/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/provider-custom-vocabulary-success/expected.json b/fixtures/conformance/provider-custom-vocabulary-success/expected.json new file mode 100644 index 0000000000..f568bfab16 --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-success/expected.json @@ -0,0 +1,35 @@ +{ + "metadata.root": { + "package": "examples", + "children": [ + { + "object.value": { + "name": "Contact", + "children": [ + { + "field.string": { + "name": "name", + "@searchable": true + } + }, + { + "field.string": { + "name": "status", + "@valueLabels": [ + "Active", + "On hold" + ] + } + }, + { + "layout.picker": { + "name": "contactPicker", + "@displayField": "name" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/provider-custom-vocabulary-success/input/meta.contacts.json b/fixtures/conformance/provider-custom-vocabulary-success/input/meta.contacts.json new file mode 100644 index 0000000000..6f7d926d7b --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-success/input/meta.contacts.json @@ -0,0 +1,17 @@ +{ + "metadata.root": { + "package": "examples", + "children": [ + { + "object.value": { + "name": "Contact", + "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "field.string": { "name": "status", "@valueLabels": ["Active", "On hold"] } }, + { "layout.picker": { "name": "contactPicker", "@displayField": "name" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/provider-custom-vocabulary-success/providers.json b/fixtures/conformance/provider-custom-vocabulary-success/providers.json new file mode 100644 index 0000000000..971d8c590a --- /dev/null +++ b/fixtures/conformance/provider-custom-vocabulary-success/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "example-custom-vocabulary"] diff --git a/server/csharp/MetaObjects.Cli.Tests/CsharpProvidersTests.cs b/server/csharp/MetaObjects.Cli.Tests/CsharpProvidersTests.cs new file mode 100644 index 0000000000..78a5e03956 --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/CsharpProvidersTests.cs @@ -0,0 +1,237 @@ +using MetaObjects.Cli; +using MetaObjects.Config; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Cli.Tests; + +/// +/// `csharp.providers` in .metaobjects/config.json: a project's own IMetaDataTypeProvider, loaded by +/// path, composes into EVERY metadata-loading entry point (gen/verify/fmt/field-lint), strictly. +/// The test provider lives in MetaObjects.TestVocabulary (built, not referenced, by this project). +/// +public sealed class CsharpProvidersTests : IDisposable +{ + private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-providers-" + Guid.NewGuid().ToString("N")); + private string MetaDir => Path.Combine(_tmp, "metaobjects"); + + private const string Metadata = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Contact", "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "layout.picker": { "name": "contactPicker", "@displayField": "name" } } + ]}} + ]}} + """; + + public CsharpProvidersTests() + { + Directory.CreateDirectory(MetaDir); + File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), Metadata); + } + + public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } } + + /// Path of the built test vocabulary dll (sibling project's output). + internal static string VocabularyDll + { + get + { + // /MetaObjects.Cli.Tests/bin//net8.0/ -> /MetaObjects.TestVocabulary/bin//net8.0/ + var baseDir = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + var tfm = baseDir.Name; var cfg = baseDir.Parent!.Name; + var root = baseDir.Parent!.Parent!.Parent!.Parent!.FullName; + return Path.Combine(root, "MetaObjects.TestVocabulary", "bin", cfg, tfm, "MetaObjects.TestVocabulary.dll"); + } + } + + private void WriteConfig(string csharpBlock) + { + Directory.CreateDirectory(Path.Combine(_tmp, ".metaobjects")); + File.WriteAllText(Path.Combine(_tmp, ".metaobjects", "config.json"), + "{ \"schema_version\": 1, \"csharp\": " + csharpBlock + " }"); + } + + private static string Esc(string s) => s.Replace("\\", "\\\\"); + + private void WriteProviderConfig(string type = "MetaObjects.TestVocabulary.TestVocabularyProvider", string? assembly = null) => + WriteConfig("{ \"providers\": [ { \"assembly\": \"" + Esc(assembly ?? VocabularyDll) + "\", \"type\": \"" + type + "\" } ] }"); + + private ResolvedMetadata Resolve() => MetadataLocation.Resolve(null, _tmp); + + // -- loading --------------------------------------------------------------------------- + + [Fact] + public void Listed_provider_loads_the_custom_vocabulary_strictly() + { + WriteProviderConfig(); + var load = Resolve().Load(strict: true); + Assert.Empty(load.Errors); + } + + [Fact] + public void Without_the_provider_the_same_metadata_fails_strict() + { + WriteConfig("{ \"outDir\": \"gen\" }"); + var codes = Resolve().Load(strict: true).Errors.Select(e => e.Code).ToList(); + Assert.Contains(ErrorCode.ERR_UNKNOWN_SUBTYPE, codes); + Assert.Contains(ErrorCode.ERR_UNKNOWN_ATTR, codes); + } + + [Fact] + public void The_assembly_path_is_resolved_against_the_directory_holding_dot_metaobjects() + { + var rel = Path.GetRelativePath(_tmp, VocabularyDll); + WriteProviderConfig(assembly: rel); + Assert.Empty(Resolve().Load(strict: true).Errors); + } + + [Fact] + public void An_explicit_metadata_dir_reads_the_providers_of_the_project_holding_it() + { + WriteProviderConfig(); + var other = Path.Combine(Path.GetTempPath(), "meta-providers-cwd-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(other); + try { Assert.Empty(MetadataLocation.Resolve(MetaDir, other).Load(strict: true).Errors); } + finally { Directory.Delete(other, recursive: true); } + } + + [Fact] + public void Registry_composes_with_FromDirectory_for_a_programmatic_host() + { + WriteProviderConfig(); + var registry = Resolve().Registry(); + Assert.Empty(MetaDataLoader.FromDirectory(MetaDir, registry, strict: true).Errors); + } + + // -- every entry point ----------------------------------------------------------------- + + [Fact] + public void Verify_templates_gate_uses_the_providers() + { + WriteProviderConfig(); + var providers = Resolve().Providers; + var tpl = Path.Combine(_tmp, "templates"); Directory.CreateDirectory(tpl); + + var with = VerifyCommand.RunSubverbs(new VerifyCommand.Options + { MetadataDir = MetaDir, TemplatesRoot = tpl, Templates = true, Providers = providers }); + Assert.Empty(with.Templates!.LoadErrors); + + var without = VerifyCommand.RunSubverbs(new VerifyCommand.Options + { MetadataDir = MetaDir, TemplatesRoot = tpl, Templates = true }); + Assert.Contains(ErrorCode.ERR_UNKNOWN_SUBTYPE.ToString(), without.Templates!.LoadErrors); + + Assert.Empty(VerifyCommand.Run(MetaDir, tpl, providers: providers).LoadErrors); + Assert.Contains(ErrorCode.ERR_UNKNOWN_SUBTYPE.ToString(), VerifyCommand.Run(MetaDir, tpl).LoadErrors); + } + + [Fact] + public void Verify_requirement_gate_loads_with_the_providers() + { + WriteProviderConfig(); + var opts = new VerifyCommand.Options { MetadataDir = MetaDir, Providers = Resolve().Providers }; + var output = new StringWriter(); + Assert.Equal(0, VerifyCommand.RunRequirementGate(opts, false, output, false)); + Assert.DoesNotContain("load error", output.ToString()); + + var bare = new StringWriter(); + Assert.Equal(1, VerifyCommand.RunRequirementGate(new VerifyCommand.Options { MetadataDir = MetaDir }, false, bare, false)); + Assert.Contains("ERR_UNKNOWN_SUBTYPE", bare.ToString()); + } + + [Fact] + public void Verify_codegen_gate_loads_with_the_providers() + { + WriteProviderConfig(); + var opts = new VerifyCommand.Options + { + MetadataDir = MetaDir, OutDir = Path.Combine(_tmp, "generated"), Codegen = true, Providers = Resolve().Providers, + }; + var r = VerifyCommand.RunSubverbs(opts); + Assert.False(r.LoadFailureReported); + + var bare = VerifyCommand.RunSubverbs(new VerifyCommand.Options + { MetadataDir = MetaDir, OutDir = Path.Combine(_tmp, "generated"), Codegen = true }); + Assert.True(bare.LoadFailureReported); + } + + [Fact] + public void Fmt_loads_and_formats_with_the_providers() + { + WriteProviderConfig(); + var result = FmtCommand.Run(Resolve(), check: true, strict: true); + Assert.Null(result.Fatal); + + File.Delete(Path.Combine(_tmp, ".metaobjects", "config.json")); + var bare = FmtCommand.Run(Resolve(), check: true, strict: true); + Assert.NotNull(bare.Fatal); + Assert.Contains("picker", bare.Fatal!); + } + + [Fact] + public void Field_lint_loads_with_the_providers() + { + WriteProviderConfig(); + // A repeated field name: the advisory scan only gets to report it if the model LOADS. + File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Contact", "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "field.string": { "name": "name" } }, + { "layout.picker": { "name": "contactPicker" } } + ]}} + ]}} + """); + var resolved = Resolve(); + var with = new StringWriter(); + FieldLint.RunAdvisory(resolved.Directory, resolved.Files, resolved.Libraries, with, resolved.Imports, resolved.Providers); + Assert.Contains("authoring warning", with.ToString()); + + var without = new StringWriter(); + FieldLint.RunAdvisory(resolved.Directory, resolved.Files, resolved.Libraries, without, resolved.Imports); + Assert.Equal("", without.ToString()); + } + + // -- config errors --------------------------------------------------------------------- + + private MetaModelException Refusal() + => Assert.Throws(() => Resolve()); + + [Fact] + public void Missing_assembly_says_build_it_first_and_names_the_key() + { + WriteProviderConfig(assembly: "vocabulary/bin/Debug/net8.0/Nope.dll"); + var ex = Refusal(); + Assert.Equal(ErrorCode.ERR_BAD_ATTR_VALUE, ex.Code); + Assert.Contains("csharp.providers", ex.Message); + Assert.Contains("Nope.dll", ex.Message); + Assert.Contains("Build the vocabulary project first", ex.Message); + } + + [Theory] + [InlineData("Acme.Missing.Provider", "was not found")] + [InlineData("MetaObjects.TestVocabulary.NotAProvider", "IMetaDataTypeProvider")] + [InlineData("MetaObjects.TestVocabulary.NeedsArgsProvider", "parameterless constructor")] + public void Bad_type_is_refused_with_an_actionable_message(string type, string fragment) + { + WriteProviderConfig(type); + var ex = Refusal(); + Assert.Equal(ErrorCode.ERR_BAD_ATTR_VALUE, ex.Code); + Assert.Contains("csharp.providers", ex.Message); + Assert.Contains(fragment, ex.Message); + } + + [Theory] + [InlineData("{ \"providers\": \"x\" }", "must be an array")] + [InlineData("{ \"providers\": [ \"x\" ] }", "must be an object")] + [InlineData("{ \"providers\": [ { \"assembly\": \"a.dll\" } ] }", "needs both")] + [InlineData("{ \"providers\": [ { \"assembly\": \"a.dll\", \"type\": \"T\", \"extra\": 1 } ] }", "unknown key \"extra\"")] + [InlineData("{ \"providers\": [ { \"assembly\": \"\", \"type\": \"T\" } ] }", "non-empty string")] + public void Malformed_provider_entries_are_refused(string block, string fragment) + { + WriteConfig(block); + var ex = Refusal(); + Assert.Equal(ErrorCode.ERR_BAD_ATTR_VALUE, ex.Code); + Assert.Contains(fragment, ex.Message); + } +} diff --git a/server/csharp/MetaObjects.Cli.Tests/MetaObjects.Cli.Tests.csproj b/server/csharp/MetaObjects.Cli.Tests/MetaObjects.Cli.Tests.csproj index 0662e71d11..a20f0e2e7c 100644 --- a/server/csharp/MetaObjects.Cli.Tests/MetaObjects.Cli.Tests.csproj +++ b/server/csharp/MetaObjects.Cli.Tests/MetaObjects.Cli.Tests.csproj @@ -12,5 +12,7 @@ + + diff --git a/server/csharp/MetaObjects.Cli/FieldLint.cs b/server/csharp/MetaObjects.Cli/FieldLint.cs index a07aa27e8e..72cf1128c4 100644 --- a/server/csharp/MetaObjects.Cli/FieldLint.cs +++ b/server/csharp/MetaObjects.Cli/FieldLint.cs @@ -208,13 +208,14 @@ public static List LintDuplicateFields(IEnumerable files) /// public static void RunAdvisory( string metadataDir, IReadOnlyList? metadataFiles, IReadOnlyList? libraries, TextWriter stderr, - MetaObjects.Config.ImportScope? imports = null) + MetaObjects.Config.ImportScope? imports = null, + IReadOnlyList? providers = null) { List findings; try { var load = new MetaObjects.Config.ResolvedMetadata( - metadataDir, metadataFiles, libraries ?? [], MetaObjects.Meta.PhysicalNaming.Default, null, null, imports).Load(); + metadataDir, metadataFiles, libraries ?? [], MetaObjects.Meta.PhysicalNaming.Default, null, null, imports, null, providers).Load(); if (load.Errors.Count > 0) return; var files = metadataFiles ?? new DirectorySource(metadataDir).Expand().Select(f => f.FilePath).ToList(); findings = [.. LintReferenceFields(load.Root), .. LintDuplicateFields(files)]; diff --git a/server/csharp/MetaObjects.Cli/FmtCommand.cs b/server/csharp/MetaObjects.Cli/FmtCommand.cs index d080c78432..0536cb1cc7 100644 --- a/server/csharp/MetaObjects.Cli/FmtCommand.cs +++ b/server/csharp/MetaObjects.Cli/FmtCommand.cs @@ -178,7 +178,7 @@ public static RunResult Run(ResolvedMetadata resolved, bool check, IReadOnlyList targets = allFiles; } - var registry = new MetaDataLoader().Registry; + var registry = resolved.Registry(); List BuildSources(string? overridePath = null, string? overrideText = null) { diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index 236ddd5f3c..a1a5c8fc25 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -526,6 +526,7 @@ static int RunVerify(string[] rest) // ResolveMetadataDirOrExit). Libraries = resolvedMeta.Libraries, Imports = resolvedMeta.Imports, + Providers = resolvedMeta.Providers, // ADR-0058 — same source as Libraries; the --codegen gate's regen reads it from // the loaded root rather than a flag (see VerifyCommand.RunCodegenDrift). Naming = resolvedMeta.Naming, @@ -607,7 +608,7 @@ static int RunVerify(string[] rest) // field name declared twice in one children list. Both load clean on every port. Runs // on every `verify`, whichever gates were selected; warnings ONLY, never the exit code. if (!noFieldLint && Environment.GetEnvironmentVariable(FieldLint.EnvOptOut) != "1") - FieldLint.RunAdvisory(resolvedMeta.Directory, resolvedMeta.Files, resolvedMeta.Libraries, Console.Error, resolvedMeta.Imports); + FieldLint.RunAdvisory(resolvedMeta.Directory, resolvedMeta.Files, resolvedMeta.Libraries, Console.Error, resolvedMeta.Imports, resolvedMeta.Providers); // The handed-off codegen gate already printed its own verdict (inherited console); // fold its exit code into the aggregate the same way every other subverb does — max, diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index bc85d1e049..ce820159e1 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -104,6 +104,8 @@ public sealed record Options /// was imported so the --codegen gate regenerates exactly what gen wrote. Null: none. /// public MetaObjects.Config.ImportScope? Imports { get; init; } + /// csharp.providers, instantiated; composed with the core providers on every load. Null: core only. + public IReadOnlyList? Providers { get; init; } /// /// ADR-0058 — the project's physical-naming rules, read from the SAME /// .metaobjects/config.json as (Program.cs's @@ -264,7 +266,7 @@ private static LoadResult LoadMetadata(Options opts) // The same load `gen` runs (ResolvedMetadata.Load): libraries, then the dependency snapshots, then the // project's own files; ADR-0058 naming and the wire format set on the root; the imports recorded on it. return new MetaObjects.Config.ResolvedMetadata( - opts.MetadataDir, opts.MetadataFiles, opts.Libraries, opts.Naming, opts.Wire, null, opts.Imports) + opts.MetadataDir, opts.MetadataFiles, opts.Libraries, opts.Naming, opts.Wire, null, opts.Imports, null, opts.Providers) .Load(opts.Strict); } @@ -466,8 +468,10 @@ private static void AddIfPresent(List refs, object? attr) if (attr is string s && s.Length > 0) refs.Add(s); } - public static Outcome Run(string metadataDir, string templatesRoot, bool strict = true) - => Run(MetaDataLoader.FromDirectory(metadataDir, strict: strict), templatesRoot); + public static Outcome Run( + string metadataDir, string templatesRoot, bool strict = true, IReadOnlyList? providers = null) + => Run(MetaDataLoader.FromDirectory( + metadataDir, Provider.ComposeRegistry(MetaObjects.Config.ProviderLoader.Compose(providers)), strict: strict), templatesRoot); /// /// Same as the metadataDir overload above, but starting from an diff --git a/server/csharp/MetaObjects.Codegen.Tests/CsharpProvidersCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CsharpProvidersCodegenTests.cs new file mode 100644 index 0000000000..e199914b3a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/CsharpProvidersCodegenTests.cs @@ -0,0 +1,88 @@ +// `csharp.providers` through CodegenCli.Run — the entry an owned codegen/Program.cs calls, and the +// one `dotnet meta gen` / `verify --codegen` hand off to. The vocabulary is loaded strictly. + +using MetaObjects.Codegen; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +[Collection("Console")] +public class CsharpProvidersCodegenTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mo-providers-" + Guid.NewGuid().ToString("N")); + private string Meta => Path.Combine(_root, "metaobjects"); + private string Out => Path.Combine(_root, "gen"); + + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Contact", "children": [ + { "field.string": { "name": "name", "@searchable": true } }, + { "layout.picker": { "name": "contactPicker", "@displayField": "name" } } + ]}} + ]}} + """; + + private sealed class OneFile : IGenerator + { + public string Name => "one-file"; + public IEnumerable Generate(GenContext ctx) => [new EmittedFile("ok.txt", "ok\n")]; + } + + public CsharpProvidersCodegenTests() + { + Directory.CreateDirectory(Meta); + File.WriteAllText(Path.Combine(Meta, "meta.acme.json"), Model); + } + + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } } + + private static string VocabularyDll + { + get + { + var baseDir = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + var root = baseDir.Parent!.Parent!.Parent!.Parent!.FullName; + return Path.Combine(root, "MetaObjects.TestVocabulary", "bin", baseDir.Parent!.Name, baseDir.Name, "MetaObjects.TestVocabulary.dll"); + } + } + + private void WriteConfig(bool withProviders) + { + Directory.CreateDirectory(Path.Combine(_root, ".metaobjects")); + var providers = withProviders + ? ", \"providers\": [ { \"assembly\": \"" + VocabularyDll.Replace("\\", "\\\\") + + "\", \"type\": \"MetaObjects.TestVocabulary.TestVocabularyProvider\" } ]" + : ""; + File.WriteAllText(Path.Combine(_root, ".metaobjects", "config.json"), + "{ \"schema_version\": 1, \"csharp\": { \"namespace\": \"Acme\"" + providers + " } }"); + } + + private static int Quiet(Func run) + { + var (o, e) = (Console.Out, Console.Error); + Console.SetOut(TextWriter.Null); Console.SetError(TextWriter.Null); + try { return run(); } finally { Console.SetOut(o); Console.SetError(e); } + } + + [Fact] + public void Gen_and_verify_codegen_load_the_custom_vocabulary_when_listed() + { + WriteConfig(withProviders: true); + Assert.Equal(0, Quiet(() => CodegenCli.Run(["gen", Meta, "--out", Out], [new OneFile()]))); + Assert.True(File.Exists(Path.Combine(Out, "ok.txt"))); + Assert.Equal(0, Quiet(() => CodegenCli.Run(["verify", "--codegen", Meta, "--out", Out], [new OneFile()]))); + } + + [Fact] + public void Gen_fails_on_the_custom_vocabulary_without_the_provider() + { + WriteConfig(withProviders: false); + var err = new StringWriter(); + var prev = Console.Error; + Console.SetError(err); + try { Assert.NotEqual(0, CodegenCli.Run(["gen", Meta, "--out", Out], [new OneFile()])); } + finally { Console.SetError(prev); } + Assert.Contains("ERR_UNKNOWN_SUBTYPE", err.ToString()); + Assert.False(File.Exists(Path.Combine(Out, "ok.txt"))); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj b/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj index 60ca76cb1d..dc8ea823c6 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj +++ b/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj @@ -17,6 +17,8 @@ + + diff --git a/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs b/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs index cb278e8da0..a35cd1d072 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs @@ -55,6 +55,35 @@ public void RegisterTypes(TypeRegistry registry) } } +// Test-only — a project's own vocabulary: layout.picker + field.string attrs. Mirrors the TS +// reference (`exampleCustomVocabularyProvider`) and the shape a `csharp.providers` entry supplies. +internal sealed class ExampleCustomVocabularyProvider : IMetaDataTypeProvider +{ + public string Id => "example-custom-vocabulary"; + public IReadOnlyList Dependencies => new[] { "metaobjects-core-types" }; + + public void RegisterTypes(TypeRegistry registry) + { + registry.Register(new TypeDefinition( + typeId: new TypeId("layout", "picker"), + description: "A picker that chooses one row of the owning object — test-only.", + childRules: new List + { + new ChildRule(BaseTypes.TYPE_ATTR, Structural.CHILD_RULE_WILDCARD, Structural.CHILD_RULE_WILDCARD), + }, + factory: (typeId, name) => new MetaLayout(typeId, name), + attributes: new List + { + new AttrSchema("displayField", "string", Required: false, Description: "Field shown for each choice."), + })); + registry.Extend("field", "string", attributes: new List + { + new AttrSchema("searchable", "boolean", Required: false, Description: "Offer this field in search."), + new AttrSchema("valueLabels", "string", Required: false, Description: "Display labels for the field's values.", IsArray: true), + }); + } +} + internal sealed class NoopTestProvider(string id, params string[] deps) : IMetaDataTypeProvider { public string Id { get; } = id; @@ -140,6 +169,7 @@ public static class ConformanceAdapter ["metaobjects-template"] = MetaObjects.Template.PromptMetaDataProvider.Instance, // Test-only — provider-extension-* fixtures. ["example-template-briefing"] = new ExampleTemplateBriefingProvider(), + ["example-custom-vocabulary"] = new ExampleCustomVocabularyProvider(), ["cycle-a"] = new NoopTestProvider("cycle-a", "cycle-b"), ["cycle-b"] = new NoopTestProvider("cycle-b", "cycle-a"), ["depends-on-missing"] = new NoopTestProvider("depends-on-missing", "does-not-exist"), diff --git a/server/csharp/MetaObjects.TestVocabulary/MetaObjects.TestVocabulary.csproj b/server/csharp/MetaObjects.TestVocabulary/MetaObjects.TestVocabulary.csproj new file mode 100644 index 0000000000..11d8d65c62 --- /dev/null +++ b/server/csharp/MetaObjects.TestVocabulary/MetaObjects.TestVocabulary.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + enable + false + + + + + diff --git a/server/csharp/MetaObjects.TestVocabulary/TestVocabularyProvider.cs b/server/csharp/MetaObjects.TestVocabulary/TestVocabularyProvider.cs new file mode 100644 index 0000000000..aef72e23d7 --- /dev/null +++ b/server/csharp/MetaObjects.TestVocabulary/TestVocabularyProvider.cs @@ -0,0 +1,34 @@ +using MetaObjects.Meta; +using MetaObjects.Shared; + +namespace MetaObjects.TestVocabulary; + +/// Registers layout.picker and extends field.string with @searchable. +public sealed class TestVocabularyProvider : IMetaDataTypeProvider +{ + public string Id => "test-vocabulary"; + public IReadOnlyList Dependencies => ["metaobjects-core-types"]; + + public void RegisterTypes(TypeRegistry registry) + { + registry.Register(new TypeDefinition( + typeId: new TypeId("layout", "picker"), + description: "A picker that chooses one row of the owning object.", + childRules: [new ChildRule(BaseTypes.TYPE_ATTR, Structural.CHILD_RULE_WILDCARD, Structural.CHILD_RULE_WILDCARD)], + factory: (typeId, name) => new MetaLayout(typeId, name), + attributes: [new AttrSchema("displayField", "string", Required: false, Description: "Field shown for each choice.")])); + registry.Extend("field", "string", + attributes: [new AttrSchema("searchable", "boolean", Required: false, Description: "Offer this field in search.")]); + } +} + +/// No public parameterless constructor: refused by the loader. +public sealed class NeedsArgsProvider(string x) : IMetaDataTypeProvider +{ + public string Id => x; + public IReadOnlyList Dependencies => []; + public void RegisterTypes(TypeRegistry registry) { } +} + +/// Not a provider at all. +public sealed class NotAProvider { } diff --git a/server/csharp/MetaObjects.sln b/server/csharp/MetaObjects.sln index b364116d81..1cf5e3c4a3 100644 --- a/server/csharp/MetaObjects.sln +++ b/server/csharp/MetaObjects.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Cli", "MetaObje EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Cli.Tests", "MetaObjects.Cli.Tests\MetaObjects.Cli.Tests.csproj", "{5B55ECEC-72F4-4344-8629-69C0C79893F6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.TestVocabulary", "MetaObjects.TestVocabulary\MetaObjects.TestVocabulary.csproj", "{07E0DCA5-F951-44CA-9660-3C30E7EBA30B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,5 +62,9 @@ Global {5B55ECEC-72F4-4344-8629-69C0C79893F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {5B55ECEC-72F4-4344-8629-69C0C79893F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {5B55ECEC-72F4-4344-8629-69C0C79893F6}.Release|Any CPU.Build.0 = Release|Any CPU + {07E0DCA5-F951-44CA-9660-3C30E7EBA30B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07E0DCA5-F951-44CA-9660-3C30E7EBA30B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07E0DCA5-F951-44CA-9660-3C30E7EBA30B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07E0DCA5-F951-44CA-9660-3C30E7EBA30B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index a86dcbe822..841bee58fa 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -34,6 +34,10 @@ namespace MetaObjects.Config; /// The generated-code directory the project declared (csharp.outDir in the same /// file), absolute; null when it declared none. An explicit --out takes precedence. /// +/// +/// csharp.providers, already instantiated (null/empty: the core vocabulary only). Every +/// loader entry point composes its registry from these via . +/// /// /// FR-023 — the project's resolved metadata dependencies, read from the same file at EVERY rung of /// the ladder (the explicit directory included). Their committed snapshots load FIRST, ahead of @@ -42,8 +46,13 @@ namespace MetaObjects.Config; /// public sealed record ResolvedMetadata( string Directory, IReadOnlyList? Files, IReadOnlyList Libraries, PhysicalNaming Naming, - WireFormat? Wire = null, string? OutDir = null, ImportScope? Imports = null, string? Namespace = null) + WireFormat? Wire = null, string? OutDir = null, ImportScope? Imports = null, string? Namespace = null, + IReadOnlyList? Providers = null) { + /// The registry this metadata loads against: the core providers plus the project's + /// csharp.providers. Hosts that load by hand can pass it to MetaDataLoader.FromDirectory. + public TypeRegistry Registry() => Provider.ComposeRegistry(ProviderLoader.Compose(Providers)); + /// Load this metadata, with the project's libraries, and set the loaded /// root's from (ADR-0058) and its /// from . @@ -52,9 +61,9 @@ public LoadResult Load(bool strict = false) var leading = Imports?.Sources(); var result = Files is { } files ? MetaDataLoader.FromUris( - files.Select(f => new Uri(f)).ToList(), MetaDataLoader.DefaultRegistry(), Libraries, leading, strict) + files.Select(f => new Uri(f)).ToList(), Registry(), Libraries, leading, strict) : MetaDataLoader.FromDirectory( - Directory, MetaDataLoader.DefaultRegistry(), Libraries, leading, strict: strict); + Directory, Registry(), Libraries, leading, strict: strict); result.Root.PhysicalNaming = Naming; result.Root.WireFormat = Wire ?? WireFormat.Default; if (Imports is null) return result; @@ -77,6 +86,9 @@ public static class MetadataLocation /// The project a metadata directory belongs to: its PARENT, the directory holding /// metaobjects/ and .metaobjects/. /// + internal static IReadOnlyList? LoadProviders(NeutralConfig? cfg) => + cfg is { Providers.Count: > 0 } ? ProviderLoader.Load(cfg.Providers) : null; + public static string ProjectRootFor(string metadataDir) => Path.GetDirectoryName(Path.GetFullPath(metadataDir)) ?? System.IO.Directory.GetCurrentDirectory(); @@ -117,7 +129,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) // No config found means no project to read a lock from: nothing is imported. var explicitImports = explicitCfg is null ? ImportScope.None : ImportScope.Resolve(explicitCfg.ConfigDir, explicitCfg); return new ResolvedMetadata( - metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire, explicitCfg?.OutDir, explicitImports, explicitCfg?.Namespace); + metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire, explicitCfg?.OutDir, explicitImports, explicitCfg?.Namespace, LoadProviders(explicitCfg)); } var cfg = NeutralConfig.Read(cwd); @@ -132,7 +144,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) // No declared sources: resolve the DEFAULT directory through the same ladder // the shared conformance corpus gates (ERR_COLLECTION_NOT_FOUND when absent). var defaultFiles = SourceResolver.ResolveCollection(cwd); - return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace); + return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)); } if (specs.Count > 1) @@ -162,6 +174,6 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) "\"sources\" at the file's containing directory."); } - return new ResolvedMetadata(resolved, files, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace); + return new ResolvedMetadata(resolved, files, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)); } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index 4ec962d0e3..4b2eb8c078 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -98,9 +98,14 @@ public sealed class NeutralConfig /// emits such a type stops and asks for one. public string? Namespace { get; } + /// `csharp.providers` — the project's own metamodel providers (assembly path resolved against + /// the directory holding `.metaobjects/`, plus a type name). Empty when absent. Instantiated by + /// , not here, so reading a config never loads code. + public IReadOnlyList Providers { get; } + private NeutralConfig( IReadOnlyList> sources, IReadOnlyList libraries, - PhysicalNaming naming, WireFormat wire, (string? OutDir, string? Namespace) csharp, + PhysicalNaming naming, WireFormat wire, (string? OutDir, string? Namespace, IReadOnlyList Providers) csharp, IReadOnlyList dependencies, IReadOnlyList scopeInclude, IReadOnlyList? migrateScope, string configDir) { @@ -110,6 +115,7 @@ private NeutralConfig( ConfigDir = configDir; OutDir = csharp.OutDir; Namespace = csharp.Namespace; + Providers = csharp.Providers; Sources = sources; Libraries = libraries; Naming = naming; @@ -311,19 +317,26 @@ private static IReadOnlyList ReadDependencies(JsonElement root, /// `dotnet meta verify` diffs, and `namespace`, the namespace both generate under. The other /// ports ignore the block, as they ignore any key they do not own. An unknown key, a /// non-string or empty value, or a non-object block is refused, the same rule as `naming`. - private static (string? OutDir, string? Namespace) ReadCSharp(JsonElement root, string path, string configDir) + private static (string? OutDir, string? Namespace, IReadOnlyList Providers) ReadCSharp( + JsonElement root, string path, string configDir) { - if (!root.TryGetProperty("csharp", out var block)) return (null, null); + if (!root.TryGetProperty("csharp", out var block)) return (null, null, []); if (block.ValueKind != JsonValueKind.Object) throw new MetaModelException($"{path}: \"csharp\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); string? outDir = null, ns = null; + IReadOnlyList providers = []; foreach (var p in block.EnumerateObject()) { - if (p.Name is not ("outDir" or "namespace")) + if (p.Name is not ("outDir" or "namespace" or "providers")) throw new MetaModelException( - $"{path}: \"csharp\" has an unknown key \"{p.Name}\" (expected \"outDir\" and/or \"namespace\")", + $"{path}: \"csharp\" has an unknown key \"{p.Name}\" (expected \"outDir\", \"namespace\" and/or \"providers\")", ErrorCode.ERR_BAD_ATTR_VALUE); + if (p.Name == "providers") + { + providers = ReadProviders(p.Value, path, configDir); + continue; + } if (p.Value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(p.Value.GetString())) throw new MetaModelException( $"{path}: \"csharp.{p.Name}\" must be a non-empty string", ErrorCode.ERR_BAD_ATTR_VALUE); @@ -332,7 +345,41 @@ private static (string? OutDir, string? Namespace) ReadCSharp(JsonElement root, else ns = p.Value.GetString()!; } - return (outDir, ns); + return (outDir, ns, providers); + } + + /// `csharp.providers`: an array of `{ "assembly", "type" }` objects. Both keys are required + /// non-empty strings; any other key is refused, like the rest of the `csharp` block. + private static IReadOnlyList ReadProviders(JsonElement value, string path, string configDir) + { + if (value.ValueKind != JsonValueKind.Array) + throw new MetaModelException( + $"{path}: \"csharp.providers\" must be an array of {{ \"assembly\", \"type\" }} objects", ErrorCode.ERR_BAD_ATTR_VALUE); + var specs = new List(); + var i = 0; + foreach (var entry in value.EnumerateArray()) + { + var at = $"csharp.providers[{i++}]"; + if (entry.ValueKind != JsonValueKind.Object) + throw new MetaModelException($"{path}: \"{at}\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + string? asm = null, type = null; + foreach (var f in entry.EnumerateObject()) + { + if (f.Name is not ("assembly" or "type")) + throw new MetaModelException( + $"{path}: \"{at}\" has an unknown key \"{f.Name}\" (expected \"assembly\" and \"type\")", + ErrorCode.ERR_BAD_ATTR_VALUE); + if (f.Value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(f.Value.GetString())) + throw new MetaModelException( + $"{path}: \"{at}.{f.Name}\" must be a non-empty string", ErrorCode.ERR_BAD_ATTR_VALUE); + if (f.Name == "assembly") asm = f.Value.GetString()!; else type = f.Value.GetString()!; + } + if (asm is null || type is null) + throw new MetaModelException( + $"{path}: \"{at}\" needs both \"assembly\" and \"type\"", ErrorCode.ERR_BAD_ATTR_VALUE); + specs.Add(new ProviderSpec(Path.GetFullPath(asm, Path.GetFullPath(configDir)), type, path)); + } + return specs; } /// The `wire` block (ADR-0019 Amendment 2): an unknown key, an unknown value, or a diff --git a/server/csharp/MetaObjects/Config/ProviderLoader.cs b/server/csharp/MetaObjects/Config/ProviderLoader.cs new file mode 100644 index 0000000000..fcd12daa40 --- /dev/null +++ b/server/csharp/MetaObjects/Config/ProviderLoader.cs @@ -0,0 +1,91 @@ +// `csharp.providers` — loads project-supplied IMetaDataTypeProvider classes for the dev tools. +// +// This is dev-tool PLUGIN loading (`dotnet meta`, an ejected codegen/ runner), the C# counterpart +// of Python's `module:symbol` config providers. It is NOT runtime type binding: ADR-0001 forbids +// reflection for binding metadata to native types in GENERATED code, and nothing here touches that. + +using System.Reflection; +using System.Runtime.Loader; + +namespace MetaObjects.Config; + +/// One csharp.providers entry: an assembly path (absolute) and a full type name. +public sealed record ProviderSpec(string AssemblyPath, string TypeName, string ConfigPath); + +public static class ProviderLoader +{ + /// The providers a metadata load composes: the core library set, then the project's own. + public static IReadOnlyList Compose(IReadOnlyList? extra) + => extra is { Count: > 0 } ? [.. CoreTypes.LibraryProviders, .. extra] : CoreTypes.LibraryProviders; + + /// Instantiate every spec. Throws (ERR_BAD_ATTR_VALUE) naming the config key. + public static IReadOnlyList Load(IReadOnlyList specs) + { + var result = new List(specs.Count); + foreach (var spec in specs) result.Add(LoadOne(spec)); + return result; + } + + private static MetaModelException Refuse(ProviderSpec s, string why) => + new($"{s.ConfigPath}: \"csharp.providers\" entry (assembly \"{s.AssemblyPath}\", type \"{s.TypeName}\"): {why}", + ErrorCode.ERR_BAD_ATTR_VALUE); + + private static IMetaDataTypeProvider LoadOne(ProviderSpec spec) + { + if (!File.Exists(spec.AssemblyPath)) + throw Refuse(spec, $"the assembly file does not exist at {spec.AssemblyPath}. Build the vocabulary project first " + + "(for example `dotnet build `), then re-run."); + + var assembly = LoadAssembly(spec); + var type = assembly.GetType(spec.TypeName, throwOnError: false); + if (type is null) + throw Refuse(spec, $"type \"{spec.TypeName}\" was not found in the assembly (use the full, namespace-qualified name)."); + if (!typeof(IMetaDataTypeProvider).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface) + throw Refuse(spec, $"type \"{spec.TypeName}\" is not a concrete class implementing IMetaDataTypeProvider."); + if (type.GetConstructor(Type.EmptyTypes) is null) + throw Refuse(spec, $"type \"{spec.TypeName}\" has no public parameterless constructor."); + try + { + return (IMetaDataTypeProvider)Activator.CreateInstance(type)!; + } + catch (TargetInvocationException ex) + { + throw Refuse(spec, $"its constructor threw {ex.InnerException?.GetType().Name}: {ex.InnerException?.Message}"); + } + } + + private static readonly HashSet HookedPaths = new(StringComparer.Ordinal); + private static readonly object Gate = new(); + + private static Assembly LoadAssembly(ProviderSpec spec) + { + var name = AssemblyName.GetAssemblyName(spec.AssemblyPath); + // Reuse an assembly of the same simple name already in the process (the codegen project may + // reference the vocabulary project directly), so type identity is not split. + var loaded = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, name.Name, StringComparison.Ordinal)); + if (loaded is not null) return loaded; + + var resolver = new AssemblyDependencyResolver(spec.AssemblyPath); + var dir = Path.GetDirectoryName(spec.AssemblyPath)!; + lock (Gate) + { + if (HookedPaths.Add(spec.AssemblyPath)) + { + AssemblyLoadContext.Default.Resolving += (ctx, requested) => + { + // MetaObjects itself is already bound in the default context; the Default context + // only raises Resolving for assemblies it could not find, so this never shadows it. + var path = resolver.ResolveAssemblyToPath(requested); + if (path is null) + { + var sibling = Path.Combine(dir, requested.Name + ".dll"); + if (File.Exists(sibling)) path = sibling; + } + return path is null ? null : ctx.LoadFromAssemblyPath(path); + }; + } + } + return AssemblyLoadContext.Default.LoadFromAssemblyPath(spec.AssemblyPath); + } +} diff --git a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java index 2038115fdb..7dc3989457 100644 --- a/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java +++ b/server/java/core-spring/src/main/java/com/metaobjects/spring/MetaDataLoaderConfiguration.java @@ -5,6 +5,9 @@ import com.metaobjects.loader.LoaderOptions; import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.MetaDataTypeProvider; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -42,6 +45,10 @@ * physical-naming-config: "classpath:.metaobjects/config.json" * } * + *

Project vocabulary: declare each {@link MetaDataTypeProvider} as a bean + * ({@code @Bean MetaDataTypeProvider contactVocabulary() { return new ContactVocabulary(); }}) + * and the application loader composes it.

+ * *

Or use @Bean methods directly:

*
{@code
  * @Configuration
@@ -82,6 +89,14 @@ public class MetaDataLoaderConfiguration {
      */
     private String physicalNamingConfig;
 
+    /**
+     * A project's own vocabulary: every {@link MetaDataTypeProvider} bean in the context, in
+     * {@code @Order} order, is composed after the metamodel set into the application loader's
+     * registry (sealed and strict, like the default). Null when this class is built by hand.
+     */
+    @Autowired(required = false)
+    private ObjectProvider typeProviders;
+
     /**
      * Creates MetaDataLoader from application properties.
      *
@@ -111,6 +126,9 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception {
         // Preserve the pre-unification strict=true default (the no-options
         // fromUris overload routes through createManual which uses strict=false).
         LoaderOptions opts = LoaderOptions.create(false, false, true);
+        if (typeProviders != null) {
+            opts.setProviders(typeProviders.orderedStream().toList());
+        }
         // ADR-0058: the naming is resolved BEFORE the load call so the loader's own
         // "no config found" WARN fires only when neither this property, the default
         // classpath:.metaobjects/config.json, nor the file: walk found a project config.
diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java
new file mode 100644
index 0000000000..b6eef28dea
--- /dev/null
+++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java
@@ -0,0 +1,65 @@
+package com.metaobjects.spring;
+
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import org.junit.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A {@link MetaDataTypeProvider} bean in the context is composed into the application
+ * loader's registry, so metadata using the project's own vocabulary loads strict and clean.
+ */
+public class MetaDataLoaderProvidersTest {
+
+    public static final class PickerLayout extends MetaLayout {
+        public PickerLayout(String name) { super("picker", name); }
+    }
+
+    static MetaDataTypeProvider contactVocabulary() {
+        return new MetaDataTypeProvider() {
+            @Override public String getProviderId() { return "spring-test-contact-vocabulary"; }
+            @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+            @Override public void registerTypes(MetaDataRegistry registry) {
+                registry.registerType(PickerLayout.class, def -> {
+                    def.type("layout").subType("picker").inheritsFrom("layout", "base");
+                    def.optionalAttributeWithConstraints("displayField")
+                       .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+                });
+                registry.extendType(StringField.class, def ->
+                    def.optionalAttributeWithConstraints("searchable")
+                       .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+            }
+        };
+    }
+
+    private final ApplicationContextRunner runner = new ApplicationContextRunner()
+        .withConfiguration(AutoConfigurations.of(MetaDataAutoConfiguration.class))
+        .withPropertyValues("metaobjects.metadata-sources=classpath:metadata/test-custom-vocabulary.json");
+
+    @Test
+    public void aProviderBeanIsComposedIntoTheApplicationLoader() {
+        runner.withBean(MetaDataTypeProvider.class, MetaDataLoaderProvidersTest::contactVocabulary)
+            .run(ctx -> {
+                MetaDataLoader loader = ctx.getBean(MetaDataLoader.class);
+                assertThat(loader.getErrors()).isEmpty();
+                assertThat(loader.getTypeRegistry().getTypeDefinition("layout", "picker")).isNotNull();
+            });
+    }
+
+    @Test
+    public void withoutTheProviderBeanTheVocabularyIsRefused() {
+        runner.run(ctx -> {
+            MetaDataLoader loader = ctx.getBean(MetaDataLoader.class);
+            assertThat(loader.getErrors()).extracting(e -> e.getCode().get().name())
+                .containsExactly("ERR_UNKNOWN_SUBTYPE", "ERR_UNKNOWN_ATTR");
+        });
+    }
+}
diff --git a/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json b/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json
new file mode 100644
index 0000000000..62bbe85046
--- /dev/null
+++ b/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json
@@ -0,0 +1,11 @@
+{
+  "metadata.root": {
+    "package": "acme::contacts",
+    "children": [
+      { "object.value": { "name": "Contact", "children": [
+        { "field.string": { "name": "name", "@searchable": true } },
+        { "layout.picker": { "name": "contactPicker", "@displayField": "name" } }
+      ] } }
+    ]
+  }
+}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index 7551d86102..f1df2868a6 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -336,6 +336,61 @@ public void setLoader(LoaderParam loaderConfig) {
         this.loaderConfig = loaderConfig;
     }
 
+    /**
+     * A project's own metamodel vocabulary: fully-qualified names of
+     * {@link com.metaobjects.registry.MetaDataTypeProvider} classes (public, no-arg constructor)
+     * composed after the metamodel set for every goal that loads metadata
+     * ({@code generate}, {@code verify}, {@code fmt}, {@code editor}). Explicit list only — no
+     * classpath scan, so a library jar's own SPI providers never leak into the measured vocabulary.
+     * The classes are loaded through the project class loader: they must be in a dependency module
+     * or in the plugin's own {@code }; a provider in the module being built is not
+     * compiled yet at {@code generate-sources}.
+     */
+    @Parameter(property = "meta.providers")
+    public List providers;
+
+    private List resolvedProviders;
+
+    /** Instantiate {@link #providers} (once per execution) through {@code projectClassLoader}. */
+    protected List resolveProviders(ClassLoader projectClassLoader)
+            throws MojoExecutionException {
+        if (resolvedProviders != null) return resolvedProviders;
+        List out = new ArrayList<>();
+        if (providers != null) {
+            for (String raw : providers) {
+                String className = raw == null ? "" : raw.trim();
+                if (className.isEmpty()) {
+                    throw new MojoExecutionException(" contains an empty class name");
+                }
+                Class c;
+                try {
+                    c = Class.forName(className, true, projectClassLoader);
+                } catch (ClassNotFoundException | LinkageError ex) {
+                    throw new MojoExecutionException(" class [" + className + "] was not found. "
+                        + "A provider must be on the plugin's classpath: in a dependency module of this project "
+                        + "or in the plugin's . A provider in the same module is not compiled yet "
+                        + "at generate-sources, so it cannot be used here.", ex);
+                }
+                if (!com.metaobjects.registry.MetaDataTypeProvider.class.isAssignableFrom(c)) {
+                    throw new MojoExecutionException(" class [" + className + "] does not implement "
+                        + com.metaobjects.registry.MetaDataTypeProvider.class.getName());
+                }
+                try {
+                    out.add((com.metaobjects.registry.MetaDataTypeProvider)
+                        c.getConstructor().newInstance());
+                } catch (NoSuchMethodException ex) {
+                    throw new MojoExecutionException(" class [" + className
+                        + "] needs a public no-argument constructor", ex);
+                } catch (ReflectiveOperationException ex) {
+                    throw new MojoExecutionException(" class [" + className
+                        + "] could not be instantiated: " + ex, ex);
+                }
+            }
+        }
+        resolvedProviders = List.copyOf(out);
+        return resolvedProviders;
+    }
+
     @Parameter
     public Map globals;
     public Map getGlobals() {
@@ -426,6 +481,7 @@ public void execute() throws MojoExecutionException, MojoFailureException
         warnIfAgentContextStale();
 
         ClassLoader projectClassLoader = createProjectClassLoader();
+        resolveProviders(projectClassLoader);
 
         MetaDataLoader loader = createLoader(projectClassLoader);
 
@@ -607,6 +663,14 @@ protected void warnIfAgentContextStale() {
 
     protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
 
+        // Normally primed by the goal's execute() (which reports a bad  entry as a
+        // MojoExecutionException); a direct caller that skipped that gets the same message unchecked.
+        List extraProviders;
+        try {
+            extraProviders = resolveProviders(projectClassLoader);
+        } catch (MojoExecutionException e) {
+            throw new MetaDataException(e.getMessage(), e);
+        }
         LoaderConfigurable configurable = null;
         String loaderClass = loaderConfig.getClassname();
         String loaderName = loaderConfig.getName();
@@ -625,6 +689,12 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
                     MetaDataLoader.SUBTYPE_MANUAL, loaderName);
         }
 
+        // The project's own vocabulary (): the loader composes its registry from the
+        // metamodel set plus these, sealed and strict like the default.
+        if (!extraProviders.isEmpty()) {
+            configurable.getLoader().getLoaderOptions().setProviders(extraProviders);
+        }
+
         // Configure the loader using the new pattern
         String sourceDir = null;
         File srcDir = getSourceDir();
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
index a346341ffe..ef95c0bcb9 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
@@ -64,6 +64,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
         warnIfAgentContextStale();
 
         ClassLoader projectClassLoader = createProjectClassLoader();
+        resolveProviders(projectClassLoader); // a bad  entry fails here, before any load
         // ONE real directory walk — to learn the resolved file list via the full
         // //.metaobjects/config.json ladder, and to
         // fail fast (via createLoader's own failOnLoaderErrors) if the project
@@ -77,6 +78,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
         List targets = resolveTargets(allFiles);
 
         boolean strict = !isLax();
+        // Every fresh reload in the safety check carries the same  as the baseline.
+        List extraProviders = resolveProviders(projectClassLoader);
         String loaderName = getLoader().getName();
         List libraries = getLoader().getLibraries();
         List librarySources =
@@ -97,7 +100,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
                 // .load(List) requires init() to have run first, but takes the actual
                 // source list as a separate call — see FmtSupport.buildSources.
                 MetaDataLoader fresh = new MetaDataLoader(
-                    LoaderOptions.create(false, false, strict), MetaDataLoader.SUBTYPE_MANUAL,
+                    LoaderOptions.create(false, false, strict).setProviders(extraProviders), MetaDataLoader.SUBTYPE_MANUAL,
                     loaderName);
                 fresh.init();
                 return fresh;
@@ -110,7 +113,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
                 // a loader name may not — that is a SEPARATE identifier, the `sourceId`
                 // CanonicalJsonParser takes, for provenance only).
                 MetaDataLoader standalone = new MetaDataLoader(
-                    LoaderOptions.create(false, false, strict), MetaDataLoader.SUBTYPE_MANUAL,
+                    LoaderOptions.create(false, false, strict).setProviders(extraProviders), MetaDataLoader.SUBTYPE_MANUAL,
                     loaderNameForFile);
                 standalone.init();
                 return standalone;
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
index c5f7dc14fa..964a29a5cb 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
@@ -341,6 +341,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti
         }
 
         ClassLoader projectClassLoader = createProjectClassLoader();
+        resolveProviders(projectClassLoader);
         MetaDataLoader loader = createLoader(projectClassLoader);
         runFieldLintAdvisory(loader);
         int requirementErrors = runRequirementGate(loader);
@@ -376,6 +377,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti
 
     private void verifyCodegen() throws MojoExecutionException, MojoFailureException {
         ClassLoader projectClassLoader = createProjectClassLoader();
+        resolveProviders(projectClassLoader);
         MetaDataLoader loader = createLoader(projectClassLoader);
         runFieldLintAdvisory(loader);
         int requirementErrors = runRequirementGate(loader);
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java
new file mode 100644
index 0000000000..8a1d4df313
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java
@@ -0,0 +1,169 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.generator.kotlin.KotlinEntityGenerator;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The {@code } parameter reaches every goal that loads metadata: a project's own
+ * vocabulary loads under generate, verify and fmt, and is refused (strict) without it.
+ */
+public class MojoProvidersTest {
+
+    public static final class PickerLayout extends MetaLayout {
+        public PickerLayout(String name) { super("picker", name); }
+    }
+
+    /** Public, no-arg: what {@code } instantiates. */
+    public static final class ContactVocabulary implements MetaDataTypeProvider {
+        @Override public String getProviderId() { return "mojo-test-contact-vocabulary"; }
+        @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+        @Override public void registerTypes(MetaDataRegistry registry) {
+            registry.registerType(PickerLayout.class, def -> {
+                def.type("layout").subType("picker").inheritsFrom("layout", "base");
+                def.optionalAttributeWithConstraints("displayField")
+                   .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+            });
+            registry.extendType(StringField.class, def ->
+                def.optionalAttributeWithConstraints("searchable")
+                   .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+        }
+    }
+
+    public static final class NoPublicCtor implements MetaDataTypeProvider {
+        public NoPublicCtor(String ignored) { }
+        @Override public String getProviderId() { return "x"; }
+        @Override public void registerTypes(MetaDataRegistry registry) { }
+    }
+
+    private static final String PROVIDER = ContactVocabulary.class.getName();
+
+    private static final String CONTACTS =
+        "{\n  \"metadata.root\": {\n    \"package\": \"examples\",\n    \"children\": [\n"
+        + "      {\n        \"object.value\": {\n          \"name\": \"Contact\",\n"
+        + "          \"children\": [\n"
+        + "            {\n              \"field.string\": {\n                \"name\": \"name\",\n"
+        + "                \"@searchable\": true\n              }\n            },\n"
+        + "            {\n              \"layout.picker\": {\n                \"name\": \"contactPicker\",\n"
+        + "                \"@displayField\": \"name\"\n              }\n            }\n"
+        + "          ]\n        }\n      }\n    ]\n  }\n}\n";
+
+    private Path dir;
+    private Path out;
+
+    @Before
+    public void setUp() throws Exception {
+        dir = Files.createTempDirectory("mojo-providers-meta");
+        out = Files.createTempDirectory("mojo-providers-out");
+        Files.writeString(dir.resolve("meta.contacts.json"), CONTACTS, StandardCharsets.UTF_8);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        for (Path root : List.of(dir, out)) {
+            try (var walk = Files.walk(root)) {
+                walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
+                    try { Files.delete(p); } catch (Exception ignored) { }
+                });
+            }
+        }
+    }
+
+    private void configure(AbstractMetaDataMojo mojo, String... providerClasses) {
+        mojo.setLoader(LoaderParam.builder("providers-test")
+            .withClassname("com.metaobjects.loader.MetaDataLoader")
+            .withSourceDir(dir.toString())
+            .build());
+        mojo.setGenerators(new ArrayList<>(List.of(GeneratorParam.builder(KotlinEntityGenerator.class.getName())
+            .withArg("outputDir", out.toString()).build())));
+        mojo.setGlobals(Collections.emptyMap());
+        mojo.providers = providerClasses.length == 0 ? null : List.of(providerClasses);
+    }
+
+    @Test
+    public void generateRefusesTheVocabularyWithoutProviders() {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen);
+        Exception e = assertThrows(Exception.class, gen::execute);
+        assertTrue(String.valueOf(e.getMessage()), e.getMessage().contains("strict provenance (2 error(s))"));
+    }
+
+    @Test
+    public void generateLoadsTheVocabularyWithProviders() throws Exception {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen, PROVIDER);
+        gen.execute();
+    }
+
+    @Test
+    public void verifyLoadsTheVocabularyWithProviders() throws Exception {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen, PROVIDER);
+        gen.execute();
+
+        MetaDataVerifyMojo verify = new MetaDataVerifyMojo();
+        configure(verify, PROVIDER);
+        verify.execute();
+
+        MetaDataVerifyMojo without = new MetaDataVerifyMojo();
+        configure(without);
+        assertThrows(Exception.class, without::execute);
+    }
+
+    @Test
+    public void fmtLoadsTheVocabularyWithProvidersInEveryReload() throws Exception {
+        MetaDataFmtMojo fmt = new MetaDataFmtMojo();
+        configure(fmt, PROVIDER);
+        fmt.execute();
+        assertEquals(CONTACTS, Files.readString(dir.resolve("meta.contacts.json"), StandardCharsets.UTF_8));
+
+        MetaDataFmtMojo without = new MetaDataFmtMojo();
+        configure(without);
+        assertThrows(Exception.class, without::execute);
+    }
+
+    @Test
+    public void aMissingClassNamesTheClasspathRule() {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen, "com.example.DoesNotExist");
+        MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+        assertTrue(e.getMessage(), e.getMessage().contains("com.example.DoesNotExist"));
+        assertTrue(e.getMessage(), e.getMessage().contains("plugin's classpath"));
+        assertTrue(e.getMessage(), e.getMessage().contains("not compiled yet at generate-sources"));
+    }
+
+    @Test
+    public void aNonProviderClassIsRefused() {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen, String.class.getName());
+        MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+        assertTrue(e.getMessage(), e.getMessage().contains("does not implement"));
+    }
+
+    @Test
+    public void aProviderWithoutAPublicNoArgConstructorIsRefused() {
+        MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+        configure(gen, NoPublicCtor.class.getName());
+        MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+        assertTrue(e.getMessage(), e.getMessage().contains("public no-argument constructor"));
+    }
+}
diff --git a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
index b2516ce973..1207ca61fb 100644
--- a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
+++ b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
@@ -4,6 +4,7 @@ import com.metaobjects.loader.DirectorySource
 import com.metaobjects.loader.LoaderOptions
 import com.metaobjects.loader.MetaDataLoader
 import com.metaobjects.loader.MetaDataSource.MetaDataFormat
+import com.metaobjects.registry.MetaDataTypeProvider
 import java.net.URI
 import java.nio.file.Path
 
@@ -13,26 +14,41 @@ import java.nio.file.Path
  * [MetaDataLoader] static factory.
  */
 
-/** Load metadata from a filesystem directory; pass [DirectorySource.Options] to tune expansion. */
+/**
+ * The options a `providers` argument rides on. Empty [providers] returns [opts] untouched (so the
+ * default load is byte-identical); otherwise the providers are set on [opts], or on the
+ * `createManual` defaults (lax, quiet, unregistered) when [opts] is null.
+ */
+private fun withProviders(opts: LoaderOptions?, providers: List): LoaderOptions? =
+    if (providers.isEmpty()) opts
+    else (opts ?: LoaderOptions.create(false, false, false)).setProviders(providers)
+
+/**
+ * Load metadata from a filesystem directory; pass [DirectorySource.Options] to tune expansion and
+ * [providers] to add a project's own vocabulary (composed after the metamodel set, sealed, strict).
+ */
 fun loadDirectory(
     name: String,
     directory: Path,
     opts: DirectorySource.Options = DirectorySource.Options(),
-): MetaDataLoader = MetaDataLoader.fromDirectory(name, directory, opts)
+    providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromDirectory(name, directory, opts, null, providers)
 
-/** Load metadata from a list of URIs; pass [LoaderOptions] to tune (e.g. `strict=true`). */
+/** Load metadata from a list of URIs; pass [LoaderOptions] to tune (e.g. `strict=true`) and [providers] for a project's own vocabulary. */
 fun loadUris(
     name: String,
     uris: List,
     opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromUris(name, uris, opts)
+    providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromUris(name, uris, withProviders(opts, providers))
 
-/** Load metadata from classpath resource paths; pass [LoaderOptions] to tune. */
+/** Load metadata from classpath resource paths; pass [LoaderOptions] to tune and [providers] for a project's own vocabulary. */
 fun loadResources(
     name: String,
     resources: List,
     opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromResources(name, resources, opts)
+    providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromResources(name, resources, withProviders(opts, providers))
 
 /**
  * Load metadata from a single inline string (defaults to JSON); pass [LoaderOptions] to
@@ -48,4 +64,5 @@ fun loadString(
     content: String,
     format: MetaDataFormat = MetaDataFormat.JSON,
     opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromString(name, content, format, opts)
+    providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromString(name, content, format, withProviders(opts, providers))
diff --git a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt
new file mode 100644
index 0000000000..b969889078
--- /dev/null
+++ b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt
@@ -0,0 +1,93 @@
+package com.metaobjects.metadata.ktx
+
+import com.metaobjects.field.StringField
+import com.metaobjects.io.json.CanonicalJsonSerializer
+import com.metaobjects.layout.MetaLayout
+import com.metaobjects.loader.LoaderOptions
+import com.metaobjects.registry.MetaDataRegistry
+import com.metaobjects.registry.MetaDataTypeProvider
+import com.metaobjects.source.JsonSource
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * A project's own vocabulary, written in Kotlin and passed to the metadata-ktx loader as
+ * `providers = listOf(...)`. Runs the two shared `provider-custom-vocabulary-*` conformance
+ * fixtures: loaded with the provider the input matches `expected.json`; loaded without it the
+ * strict load refuses it with the expected codes, in order, at the expected JSON paths.
+ */
+class ProviderVocabularyTest {
+
+    /** The project's own layout subtype. */
+    class PickerLayout(name: String) : MetaLayout("picker", name)
+
+    /** `layout.picker` + `field.string` gains `@searchable` / `@valueLabels`. */
+    class ContactVocabulary : MetaDataTypeProvider {
+        override fun getProviderId() = "example-custom-vocabulary"
+
+        // The Java port's real provider ids; the cross-port alias "metaobjects-core-types" is
+        // not a Java provider id.
+        override fun getDependencies() = arrayOf("field-types", "layout-types")
+
+        override fun registerTypes(registry: MetaDataRegistry) {
+            registry.registerType(PickerLayout::class.java) { def ->
+                def.type("layout").subType("picker").inheritsFrom("layout", "base")
+                def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle()
+            }
+            registry.extendType(StringField::class.java) { def ->
+                def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle()
+                def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray()
+            }
+        }
+    }
+
+    private fun fixtures(): Path {
+        var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
+        while (p != null && !Files.exists(p.resolve("fixtures/conformance"))) p = p.parent
+        assertTrue(p != null, "could not locate fixtures/conformance from user.dir")
+        return p!!.resolve("fixtures/conformance")
+    }
+
+    private fun read(p: Path) = String(Files.readAllBytes(p), StandardCharsets.UTF_8)
+
+    private val strict = { LoaderOptions.create(false, false, true) }
+
+    @Test fun `success fixture loads with providers and matches the canonical output`() {
+        val dir = fixtures().resolve("provider-custom-vocabulary-success")
+        val loader = loadString(
+            "examples", read(dir.resolve("input/meta.contacts.json")),
+            opts = strict(), providers = listOf(ContactVocabulary()),
+        )
+        assertTrue(loader.errors.isEmpty(), "unexpected errors: ${loader.errors}")
+        assertEquals(
+            read(dir.resolve("expected.json")).trim(),
+            CanonicalJsonSerializer.canonicalSerialize(loader.root).trim(),
+        )
+    }
+
+    @Test fun `missing-provider fixture is refused with the expected codes in order`() {
+        val dir = fixtures().resolve("provider-custom-vocabulary-missing-provider-fails")
+        val loader = loadString("examples", read(dir.resolve("input/meta.contacts.json")), opts = strict())
+        val got = loader.errors.map {
+            it.code.get().name to (it.envelope.get() as JsonSource).jsonPath()
+        }
+        val base = "\$['metadata.root'].children[0]['object.value'].children"
+        assertEquals(
+            listOf(
+                "ERR_UNKNOWN_SUBTYPE" to "$base[2]['layout.picker']",
+                "ERR_UNKNOWN_ATTR" to "$base[0]['field.string']",
+                "ERR_UNKNOWN_ATTR" to "$base[1]['field.string']",
+            ),
+            got,
+        )
+    }
+
+    @Test fun `no providers leaves the default registry in place`() {
+        val loader = loadString("x", """{ "metadata.root": { "package": "acme", "children": [] } }""")
+        assertTrue(loader.typeRegistry.isSealed)
+    }
+}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
index e4759bd999..74ce2192e3 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
@@ -1,5 +1,10 @@
 package com.metaobjects.loader;
 
+import com.metaobjects.registry.MetaDataTypeProvider;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
 import java.util.Objects;
 
 
@@ -13,6 +18,9 @@ public class LoaderOptions {
     private boolean verbose = true;
     private boolean strict = true;
 
+    /** Extra vocabulary providers composed into the loader's registry; empty = the shared default. */
+    private List providers = List.of();
+
     public LoaderOptions() {}
 
     public static LoaderOptions create(boolean shouldRegister, boolean verbose, boolean strict ) {
@@ -53,6 +61,22 @@ public boolean isStrict() {
         return strict;
     }
 
+    /**
+     * A project's own vocabulary: the loader composes
+     * {@link com.metaobjects.registry.RegistryManifest#composeLoaderRegistry(Collection)} with these
+     * providers appended after the metamodel set, instead of using the shared default registry.
+     * Empty (the default) leaves loading byte-identical to a loader given no providers.
+     */
+    public LoaderOptions setProviders(Collection providers) {
+        this.providers = providers == null ? List.of() : List.copyOf(new ArrayList<>(providers));
+        return this;
+    }
+
+    /** The extra providers (never null, unmodifiable). */
+    public List getProviders() {
+        return providers;
+    }
+
     ///////////////////////////////////////////////////////////////////////////
     // Misc Functions
 
@@ -63,7 +87,8 @@ public boolean equals(Object o) {
         LoaderOptions that = (LoaderOptions) o;
         return shouldRegister == that.shouldRegister &&
                 verbose == that.verbose &&
-                strict == that.strict;
+                strict == that.strict &&
+                providers.equals(that.providers);
     }
 
     @Override
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
index 28b6c0326b..f09473d809 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
@@ -175,6 +175,7 @@ public class MetaDataLoader implements LoaderConfigurable {
     // this list AFTER catching the load's (final) thrown MetaDataException and
     // merges the two into a single per-fixture error set.
     private final List errors = new ArrayList<>();
+    private final List deferredAttrErrors = new ArrayList<>();
 
     // FR5c — envelope-shaped warnings accumulator (cross-port parallel channel).
     //
@@ -717,7 +718,21 @@ public static MetaDataLoader fromDirectory(String name, Path directory, Director
      */
     public static MetaDataLoader fromDirectory(String name, Path directory,
                                                DirectorySource.Options opts, List libraries) {
-        MetaDataLoader loader = createManual(false, name);
+        return fromDirectory(name, directory, opts, libraries, List.of());
+    }
+
+    /**
+     * {@link #fromDirectory(String, Path, DirectorySource.Options, List)} with a project's own
+     * vocabulary: {@code providers} are composed after the metamodel set (see
+     * {@link LoaderOptions#setProviders}). Empty is byte-identical to the overload without it.
+     *
+     * @param providers extra {@link com.metaobjects.registry.MetaDataTypeProvider}s; null or empty adds none
+     */
+    public static MetaDataLoader fromDirectory(String name, Path directory,
+                                               DirectorySource.Options opts, List libraries,
+                                               List providers) {
+        MetaDataLoader loader = new MetaDataLoader(
+            LoaderOptions.create(false, false, false).setProviders(providers), SUBTYPE_MANUAL, name);
         try {
             loader.init();
             List sources = new ArrayList<>(
@@ -1036,6 +1051,23 @@ public void addError(MetaDataException error) {
         errors.add(error);
     }
 
+    /**
+     * Record an {@code ERR_UNKNOWN_ATTR} found while parsing, to be appended to {@link #getErrors()}
+     * once every source of this batch has been parsed. The cross-port contract orders structural
+     * errors (unknown type / subtype, ...) ahead of attribute-schema errors, because the TypeScript
+     * reference checks attributes in a pass after the tree is built; recording this one inline would
+     * interleave it in document order (see {@code provider-custom-vocabulary-missing-provider-fails}).
+     */
+    public void addDeferredAttrError(MetaDataException error) {
+        if (error == null) return;
+        deferredAttrErrors.add(error);
+    }
+
+    private void flushDeferredAttrErrors() {
+        errors.addAll(deferredAttrErrors);
+        deferredAttrErrors.clear();
+    }
+
     /**
      * Clear accumulated errors. Called at the start of {@link #load(List)} so
      * a fresh batch does not see stale errors from a prior load on the same
@@ -1043,6 +1075,7 @@ public void addError(MetaDataException error) {
      */
     void clearErrors() {
         errors.clear();
+        deferredAttrErrors.clear();
     }
 
     /**
@@ -1158,11 +1191,24 @@ public MetaDataRegistry getTypeRegistry() {
             // ai*/json*/object.managed tooling vocabulary). Downstream apps that
             // need extra vocabulary use setTypeRegistry(compose(...)) — their own
             // unsealed registry.
-            typeRegistry = com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry();
+            typeRegistry = resolveDefaultTypeRegistry();
         }
         return typeRegistry;
     }
 
+    /**
+     * The registry this loader uses when none was set explicitly: the shared sealed default, or —
+     * when {@link LoaderOptions#getProviders()} is non-empty — a sealed registry composed from the
+     * metamodel set plus those providers (the sanctioned project-vocabulary path).
+     */
+    private com.metaobjects.registry.MetaDataRegistry resolveDefaultTypeRegistry() {
+        List extra =
+            loaderOptions == null ? List.of() : loaderOptions.getProviders();
+        return extra.isEmpty()
+            ? com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry()
+            : com.metaobjects.registry.RegistryManifest.composeLoaderRegistry(extra);
+    }
+
     public MetaDataLoader setTypeRegistry(MetaDataRegistry typeRegistry) {
         this.typeRegistry = typeRegistry;
         return this;
@@ -1441,7 +1487,7 @@ protected void initDefaultRegistries() {
         if (typeRegistry == null) {
             // ADR-0023 Decision 2 — default to the sealed defined-provider-set
             // registry (see getTypeRegistry()), not the polluted SPI singleton.
-            typeRegistry = com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry();
+            typeRegistry = resolveDefaultTypeRegistry();
             log.debug("Initialized default (sealed metamodel) MetaDataRegistry for loader: {}", getName());
         }
 
@@ -1796,6 +1842,7 @@ public MetaDataLoader load(List sources) {
             }
         } finally {
             deferringOverlays = false;
+            flushDeferredAttrErrors();
         }
 
         // ADR-0055 — apply every queued overlay now, before super resolution.
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
index e4fc2442cc..93873239a9 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
@@ -1364,7 +1364,7 @@ private void processAttributes(MetaData md, JsonObject body) {
             if (isStrictLoad()
                     && !isDeclaredAttribute(md, attrName)
                     && !isExemptPropertiesAttr(entry.getValue())) {
-                getLoader().addError(new MetaDataException(
+                getLoader().addDeferredAttrError(new MetaDataException(
                     "Unknown attribute '" + JSON_ATTR_PREFIX + attrName + "' on "
                         + md.getType() + "." + md.getSubType() + " '" + md.getName()
                         + "' in file [" + getFilename() + "] — not declared by any "
diff --git a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
index 596e86fd55..9c97e70d21 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
@@ -207,13 +207,7 @@ public static MetaDataRegistry defaultLoaderRegistry() {
             synchronized (DEFAULT_LOCK) {
                 r = defaultLoaderRegistry;
                 if (r == null) {
-                    r = composeMetamodelRegistry();
-                    // The core constraint set is normally lazy-loaded on first
-                    // getAllValidationConstraints() read — which, post-seal, would
-                    // happen DURING a load and trip the seal guard. Force it now,
-                    // while still unsealed, so the sealed registry is fully built.
-                    r.getAllValidationConstraints();
-                    r.seal();
+                    r = composeLoaderRegistry(List.of());
                     defaultLoaderRegistry = r;
                 }
             }
@@ -221,6 +215,32 @@ public static MetaDataRegistry defaultLoaderRegistry() {
         return r;
     }
 
+    /**
+     * The sanctioned way to give a loader a project's own vocabulary: compose the
+     * metamodel provider set PLUS {@code extra} (a project's {@link MetaDataTypeProvider}s),
+     * run the strict spec-description pipeline, and SEAL the result — exactly what
+     * {@link #defaultLoaderRegistry()} does for zero extras, so a project that adds
+     * vocabulary is held to the same strict provenance (ADR-0023) as one that does not.
+     * Every {@code MetaDataLoader} entry point that accepts providers
+     * ({@code LoaderOptions.setProviders}, the {@code from*} factories, the Maven
+     * plugin's {@code }, the Spring auto-configuration, the Kotlin facade)
+     * routes through here. Unlike {@code defaultLoaderRegistry()} the result is a NEW
+     * registry on every call.
+     *
+     * @param extra additional providers composed after the metamodel provider set
+     * @return a new, sealed registry
+     */
+    public static MetaDataRegistry composeLoaderRegistry(Collection extra) {
+        MetaDataRegistry r = composeMetamodelRegistry(extra);
+        // The core constraint set is normally lazy-loaded on first
+        // getAllValidationConstraints() read — which, post-seal, would
+        // happen DURING a load and trip the seal guard. Force it now,
+        // while still unsealed, so the sealed registry is fully built.
+        r.getAllValidationConstraints();
+        r.seal();
+        return r;
+    }
+
     private static volatile MetaDataRegistry defaultLoaderRegistry;
     private static final Object DEFAULT_LOCK = new Object();
 
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
index f067080630..ac113346be 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
@@ -362,6 +362,11 @@ static void runConformanceChecks(FixtureDiscovery.Fixture fix,
         String loaderName = detectLoaderName(inputFiles);
 
         LoaderOptions opts = LoaderOptions.create(false, false, true);
+        // A fixture naming the project-vocabulary provider loads through the SANCTIONED path:
+        // LoaderOptions.setProviders -> RegistryManifest.composeLoaderRegistry (sealed, strict).
+        if (fix.hasProvidersJson && fix.requiredProviders.contains("example-custom-vocabulary")) {
+            opts.setProviders(List.of(ConformanceTestProviders.EXAMPLE_CUSTOM_VOCABULARY));
+        }
         MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName);
         // A fixture that requires the test-only example-template-briefing provider
         // gets its OWN registry (core + briefing) via setTypeRegistry — no global
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
index 27cfb3f36f..3e6f25f3b2 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
@@ -17,7 +17,10 @@
  */
 package com.metaobjects.conformance;
 
+import com.metaobjects.attr.BooleanAttribute;
 import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
 import com.metaobjects.registry.MetaDataRegistry;
 import com.metaobjects.registry.MetaDataTypeProvider;
 import com.metaobjects.template.MetaTemplate;
@@ -66,6 +69,41 @@ public static void registerTypes(MetaDataRegistry registry) {
         @Override public String getDescription() { return "Test-only: registers a fictional template.briefing subtype."; }
     };
 
+    /** Test-only `layout.picker` node — a MetaLayout subclass, as a project's own node class would be. */
+    public static final class PickerLayout extends MetaLayout {
+        public PickerLayout(String name) {
+            super("picker", name);
+        }
+    }
+
+    /**
+     * A project's own vocabulary (provider-custom-vocabulary-* fixtures): registers
+     * {@code layout.picker} (+ optional {@code @displayField}) and extends
+     * {@code field.string} with optional {@code @searchable} (boolean) and
+     * {@code @valueLabels} (string array). Mirrors the TypeScript reference
+     * {@code exampleCustomVocabularyProvider}.
+     */
+    public static final MetaDataTypeProvider EXAMPLE_CUSTOM_VOCABULARY = new MetaDataTypeProvider() {
+        @Override public String getProviderId() { return "example-custom-vocabulary"; }
+        @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+        @Override public void registerTypes(MetaDataRegistry registry) {
+            registry.registerType(PickerLayout.class, def -> {
+                def.type("layout").subType("picker")
+                   .description("A picker that chooses one row of the owning object — test-only.")
+                   .inheritsFrom("layout", "base");
+                def.optionalAttributeWithConstraints("displayField")
+                   .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+            });
+            registry.extendType(StringField.class, def -> {
+                def.optionalAttributeWithConstraints("searchable")
+                   .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle();
+                def.optionalAttributeWithConstraints("valueLabels")
+                   .ofType(StringAttribute.SUBTYPE_STRING).asArray();
+            });
+        }
+        @Override public String getDescription() { return "Test-only: a project's own layout.picker + field.string attrs."; }
+    };
+
     public static final MetaDataTypeProvider CYCLE_A = noop("cycle-a", "cycle-b");
     public static final MetaDataTypeProvider CYCLE_B = noop("cycle-b", "cycle-a");
     public static final MetaDataTypeProvider DEPENDS_ON_MISSING = noop("depends-on-missing", "does-not-exist");
@@ -82,6 +120,7 @@ public static void registerTypes(MetaDataRegistry registry) {
      */
     public static final Map TEST_PROVIDERS = Map.of(
         "example-template-briefing", EXAMPLE_TEMPLATE_BRIEFING,
+        "example-custom-vocabulary", EXAMPLE_CUSTOM_VOCABULARY,
         "cycle-a", CYCLE_A,
         "cycle-b", CYCLE_B,
         "depends-on-missing", DEPENDS_ON_MISSING,
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
index b713282ab6..01b25e613d 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
@@ -158,6 +158,9 @@ private static final class Outcome {
     private static Outcome load(FixtureDiscovery.Fixture f, List files, List texts,
                                 String loaderName, MetaDataSource.MetaDataFormat format) {
         LoaderOptions opts = LoaderOptions.create(false, false, true);
+        if (f.hasProvidersJson && f.requiredProviders.contains("example-custom-vocabulary")) {
+            opts.setProviders(List.of(ConformanceTestProviders.EXAMPLE_CUSTOM_VOCABULARY));
+        }
         MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName);
         if (f.hasProvidersJson && f.requiredProviders.contains("example-template-briefing")) {
             com.metaobjects.registry.MetaDataRegistry reg =
diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java
new file mode 100644
index 0000000000..4b46d9e045
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java
@@ -0,0 +1,97 @@
+package com.metaobjects.loader;
+
+import com.metaobjects.MetaDataException;
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import com.metaobjects.registry.RegistryManifest;
+import org.junit.Test;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/** A project's own vocabulary reaches the loader through every {@code from*} factory. */
+public class LoaderProvidersTest {
+
+    /** A project's own layout subtype. */
+    public static final class PickerLayout extends MetaLayout {
+        public PickerLayout(String name) { super("picker", name); }
+    }
+
+    static final MetaDataTypeProvider PROVIDER = new MetaDataTypeProvider() {
+        @Override public String getProviderId() { return "loader-providers-test"; }
+        @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+        @Override public void registerTypes(MetaDataRegistry registry) {
+            registry.registerType(PickerLayout.class, def -> {
+                def.type("layout").subType("picker").inheritsFrom("layout", "base");
+                def.optionalAttributeWithConstraints("displayField")
+                   .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+            });
+            registry.extendType(StringField.class, def ->
+                def.optionalAttributeWithConstraints("searchable")
+                   .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+        }
+    };
+
+    static final String JSON = """
+        {"metadata.root": {"package": "p", "children": [
+          {"object.value": {"name": "Contact", "children": [
+            {"field.string": {"name": "name", "@searchable": true}},
+            {"layout.picker": {"name": "pick", "@displayField": "name"}}
+          ]}}
+        ]}}
+        """;
+
+    private static LoaderOptions strict() { return LoaderOptions.create(false, false, true); }
+
+    @Test public void fromStringComposesProviders() {
+        MetaDataLoader loader = MetaDataLoader.fromString("p", JSON,
+            MetaDataSource.MetaDataFormat.JSON, strict().setProviders(List.of(PROVIDER)));
+        assertTrue(loader.getErrors().isEmpty());
+        assertTrue(loader.getTypeRegistry().isSealed());
+        assertNotSame(RegistryManifest.defaultLoaderRegistry(), loader.getTypeRegistry());
+    }
+
+    @Test public void withoutProvidersTheStrictLoadRefusesAndDefaultRegistryIsShared() {
+        MetaDataLoader loader = MetaDataLoader.createManual(false, "p");
+        loader.getLoaderOptions().setStrict(true);
+        assertSame(RegistryManifest.defaultLoaderRegistry(), loader.getTypeRegistry());
+        MetaDataLoader refused = MetaDataLoader.fromString("p", JSON,
+            MetaDataSource.MetaDataFormat.JSON, strict());
+        assertEquals(List.of("ERR_UNKNOWN_SUBTYPE", "ERR_UNKNOWN_ATTR"),
+            refused.getErrors().stream().map(e -> e.getCode().get().name()).toList());
+    }
+
+    @Test public void fromDirectoryAndFromUrisAndFromResourcesAcceptProviders() throws Exception {
+        Path dir = Files.createTempDirectory("providers-test");
+        Path f = dir.resolve("meta.contacts.json");
+        Files.writeString(f, JSON);
+        MetaDataLoader d = MetaDataLoader.fromDirectory("p", dir,
+            new DirectorySource.Options(), null, List.of(PROVIDER));
+        assertTrue(d.getTypeRegistry().hasType("layout"));
+        assertNotNull(d.getTypeRegistry().getTypeDefinition("layout", "picker"));
+
+        MetaDataLoader u = MetaDataLoader.fromUris("p", List.of(URI.create(f.toUri().toString())),
+            strict().setProviders(List.of(PROVIDER)));
+        assertNotNull(u.getTypeRegistry().getTypeDefinition("layout", "picker"));
+    }
+
+    @Test public void composeLoaderRegistryIsFreshAndSealedEachCall() {
+        MetaDataRegistry a = RegistryManifest.composeLoaderRegistry(List.of(PROVIDER));
+        MetaDataRegistry b = RegistryManifest.composeLoaderRegistry(List.of(PROVIDER));
+        assertNotSame(a, b);
+        assertTrue(a.isSealed());
+    }
+
+    @Test public void defaultOptionsEqualityIgnoresNothingNew() {
+        assertEquals(LoaderOptions.create(false, false, true), LoaderOptions.create(false, false, true));
+        assertNotEquals(strict(), strict().setProviders(List.of(PROVIDER)));
+    }
+}
diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py
index 06817f3b69..d04d23e195 100644
--- a/server/python/src/metaobjects/cli.py
+++ b/server/python/src/metaobjects/cli.py
@@ -1178,8 +1178,9 @@ def _cmd_fmt(args: argparse.Namespace) -> int:
 
     Mirrors the TS reference surface exactly: no ``metadata_dir`` positional —
     files come from the neutral ``.metaobjects/config.json`` ladder
-    (:func:`resolve_metadata_location`, rungs 3-4; this command does not read
-    this port's own ``metaobjects.config.yaml`` rung 2, matching the scope the
+    (:func:`resolve_metadata_location`, rungs 3-4; this command reads only the
+    ``providers`` of this port's own ``metaobjects.config.yaml`` (never its rung 2
+    ``metadata`` location), matching the scope the
     C# and Java ports also keep for `fmt`), optionally narrowed by explicit
     file arguments.
     """
@@ -1191,6 +1192,20 @@ def _cmd_fmt(args: argparse.Namespace) -> int:
     providers_extra, providers_ok = _providers_from_args(args)
     if not providers_ok:
         return 1
+    # The project's own `providers:` (metaobjects.config.yaml) reach fmt too, merged with
+    # --provider. Only the providers are read from the config: the metadata location stays
+    # on the neutral ladder (see the docstring).
+    config_path = _find_config(args)
+    if config_path is not None:
+        try:
+            fmt_config = load_project_config(config_path)
+        except ConfigError as exc:
+            print(f"error: {exc}", file=sys.stderr)
+            return 1
+        config_providers, config_providers_ok = _config_providers(fmt_config)
+        if not config_providers_ok:
+            return 1
+        providers_extra = [*config_providers, *providers_extra]
     providers = [*core_providers, *providers_extra]
     registry = compose_registry(providers)
 
@@ -2587,11 +2602,17 @@ def unread(*lines: str) -> "_VerifyModel | None":
             config = load_project_config(config_path) if config_path is not None else None
             libraries: "list[str] | None" = None
             if config is not None:
-                providers, provider_errors = _resolve_config_providers(config)
-                if provider_errors:
+                # Merge, never overwrite: the project's providers AND --provider both apply.
+                flag_provider_errors = provider_errors
+                config_providers, config_provider_errors = _resolve_config_providers(config)
+                if config_provider_errors:
                     return unread(
-                        "error: invalid provider in config:", *(f"  {m}" for m in provider_errors)
+                        "error: invalid provider in config:",
+                        *(f"  {m}" for m in config_provider_errors),
                     )
+                if flag_provider_errors:
+                    return unread("error: invalid --provider:", *(f"  {m}" for m in flag_provider_errors))
+                providers = [*config_providers, *providers]
                 libraries = config.libraries
                 start = project_root_for(config.metadata_dir())
             else:
@@ -3117,7 +3138,14 @@ def _build_parser() -> argparse.ArgumentParser:
         action="append",
         default=None,
         metavar="MODULE:SYMBOL",
-        help="consumer-registered Provider(s), repeatable (module:symbol)",
+        help="consumer-registered Provider(s), repeatable (module:symbol); merged with the "
+        "`providers:` of metaobjects.config.yaml",
+    )
+    fmt_p.add_argument(
+        "--config",
+        default=None,
+        help="path to the metaobjects.config.yaml whose `providers:` apply "
+        "(default: ./metaobjects.config.yaml in cwd)",
     )
     fmt_p.set_defaults(func=_cmd_fmt)
 
diff --git a/server/python/src/metaobjects/loader/meta_data_loader.py b/server/python/src/metaobjects/loader/meta_data_loader.py
index 4589a04003..10aac86085 100644
--- a/server/python/src/metaobjects/loader/meta_data_loader.py
+++ b/server/python/src/metaobjects/loader/meta_data_loader.py
@@ -106,6 +106,9 @@ def get_envelope_warnings(self) -> list[LoaderWarning]:
         return list(self.envelope_warnings)
 
 
+_NODE_SKIPPING_CODES = frozenset({ErrorCode.ERR_UNKNOWN_SUBTYPE, ErrorCode.ERR_UNKNOWN_TYPE})
+
+
 class MetaDataLoader:
     """Source-polymorphic metadata loader.
 
@@ -152,7 +155,12 @@ def load(self, sources: list[MetaDataSource]) -> LoadResult:
             # Today these only arrive from merge sites (see merge_roots);
             # the channel is exposed here for future parser-emitted ones.
             result.envelope_warnings.extend(parsed.envelope_warnings)
-            if not parsed.errors:
+            if not parsed.errors or all(
+                e.code in _NODE_SKIPPING_CODES for e in parsed.errors
+            ):
+                # An unknown type/subtype drops only that node; the rest of the tree is
+                # intact, so keep it and let the validation passes report what else is
+                # wrong in the same load (cross-port: the TS loader does the same).
                 roots.append(parsed.root)
 
         if roots:
diff --git a/server/python/tests/codegen/test_cli_config_providers.py b/server/python/tests/codegen/test_cli_config_providers.py
new file mode 100644
index 0000000000..ff56c014d1
--- /dev/null
+++ b/server/python/tests/codegen/test_cli_config_providers.py
@@ -0,0 +1,137 @@
+"""A project's custom providers reach every metadata-loading CLI entry point.
+
+``providers: ["module:symbol"]`` in ``metaobjects.config.yaml`` and ``--provider
+module:symbol`` are two spellings of the same thing and must MERGE wherever the CLI loads
+metadata: ``gen``, ``verify`` (codegen, templates, the requirement gate and field lint)
+and ``fmt``.
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+from metaobjects.cli import main
+
+# The project's own vocabulary: a new layout.picker subtype + two attrs on field.string.
+VOCAB_MODULE = '''
+from metaobjects.meta.presentation.layout.meta_layout import MetaLayout
+from metaobjects.provider import Provider
+from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition
+from metaobjects.shared.base_types import TYPE_ATTR
+
+vocab = Provider("acme-vocabulary", dependencies=("metaobjects-core-types",))
+vocab.add(TypeDefinition(
+    type="layout", sub_type="picker", factory=MetaLayout,
+    attrs=[AttrSchema("displayField", "string", required=False)],
+    child_rules=[ChildRule(TYPE_ATTR, "*")],
+))
+vocab.on_register(lambda registry: registry.extend(
+    "field", "string",
+    attributes=[
+        AttrSchema("searchable", "boolean", required=False),
+        AttrSchema("valueLabels", "string", required=False, is_array=True),
+    ],
+))
+'''
+
+# A second, independent provider, supplied on the command line.
+FLAG_MODULE = '''
+from metaobjects.meta.meta_data import MetaData
+from metaobjects.provider import Provider
+from metaobjects.registry import TypeDefinition
+
+flag_provider = Provider("acme-flag", ("metaobjects-core-types",))
+flag_provider.add(TypeDefinition(
+    type="validator", sub_type="geocheck",
+    factory=lambda t, s, n: MetaData(t, s, n), description="A custom validator",
+))
+'''
+
+CUSTOM_META = {
+    "metadata.root": {
+        "package": "acme",
+        "children": [
+            {
+                "object.value": {
+                    "name": "Contact",
+                    "children": [
+                        {"field.string": {"name": "name", "@searchable": True}},
+                        {"layout.picker": {"name": "contactPicker", "@displayField": "name"}},
+                    ],
+                }
+            }
+        ],
+    }
+}
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+    meta = tmp_path / "metaobjects"
+    meta.mkdir()
+    (meta / "meta.contacts.json").write_text(json.dumps(CUSTOM_META), encoding="utf-8")
+    (tmp_path / "acme_vocab_mod.py").write_text(VOCAB_MODULE, encoding="utf-8")
+    (tmp_path / "acme_flag_mod.py").write_text(FLAG_MODULE, encoding="utf-8")
+    (tmp_path / "metaobjects.config.yaml").write_text(
+        'metadata: metaobjects\nproviders: ["acme_vocab_mod:vocab"]\n'
+        "targets:\n  app:\n    outDir: gen\n    generators: []\n",
+        encoding="utf-8",
+    )
+    monkeypatch.chdir(tmp_path)
+    saved = list(sys.path)
+    sys.path.insert(0, str(tmp_path))  # for the --provider (flag) module
+    yield tmp_path
+    sys.path[:] = saved
+    for name in ("acme_vocab_mod", "acme_flag_mod"):
+        sys.modules.pop(name, None)
+
+
+def test_fmt_honors_config_providers(project: Path, capsys) -> None:
+    rc = main(["fmt", "--check"])
+    err = capsys.readouterr().err
+    assert "ERR_UNKNOWN" not in err and "unknown" not in err.lower()
+    assert rc in (0, 1)  # 1 only means "not canonical"; the load itself must not fail
+    assert (project / "metaobjects" / "meta.contacts.json").read_text(encoding="utf-8")
+
+
+def test_fmt_rewrites_a_file_using_the_config_vocabulary(project: Path) -> None:
+    path = project / "metaobjects" / "meta.contacts.json"
+    path.write_text(json.dumps(CUSTOM_META), encoding="utf-8")  # compact, not canonical
+    assert main(["fmt"]) == 0
+    assert path.read_text(encoding="utf-8").startswith("{\n  \"metadata.root\"")
+    assert "@searchable" in path.read_text(encoding="utf-8") or "searchable" in path.read_text(
+        encoding="utf-8"
+    )
+
+
+def test_fmt_without_config_providers_still_refuses_the_vocabulary(project: Path) -> None:
+    (project / "metaobjects.config.yaml").unlink()
+    assert main(["fmt"]) != 0
+
+
+def test_fmt_merges_config_and_flag_providers(project: Path) -> None:
+    rc = main(["fmt", "--provider", "acme_flag_mod:flag_provider"])
+    assert rc == 0
+
+
+def test_verify_keeps_config_and_flag_providers(project: Path) -> None:
+    """Metadata needing BOTH the config provider's vocabulary and the flag provider's
+    validator loads clean through every verify gate (requirement gate + field lint)."""
+    meta = json.loads(json.dumps(CUSTOM_META))
+    meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
+        "children"
+    ] = [{"validator.geocheck": {"name": "chk"}}]
+    (project / "metaobjects" / "meta.contacts.json").write_text(json.dumps(meta), encoding="utf-8")
+    assert main(["verify", "--provider", "acme_flag_mod:flag_provider"]) == 0
+
+
+def test_verify_without_the_flag_provider_fails_on_its_validator(project: Path) -> None:
+    meta = json.loads(json.dumps(CUSTOM_META))
+    meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
+        "children"
+    ] = [{"validator.geocheck": {"name": "chk"}}]
+    (project / "metaobjects" / "meta.contacts.json").write_text(json.dumps(meta), encoding="utf-8")
+    assert main(["verify"]) != 0
diff --git a/server/python/tests/conformance/conformance_adapter.py b/server/python/tests/conformance/conformance_adapter.py
index f49f406da2..5ab97f2e02 100644
--- a/server/python/tests/conformance/conformance_adapter.py
+++ b/server/python/tests/conformance/conformance_adapter.py
@@ -65,6 +65,42 @@ def _example_template_briefing_provider() -> Provider:
     return p
 
 
+def _example_custom_vocabulary_provider() -> Provider:
+    """Test-only: a project's own vocabulary (layout.picker + field.string attrs).
+
+    Mirrors the TS adapter's ``exampleCustomVocabularyProvider``: registers a new
+    ``layout.picker`` subtype (one optional ``displayField``) and extends
+    ``field.string`` with an optional boolean ``searchable`` and an optional
+    string-array ``valueLabels``. The ``provider-custom-vocabulary-*`` fixtures load
+    clean with it and are refused without it.
+    """
+    from metaobjects.meta.presentation.layout.meta_layout import MetaLayout
+
+    p = Provider("example-custom-vocabulary", dependencies=("metaobjects-core-types",))
+    p.add(
+        TypeDefinition(
+            type="layout",
+            sub_type="picker",
+            factory=MetaLayout,
+            attrs=[AttrSchema("displayField", "string", required=False)],
+            child_rules=[ChildRule(TYPE_ATTR, "*")],
+        )
+    )
+
+    def _extend(registry) -> None:
+        registry.extend(
+            "field",
+            "string",
+            attributes=[
+                AttrSchema("searchable", "boolean", required=False),
+                AttrSchema("valueLabels", "string", required=False, is_array=True),
+            ],
+        )
+
+    p.on_register(_extend)
+    return p
+
+
 def _noop_provider(provider_id: str, *deps: str) -> Provider:
     return Provider(provider_id, dependencies=tuple(deps))
 
@@ -96,6 +132,7 @@ def _noop_provider(provider_id: str, *deps: str) -> Provider:
     "metaobjects-template": prompt_provider,
     # Test-only — provider-extension-* fixtures.
     "example-template-briefing": _example_template_briefing_provider(),
+    "example-custom-vocabulary": _example_custom_vocabulary_provider(),
     "cycle-a": _noop_provider("cycle-a", "cycle-b"),
     "cycle-b": _noop_provider("cycle-b", "cycle-a"),
     "depends-on-missing": _noop_provider("depends-on-missing", "does-not-exist"),
diff --git a/server/typescript/packages/cli/src/commands/export.ts b/server/typescript/packages/cli/src/commands/export.ts
index fc9f1c3a34..58de681e75 100644
--- a/server/typescript/packages/cli/src/commands/export.ts
+++ b/server/typescript/packages/cli/src/commands/export.ts
@@ -3,8 +3,9 @@ import { writeFile } from "node:fs/promises";
 import { parseExportArgs } from "../lib/args.js";
 import { log } from "../lib/log.js";
 import { FileSource } from "@metaobjectsdev/metadata/core";
-import { TypeRegistry, registerCoreTypes, MetaDataLoader, canonicalSerialize } from "@metaobjectsdev/metadata";
-import { registerForgeTypes, resolveCollection } from "@metaobjectsdev/sdk";
+import { composeRegistry, MetaDataLoader, canonicalSerialize } from "@metaobjectsdev/metadata";
+import { defaultLoadMemoryProviders, forgeTypesProvider, resolveCollection } from "@metaobjectsdev/sdk";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
 import { collectionLoadOptions } from "../lib/collection-load-options.js";
 import { describeError } from "../lib/error-text.js";
 
@@ -19,12 +20,6 @@ export async function exportCommand(args: string[], cwd: string): Promise = {};
-  try {
-    // `metaobjects.config.ts` gets its own nearest-ancestor walk, not `projectRoot`:
-    // it answers a different question from `.metaobjects/config.json` (design §4.6)
-    // and in a Maven- or pip-rooted monorepo the two legitimately sit in different
-    // directories, so reading it from the collection's would silently drop this
-    // package's providers (#326). Same path whenever the two files sit together.
-    configLoadOptions = loadMemoryOptionsFrom(
-      await loadMetaobjectsConfig(resolveGenConfigDir(cwd, projectRoot)),
-    );
-  } catch {
-    configLoadOptions = {};
+  // `metaobjects.config.ts` gets its own nearest-ancestor walk, not `projectRoot`:
+  // it answers a different question from `.metaobjects/config.json` (design §4.6)
+  // and in a Maven- or pip-rooted monorepo the two legitimately sit in different
+  // directories, so reading it from the collection's would silently drop this
+  // package's providers (#326). Same path whenever the two files sit together.
+  const genConfigDir = resolveGenConfigDir(cwd, projectRoot);
+  if (existsSync(join(genConfigDir, "metaobjects.config.ts"))) {
+    try {
+      configLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genConfigDir));
+    } catch (err) {
+      // A config that exists but fails to load is reported as itself, not as the
+      // unknown-subtype error its providers would have prevented.
+      log.error(`metaobjects.config.ts failed to load: ${describeError(err)}`);
+      return 1;
+    }
   }
 
   let root;
diff --git a/server/typescript/packages/cli/src/commands/types.ts b/server/typescript/packages/cli/src/commands/types.ts
index 36f4a16648..8378583e3b 100644
--- a/server/typescript/packages/cli/src/commands/types.ts
+++ b/server/typescript/packages/cli/src/commands/types.ts
@@ -20,7 +20,8 @@
 // The `--json` flag this once advertised in its own --help, twice, is not coming back: the
 // CLI rejects a bare `--json` before a command ever sees its args (index.ts, `formatAlias`),
 // on purpose — one global spelling for all three formats.
-import { composeRegistry, buildVocabularyCatalog } from "@metaobjectsdev/metadata";
+import { composeRegistry, buildVocabularyCatalog, type MetaDataTypeProvider } from "@metaobjectsdev/metadata";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
 import { defaultLoadMemoryProviders } from "@metaobjectsdev/sdk";
 import { log } from "../lib/log.js";
 import { emitStructured, type OutputFormat } from "../lib/format.js";
@@ -257,7 +258,18 @@ export async function typesCommand(args: string[], fmt: OutputFormat = "text"):
   // once in this list, and printing it as registered vocabulary was part of how a TS-only
   // registration read as cross-port: `meta types forge --all` listed 20 attrs no other port
   // has. One composition, one door.
-  const registry = composeRegistry([...defaultLoadMemoryProviders], { validate: true });
+  // Plus the project's own providers when a `metaobjects.config.ts` governs this directory,
+  // so project vocabulary (`layout.picker`, extended attrs) is listed. No config: unchanged.
+  let projectProviders: readonly MetaDataTypeProvider[] = [];
+  try {
+    projectProviders = await loadProjectProviders(process.cwd(), process.cwd());
+  } catch (err) {
+    const msg = `metaobjects.config.ts failed to load: ${describeError(err)}`;
+    log.error(msg);
+    emitStructured({ error: msg, hint: "fix the config, or run outside the project for the built-in vocabulary" }, fmt);
+    return 1;
+  }
+  const registry = composeRegistry([...defaultLoadMemoryProviders, ...projectProviders], { validate: true });
   const catalog = buildVocabularyCatalog(registry);
 
   // Flatten the catalog into searchable entries.
diff --git a/server/typescript/packages/cli/src/commands/upgrade.ts b/server/typescript/packages/cli/src/commands/upgrade.ts
index 4e1bcbf95f..228d88f468 100644
--- a/server/typescript/packages/cli/src/commands/upgrade.ts
+++ b/server/typescript/packages/cli/src/commands/upgrade.ts
@@ -27,7 +27,9 @@ import {
   coreProviders,
   SUBTYPE_BASE,
   retirementSuggestions,
+  type MetaDataTypeProvider,
 } from "@metaobjectsdev/metadata";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
 import { log } from "../lib/log.js";
 import { describeError } from "../lib/error-text.js";
 import { unknownFlagMessage } from "../lib/strict-args.js";
@@ -124,7 +126,18 @@ export async function upgradeCommand(args: string[], cwd: string): Promise id.type))]
diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts
index 991aebeaea..ca140f0563 100644
--- a/server/typescript/packages/cli/src/commands/verify.ts
+++ b/server/typescript/packages/cli/src/commands/verify.ts
@@ -309,6 +309,16 @@ export async function verifyCommand(
     forgeConfig = undefined;
     configLoadError = e;
   }
+  // A config that EXISTS but fails to load must be reported as itself in EVERY mode: continuing
+  // without its providers turns a config error into a misleading ERR_UNKNOWN_SUBTYPE on the
+  // custom vocabulary those providers register.
+  if (forgeConfig === undefined && existsSync(join(genConfigDir, "metaobjects.config.ts"))) {
+    log.error(
+      "meta verify: metaobjects.config.ts exists but failed to load: " +
+        (configLoadError instanceof Error ? configLoadError.message : String(configLoadError)),
+    );
+    return 2;
+  }
   // Both of the gen config's contributions to the load, together — see
   // `loadMemoryOptionsFrom`. Threading `providers` and forgetting `libraries` is how
   // a shipped library became unloadable through the CLI (#333).
diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts
index 3cdf2488fc..26ec4ef22a 100644
--- a/server/typescript/packages/cli/src/lib/dependency-load.ts
+++ b/server/typescript/packages/cli/src/lib/dependency-load.ts
@@ -35,6 +35,7 @@ import {
   type ResolvedDependency,
 } from "@metaobjectsdev/sdk";
 import { collectionLoadOptions } from "./collection-load-options.js";
+import { loadProjectProviders } from "./load-metaobjects-config.js";
 
 /** `resolveCollection`'s code for "no `sources` declared and no default directory". */
 const ERR_COLLECTION_NOT_FOUND = "ERR_COLLECTION_NOT_FOUND";
@@ -130,7 +131,14 @@ export async function proveDependencySetLoads(configDir: string): Promise 0 ? { providers } : {}) };
   try {
     await loadMemory(collection.configDir, loadOptions);
     return { scope: "full" };
diff --git a/server/typescript/packages/cli/src/lib/fmt-engine.ts b/server/typescript/packages/cli/src/lib/fmt-engine.ts
index 48d6ed6862..0a165bd5ce 100644
--- a/server/typescript/packages/cli/src/lib/fmt-engine.ts
+++ b/server/typescript/packages/cli/src/lib/fmt-engine.ts
@@ -25,7 +25,7 @@ import {
 } from "@metaobjectsdev/metadata";
 import { FileSource } from "@metaobjectsdev/metadata/core";
 import { resolveCollection, defaultLoadMemoryProviders, type Collection } from "@metaobjectsdev/sdk";
-import { loadMetaobjectsConfig, loadMemoryOptionsFrom } from "./load-metaobjects-config.js";
+import { loadProjectProviders } from "./load-metaobjects-config.js";
 
 export type FmtFileStatus =
   | "formatted"
@@ -57,18 +57,6 @@ export interface FmtRunOptions {
   readonly files?: readonly string[];
 }
 
-async function projectProviders(collection: Collection): Promise {
-  try {
-    const cfg = await loadMetaobjectsConfig(collection.configDir);
-    return loadMemoryOptionsFrom(cfg).providers ?? [];
-  } catch {
-    // No metaobjects.config.ts (common for a non-TS-server project running
-    // `meta fmt` just for schema-neutral formatting) — format against the
-    // core vocabulary only, exactly like a project with no custom providers.
-    return [];
-  }
-}
-
 export async function runFmt(opts: FmtRunOptions): Promise {
   let collection: Collection;
   try {
@@ -77,7 +65,14 @@ export async function runFmt(opts: FmtRunOptions): Promise {
     return { files: [], fatal: err instanceof Error ? err.message : String(err) };
   }
 
-  const providers = await projectProviders(collection);
+  // Nearest-ancestor config (like every other command); a config that exists but fails
+  // to load is fatal rather than silently "no providers".
+  let providers: readonly MetaDataTypeProvider[];
+  try {
+    providers = await loadProjectProviders(opts.projectRoot, collection.configDir);
+  } catch (err) {
+    return { files: [], fatal: `metaobjects.config.ts failed to load: ${err instanceof Error ? err.message : String(err)}` };
+  }
   const registry = composeRegistry([...defaultLoadMemoryProviders, ...providers]);
 
   let targets: readonly string[];
diff --git a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
index 848b1e4c30..606dc55fc7 100644
--- a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
+++ b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
@@ -309,6 +309,23 @@ export async function resolveGenCollection(
   }
 }
 
+/**
+ * The custom providers a project's `metaobjects.config.ts` contributes, for a command run
+ * from `startDir` (`fallback` = the collection's directory, as for `resolveGenConfigDir`).
+ *
+ * Absent config: `[]` (core vocabulary only). A config that EXISTS but fails to load
+ * THROWS, so the caller reports the real error instead of a downstream
+ * `ERR_UNKNOWN_SUBTYPE` that the config's own providers would have prevented.
+ */
+export async function loadProjectProviders(
+  startDir: string,
+  fallback: string,
+): Promise {
+  const dir = resolveGenConfigDir(startDir, fallback);
+  if (!existsSync(join(dir, CONFIG_FILE))) return [];
+  return loadMemoryOptionsFrom(await loadMetaobjectsConfig(dir)).providers ?? [];
+}
+
 /**
  * A `metaobjects.config.ts` that still sets column naming (ADR-0058). Its own class because
  * the commands that tolerate a missing or broken config (`meta migrate` falls back to
diff --git a/server/typescript/packages/cli/test/config-providers-everywhere.test.ts b/server/typescript/packages/cli/test/config-providers-everywhere.test.ts
new file mode 100644
index 0000000000..d7df5f0844
--- /dev/null
+++ b/server/typescript/packages/cli/test/config-providers-everywhere.test.ts
@@ -0,0 +1,188 @@
+import { describe, test, expect, afterAll, spyOn } from "bun:test";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { exportCommand } from "../src/commands/export.js";
+import { typesCommand } from "../src/commands/types.js";
+import { verifyCommand } from "../src/commands/verify.js";
+import { runFmt } from "../src/lib/fmt-engine.js";
+import { proveDependencySetLoads } from "../src/lib/dependency-load.js";
+
+// A project's own `metaobjects.config.ts` providers must reach EVERY command that loads
+// metadata, not just gen/migrate. The vocabulary mirrors the shared conformance fixture
+// `provider-custom-vocabulary-success`: a new subtype (`layout.picker @displayField`) and
+// two attrs added to `field.string` (`@searchable`, `@valueLabels`).
+
+const dirs: string[] = [];
+afterAll(async () => {
+  for (const d of dirs) await rm(d, { recursive: true, force: true });
+});
+
+const PROVIDER_SRC = [
+  `import { TypeId, MetaLayout } from "@metaobjectsdev/metadata";`,
+  `import { TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN } from "@metaobjectsdev/metadata/constants";`,
+  `const vocab = {`,
+  `  id: "test-custom-vocabulary",`,
+  `  dependencies: ["metaobjects-core-types"],`,
+  `  registerTypes(registry) {`,
+  `    registry.register({`,
+  `      typeId: new TypeId(TYPE_LAYOUT, "picker"),`,
+  `      description: "A picker over the owning object.",`,
+  `      factory: (typeId, name) => new MetaLayout(typeId, name),`,
+  `      childRules: [],`,
+  `      attributes: [{ name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }],`,
+  `    });`,
+  `    registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, {`,
+  `      attributes: [`,
+  `        { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." },`,
+  `        { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." },`,
+  `      ],`,
+  `    });`,
+  `  },`,
+  `};`,
+].join("\n");
+
+const GOOD_CONFIG = [
+  `import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
+  PROVIDER_SRC,
+  `export default defineConfig({ outDir: "out", dialect: "postgres", generators: [], providers: [vocab] });`,
+].join("\n");
+
+const BROKEN_CONFIG = `export default (() => { throw new Error("boom-from-config"); })();\n`;
+
+const META = JSON.stringify({
+  "metadata.root": {
+    package: "examples",
+    children: [
+      {
+        "object.value": {
+          name: "Contact",
+          children: [
+            { "field.string": { name: "name", "@searchable": true } },
+            { "field.string": { name: "status", "@valueLabels": ["Active", "On hold"] } },
+            { "layout.picker": { name: "contactPicker", "@displayField": "name" } },
+          ],
+        },
+      },
+    ],
+  },
+});
+
+async function project(config: string | undefined): Promise {
+  const root = await mkdtemp(join(tmpdir(), "mts-cfgprov-"));
+  dirs.push(root);
+  await mkdir(join(root, "metaobjects"), { recursive: true });
+  await writeFile(join(root, "metaobjects", "meta.app.json"), META, "utf8");
+  if (config !== undefined) await writeFile(join(root, "metaobjects.config.ts"), config, "utf8");
+  return root;
+}
+
+async function captured(fn: () => Promise): Promise<{ result: T; err: string; out: string }> {
+  const errs: string[] = [];
+  const outs: string[] = [];
+  const e = spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.map(String).join(" ")); });
+  const l = spyOn(console, "log").mockImplementation((...a: unknown[]) => { outs.push(a.map(String).join(" ")); });
+  try {
+    return { result: await fn(), err: errs.join("\n"), out: outs.join("\n") };
+  } finally {
+    e.mockRestore();
+    l.mockRestore();
+  }
+}
+
+describe("config providers reach every metadata-loading command", () => {
+  test("export loads custom vocabulary; without the provider it is refused", async () => {
+    const root = await project(GOOD_CONFIG);
+    const out = join(root, "export.json");
+    const { result } = await captured(() => exportCommand(["--out", out], root));
+    expect(result).toBe(0);
+    expect(await readFile(out, "utf8")).toContain("layout.picker");
+
+    const bare = await project(undefined);
+    const { result: bareCode } = await captured(() => exportCommand(["--out", join(bare, "x.json")], bare));
+    expect(bareCode).toBe(1);
+  });
+
+  test("export surfaces a broken config as itself", async () => {
+    const root = await project(BROKEN_CONFIG);
+    const { result, err } = await captured(() => exportCommand([], root));
+    expect(result).toBe(1);
+    expect(err).toContain("boom-from-config");
+  });
+
+  test("meta deps proof loads the full set with the provider (not 'port-only vocabulary')", async () => {
+    const root = await project(GOOD_CONFIG);
+    expect((await proveDependencySetLoads(root)).scope).toBe("full");
+  });
+
+  test("meta fmt finds the config in an ancestor of the collection and in-place", async () => {
+    const root = await project(GOOD_CONFIG);
+    const r = await runFmt({ projectRoot: root, check: true });
+    expect(r.fatal).toBeUndefined();
+    expect(r.files.every((f) => f.status !== "error")).toBe(true);
+  });
+
+  test("meta fmt run from a sub-project reads that sub-project's config, not the collection's", async () => {
+    // The collection (.metaobjects/config.json + metadata) sits at the repo root; the
+    // TypeScript config sits in a package below it. Reading the config from the
+    // collection's directory (the old behaviour) never saw it.
+    const root = await project(undefined);
+    await mkdir(join(root, ".git"), { recursive: true });
+    await mkdir(join(root, ".metaobjects"), { recursive: true });
+    await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1 }), "utf8");
+    const app = join(root, "app");
+    await mkdir(app, { recursive: true });
+    await writeFile(join(app, "metaobjects.config.ts"), GOOD_CONFIG, "utf8");
+    const r = await runFmt({ projectRoot: app, check: true });
+    expect(r.fatal).toBeUndefined();
+    expect(r.files.length).toBeGreaterThan(0);
+    expect(r.files.every((f) => f.status !== "error")).toBe(true);
+  });
+
+  test("meta fmt reports a broken config instead of dropping providers", async () => {
+    const root = await project(BROKEN_CONFIG);
+    const r = await runFmt({ projectRoot: root, check: true });
+    expect(r.fatal).toContain("boom-from-config");
+  });
+
+  test("meta types lists project vocabulary when a config is present", async () => {
+    const root = await project(GOOD_CONFIG);
+    const prev = process.cwd();
+    process.chdir(root);
+    try {
+      const { result, out } = await captured(() => typesCommand(["layout.picker"]));
+      expect(result).toBe(0);
+      expect(out).toContain("layout.picker");
+      const attrs = await captured(() => typesCommand(["searchable"]));
+      expect(attrs.out).toContain("searchable");
+    } finally {
+      process.chdir(prev);
+    }
+  });
+
+  test("meta types is unchanged with no config", async () => {
+    const root = await project(undefined);
+    const prev = process.cwd();
+    process.chdir(root);
+    try {
+      const { out } = await captured(() => typesCommand(["layout.picker"]));
+      expect(out).not.toContain("layout.picker  ");
+      expect(out.toLowerCase()).toContain("no vocabulary matches");
+    } finally {
+      process.chdir(prev);
+    }
+  });
+
+  test("strict verify passes custom vocabulary with the provider and surfaces a broken config", async () => {
+    const root = await project(GOOD_CONFIG);
+    const ok = await captured(() => verifyCommand(["--templates"], root));
+    expect(ok.err).not.toContain("ERR_UNKNOWN");
+    expect(ok.result).toBe(0);
+
+    const broken = await project(BROKEN_CONFIG);
+    const bad = await captured(() => verifyCommand(["--templates"], broken));
+    expect(bad.result).toBe(2);
+    expect(bad.err).toContain("boom-from-config");
+    expect(bad.err).not.toContain("ERR_UNKNOWN_SUBTYPE");
+  });
+});
diff --git a/server/typescript/packages/metadata/test/conformance/adapter.ts b/server/typescript/packages/metadata/test/conformance/adapter.ts
index b5f1bdc4a8..77991fbe4e 100644
--- a/server/typescript/packages/metadata/test/conformance/adapter.ts
+++ b/server/typescript/packages/metadata/test/conformance/adapter.ts
@@ -26,9 +26,11 @@ import { canonicalSerialize, canonicalSerializeEffective } from "../../src/seria
 import { ParseError } from "../../src/errors.js";
 import { TypeId } from "../../src/registry.js";
 import { MetaTemplate } from "../../src/template/meta-template.js";
-import { TYPE_TEMPLATE, TYPE_ATTR } from "../../src/shared/base-types.js";
+import { TYPE_TEMPLATE, TYPE_ATTR, TYPE_FIELD, TYPE_LAYOUT } from "../../src/shared/base-types.js";
 import { CHILD_RULE_WILDCARD } from "../../src/shared/structural.js";
-import { ATTR_SUBTYPE_STRING } from "../../src/core/attr/attr-constants.js";
+import { ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_STRING } from "../../src/core/attr/attr-constants.js";
+import { MetaLayout } from "../../src/presentation/layout/meta-layout.js";
+import { FIELD_SUBTYPE_STRING } from "../../src/core/field/field-constants.js";
 import { navigate } from "./navigator.js";
 import { binding } from "./binding.js";
 
@@ -76,6 +78,37 @@ const exampleTemplateBriefingProvider: MetaDataTypeProvider = {
   },
 };
 
+/**
+ * The project-vocabulary case (provider-custom-vocabulary-* fixtures): a
+ * downstream project's own provider that REGISTERS one new subtype
+ * (`layout.picker`, carrying its own `@displayField`) and EXTENDS an existing
+ * type (`field.string`) with two optional attrs (`@searchable`,
+ * `@valueLabels`). Every port carries the same provider, written in its own
+ * language, so the same metadata loads clean with it and is refused without it.
+ */
+const exampleCustomVocabularyProvider: MetaDataTypeProvider = {
+  id: "example-custom-vocabulary",
+  dependencies: ["metaobjects-core-types"],
+  description: "Test-only — a project's own vocabulary: layout.picker + field.string attrs.",
+  registerTypes(registry) {
+    registry.register({
+      typeId: new TypeId(TYPE_LAYOUT, "picker"),
+      description: "A picker that chooses one row of the owning object — test-only.",
+      factory: (typeId, name) => new MetaLayout(typeId, name),
+      childRules: [],
+      attributes: [
+        { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown for each choice." },
+      ],
+    });
+    registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, {
+      attributes: [
+        { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer this field in search." },
+        { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels for the field's values." },
+      ],
+    });
+  },
+};
+
 /** Two providers that name each other as a dependency — surfaces ERR_PROVIDER_DEPENDENCY_CYCLE. */
 const cycleAProvider: MetaDataTypeProvider = {
   id: "cycle-a",
@@ -126,6 +159,7 @@ export const PROVIDERS: Readonly> = {
   [uiProvider.id]: uiProvider,               // "metaobjects-ui" (filter/sort field markers)
   // Test-only — provider-extension-* fixtures.
   "example-template-briefing": exampleTemplateBriefingProvider,
+  "example-custom-vocabulary": exampleCustomVocabularyProvider,
   "cycle-a": cycleAProvider,
   "cycle-b": cycleBProvider,
   "depends-on-missing": dependsOnMissingProvider,
diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts
index 40d127843d..ae2e385592 100644
--- a/server/typescript/packages/sdk/src/config.ts
+++ b/server/typescript/packages/sdk/src/config.ts
@@ -239,6 +239,16 @@ export const ConfigSchema = z.object({
   csharp: z.strictObject({
     outDir: z.string().min(1).optional(),
     namespace: z.string().min(1).optional(),
+    /**
+     * The project's own metadata providers: each names a BUILT .NET assembly (a path relative
+     * to the directory holding `.metaobjects/`) and the provider's full type name. Only the C#
+     * port reads this; the TypeScript port validates it so the shared file loads, and takes
+     * its providers from `defineConfig({ providers })` in `metaobjects.config.ts` instead.
+     */
+    providers: z.array(z.strictObject({
+      assembly: z.string().min(1),
+      type: z.string().min(1),
+    })).optional(),
   }).optional(),
 }).strict();
 
diff --git a/server/typescript/packages/sdk/test/config.test.ts b/server/typescript/packages/sdk/test/config.test.ts
index 2464517e85..0d964d4531 100644
--- a/server/typescript/packages/sdk/test/config.test.ts
+++ b/server/typescript/packages/sdk/test/config.test.ts
@@ -284,3 +284,19 @@ describe("csharp.outDir (the C# port's generated-code directory)", () => {
     expect(ConfigSchema.safeParse({ schema_version: 1, csharp: { outDir: "" } }).success).toBe(false);
   });
 });
+
+describe("csharp.providers (read by the C# port only)", () => {
+  test("a config carrying provider assemblies validates", () => {
+    const ok = ConfigSchema.safeParse({
+      schema_version: 1,
+      csharp: { providers: [{ assembly: "bin/Debug/net8.0/Acme.Vocabulary.dll", type: "Acme.Vocabulary.AcmeProvider" }] },
+    });
+    expect(ok.success).toBe(true);
+  });
+  test("rejects an empty field, a missing field, and an unknown key", () => {
+    const bad = (p: unknown) => ConfigSchema.safeParse({ schema_version: 1, csharp: { providers: p } }).success;
+    expect(bad([{ assembly: "", type: "A.B" }])).toBe(false);
+    expect(bad([{ assembly: "a.dll" }])).toBe(false);
+    expect(bad([{ assembly: "a.dll", type: "A.B", extra: 1 }])).toBe(false);
+  });
+});

From 5ae0df652a81c7934650e87ca18adb8789f85f80 Mon Sep 17 00:00:00 2001
From: Doug Mealing 
Date: Tue, 3 Nov 2026 22:00:21 -0500
Subject: [PATCH 2/4] no-mistakes(review): apply reviewer inline fixes

---
 server/csharp/MetaObjects/Config/MetadataLocation.cs       | 7 ++++---
 .../src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt | 3 ++-
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs
index 841bee58fa..cbc250800e 100644
--- a/server/csharp/MetaObjects/Config/MetadataLocation.cs
+++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs
@@ -82,13 +82,14 @@ public sealed class MetadataLocationException(string message) : Exception(messag
 
 public static class MetadataLocation
 {
+    /// csharp.providers from , instantiated; null when none are declared.
+    internal static IReadOnlyList? LoadProviders(NeutralConfig? cfg) =>
+        cfg is { Providers.Count: > 0 } ? ProviderLoader.Load(cfg.Providers) : null;
+
     /// 
     /// The project a metadata directory belongs to: its PARENT, the directory holding
     /// metaobjects/ and .metaobjects/.
     /// 
-    internal static IReadOnlyList? LoadProviders(NeutralConfig? cfg) =>
-        cfg is { Providers.Count: > 0 } ? ProviderLoader.Load(cfg.Providers) : null;
-
     public static string ProjectRootFor(string metadataDir) =>
         Path.GetDirectoryName(Path.GetFullPath(metadataDir)) ?? System.IO.Directory.GetCurrentDirectory();
 
diff --git a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
index 1207ca61fb..90de803f4b 100644
--- a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
+++ b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
@@ -25,7 +25,8 @@ private fun withProviders(opts: LoaderOptions?, providers: List
Date: Tue, 3 Nov 2026 22:10:40 -0500
Subject: [PATCH 3/4] no-mistakes(review): Dedupe export providers, refuse bad
 C# assemblies, tighten tests

---
 .../MetaObjects/Config/ProviderLoader.cs      | 12 ++-
 .../codegen/test_cli_config_providers.py      | 73 ++++++++++++-------
 .../packages/cli/src/commands/export.ts       |  3 +-
 .../packages/cli/src/commands/upgrade.ts      |  2 +-
 .../packages/cli/src/commands/verify.ts       |  8 +-
 .../test/config-providers-everywhere.test.ts  | 15 ++++
 6 files changed, 77 insertions(+), 36 deletions(-)

diff --git a/server/csharp/MetaObjects/Config/ProviderLoader.cs b/server/csharp/MetaObjects/Config/ProviderLoader.cs
index fcd12daa40..b735638057 100644
--- a/server/csharp/MetaObjects/Config/ProviderLoader.cs
+++ b/server/csharp/MetaObjects/Config/ProviderLoader.cs
@@ -36,8 +36,16 @@ private static IMetaDataTypeProvider LoadOne(ProviderSpec spec)
             throw Refuse(spec, $"the assembly file does not exist at {spec.AssemblyPath}. Build the vocabulary project first " +
                                "(for example `dotnet build `), then re-run.");
 
-        var assembly = LoadAssembly(spec);
-        var type = assembly.GetType(spec.TypeName, throwOnError: false);
+        Type? type;
+        try
+        {
+            type = LoadAssembly(spec).GetType(spec.TypeName, throwOnError: false);
+        }
+        catch (Exception ex) when (ex is BadImageFormatException or FileLoadException or ReflectionTypeLoadException)
+        {
+            throw Refuse(spec, $"the assembly could not be loaded ({ex.GetType().Name}: {ex.Message}). " +
+                               "Point it at a managed .NET assembly built for this runtime.");
+        }
         if (type is null)
             throw Refuse(spec, $"type \"{spec.TypeName}\" was not found in the assembly (use the full, namespace-qualified name).");
         if (!typeof(IMetaDataTypeProvider).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
diff --git a/server/python/tests/codegen/test_cli_config_providers.py b/server/python/tests/codegen/test_cli_config_providers.py
index ff56c014d1..260314a4cf 100644
--- a/server/python/tests/codegen/test_cli_config_providers.py
+++ b/server/python/tests/codegen/test_cli_config_providers.py
@@ -89,49 +89,72 @@ def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
         sys.modules.pop(name, None)
 
 
-def test_fmt_honors_config_providers(project: Path, capsys) -> None:
-    rc = main(["fmt", "--check"])
-    err = capsys.readouterr().err
-    assert "ERR_UNKNOWN" not in err and "unknown" not in err.lower()
-    assert rc in (0, 1)  # 1 only means "not canonical"; the load itself must not fail
-    assert (project / "metaobjects" / "meta.contacts.json").read_text(encoding="utf-8")
+_SHARED_FIXTURE = (
+    Path(__file__).resolve().parents[4] / "fixtures" / "conformance" / "provider-custom-vocabulary-success"
+)
 
 
-def test_fmt_rewrites_a_file_using_the_config_vocabulary(project: Path) -> None:
+def _with_geocheck(meta: dict) -> dict:
+    meta = json.loads(json.dumps(meta))
+    meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
+        "children"
+    ] = [{"validator.geocheck": {"name": "chk"}}]
+    return meta
+
+
+def _use_shared_fixture(project: Path) -> Path:
     path = project / "metaobjects" / "meta.contacts.json"
-    path.write_text(json.dumps(CUSTOM_META), encoding="utf-8")  # compact, not canonical
+    meta = json.loads((_SHARED_FIXTURE / "input" / "meta.contacts.json").read_text(encoding="utf-8"))
+    path.write_text(json.dumps(meta), encoding="utf-8")  # compact, not canonical
+    return path
+
+
+def test_fmt_honors_config_providers(project: Path) -> None:
+    path = _use_shared_fixture(project)
+    assert main(["fmt", "--check"]) == 1
     assert main(["fmt"]) == 0
-    assert path.read_text(encoding="utf-8").startswith("{\n  \"metadata.root\"")
-    assert "@searchable" in path.read_text(encoding="utf-8") or "searchable" in path.read_text(
-        encoding="utf-8"
+    assert main(["fmt", "--check"]) == 0
+    assert json.loads(path.read_text(encoding="utf-8")) == json.loads(
+        (_SHARED_FIXTURE / "expected.json").read_text(encoding="utf-8")
     )
 
 
-def test_fmt_without_config_providers_still_refuses_the_vocabulary(project: Path) -> None:
+def test_fmt_rewrites_a_file_using_the_config_vocabulary(project: Path) -> None:
+    path = _use_shared_fixture(project)
+    assert main(["fmt"]) == 0
+    expected = (_SHARED_FIXTURE / "expected.json").read_text(encoding="utf-8")
+    assert path.read_text(encoding="utf-8").rstrip("\n") == expected.rstrip("\n")
+
+
+def test_fmt_without_config_providers_still_refuses_the_vocabulary(project: Path, capsys) -> None:
+    path = _use_shared_fixture(project)
+    before = path.read_text(encoding="utf-8")
     (project / "metaobjects.config.yaml").unlink()
-    assert main(["fmt"]) != 0
+    assert main(["fmt"]) == 1
+    captured = capsys.readouterr()
+    assert "ERR_UNKNOWN_SUBTYPE" in captured.out + captured.err
+    assert path.read_text(encoding="utf-8") == before
 
 
 def test_fmt_merges_config_and_flag_providers(project: Path) -> None:
-    rc = main(["fmt", "--provider", "acme_flag_mod:flag_provider"])
-    assert rc == 0
+    path = project / "metaobjects" / "meta.contacts.json"
+    path.write_text(json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8")
+    assert main(["fmt"]) == 1
+    assert main(["fmt", "--provider", "acme_flag_mod:flag_provider"]) == 0
+    assert json.loads(path.read_text(encoding="utf-8")) == _with_geocheck(CUSTOM_META)
 
 
 def test_verify_keeps_config_and_flag_providers(project: Path) -> None:
     """Metadata needing BOTH the config provider's vocabulary and the flag provider's
     validator loads clean through every verify gate (requirement gate + field lint)."""
-    meta = json.loads(json.dumps(CUSTOM_META))
-    meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
-        "children"
-    ] = [{"validator.geocheck": {"name": "chk"}}]
-    (project / "metaobjects" / "meta.contacts.json").write_text(json.dumps(meta), encoding="utf-8")
+    (project / "metaobjects" / "meta.contacts.json").write_text(
+        json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8"
+    )
     assert main(["verify", "--provider", "acme_flag_mod:flag_provider"]) == 0
 
 
 def test_verify_without_the_flag_provider_fails_on_its_validator(project: Path) -> None:
-    meta = json.loads(json.dumps(CUSTOM_META))
-    meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
-        "children"
-    ] = [{"validator.geocheck": {"name": "chk"}}]
-    (project / "metaobjects" / "meta.contacts.json").write_text(json.dumps(meta), encoding="utf-8")
+    (project / "metaobjects" / "meta.contacts.json").write_text(
+        json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8"
+    )
     assert main(["verify"]) != 0
diff --git a/server/typescript/packages/cli/src/commands/export.ts b/server/typescript/packages/cli/src/commands/export.ts
index 58de681e75..7db145be2f 100644
--- a/server/typescript/packages/cli/src/commands/export.ts
+++ b/server/typescript/packages/cli/src/commands/export.ts
@@ -44,7 +44,8 @@ export async function exportCommand(args: string[], cwd: string): Promise !base.some((b) => b.id === p.id))]);
 
   // `loadAndExportJson` only accepts a scanned directory (`MetaDataLoader.fromDirectory`);
   // `resolveCollection` already resolved the file SET (declared `sources`, or the
diff --git a/server/typescript/packages/cli/src/commands/upgrade.ts b/server/typescript/packages/cli/src/commands/upgrade.ts
index 228d88f468..01e2a2b3b0 100644
--- a/server/typescript/packages/cli/src/commands/upgrade.ts
+++ b/server/typescript/packages/cli/src/commands/upgrade.ts
@@ -133,7 +133,7 @@ export async function upgradeCommand(args: string[], cwd: string): Promise {
     expect(bareCode).toBe(1);
   });
 
+  test("export accepts a config that opts into forge vocabulary itself", async () => {
+    const config = [
+      `import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
+      `import { forgeTypesProvider } from ${JSON.stringify(join(import.meta.dir, "..", "..", "sdk", "src", "index.ts"))};`,
+      PROVIDER_SRC,
+      `export default defineConfig({ outDir: "out", dialect: "postgres", generators: [], providers: [forgeTypesProvider, vocab] });`,
+    ].join("\n");
+    const root = await project(config);
+    const out = join(root, "export.json");
+    const { result, err } = await captured(() => exportCommand(["--out", out], root));
+    expect(err).not.toContain("ERR_PROVIDER_DUPLICATE_ID");
+    expect(result).toBe(0);
+    expect(await readFile(out, "utf8")).toContain("layout.picker");
+  });
+
   test("export surfaces a broken config as itself", async () => {
     const root = await project(BROKEN_CONFIG);
     const { result, err } = await captured(() => exportCommand([], root));

From 3fc85609a6e410e5cd1f6501e05cd9727d1fa7a1 Mon Sep 17 00:00:00 2001
From: Doug Mealing 
Date: Tue, 3 Nov 2026 23:47:27 -0500
Subject: [PATCH 4/4] no-mistakes(document): Fixed three stale provider-loading
 docs: own-your-codegen.md provider table (Java SPI claim, C#, TypeScript
 command list), server/java/README.md (ServiceLoader wording), and the
 recipe's per-language section now points to extending-with-providers.md
 instead of duplicating wiring facts. No code or tests touched; nothing
 unresolved.

---
 docs/features/own-your-codegen.md               |  8 ++++----
 .../extending-metaobjects-with-providers.md     | 17 +++++------------
 server/java/README.md                           |  2 +-
 3 files changed, 10 insertions(+), 17 deletions(-)

diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md
index 3d40f8f739..8edc20fae2 100644
--- a/docs/features/own-your-codegen.md
+++ b/docs/features/own-your-codegen.md
@@ -906,13 +906,13 @@ provider class** in each language: a JSON file could express the declarative sur
 
 | Port | How the CLI / build tool loads your provider |
 |---|---|
-| **TypeScript** | `metaobjects.config.ts` → `providers: [myProvider]`. Threaded into `meta gen` (the docs site included), `meta verify`, **and** the offline `meta migrate` paths (baseline + generate). |
+| **TypeScript** | `metaobjects.config.ts` → `providers: [myProvider]`. Every command that loads metadata reads it: `meta gen` (the docs site included), `verify`, `export`, `deps`, `fmt`, `types`, `upgrade` and the offline `migrate` paths. |
 | **Python** | `metaobjects gen \| verify --provider module:symbol` (repeatable). The symbol resolves to a `Provider` (or a list, or a zero-arg factory returning one) and is composed **on top of** the core providers — parity with TS `config.providers`. E.g. `metaobjects gen --provider myapp.providers:view_provider`. A declarative `metaobjects.config.yaml` `providers:` list is also supported (resolved config-relative, no `PYTHONPATH=` — mirroring TS's config-file providers); see [`cli.md`](cli.md). |
-| **Java / Kotlin** | Put your compiled `MetaDataTypeProvider` on the **project classpath** with a `META-INF/services/com.metaobjects.registry.MetaDataTypeProvider` entry. `metaobjects-maven-plugin` builds the loader with the project classloader, so **Java ServiceLoader auto-discovers it** — no plugin config needed. |
-| **C#** | The loader accepts providers like every port; a first-class `dotnet meta` consumer-provider hook is tracked for a future release ([#158](https://github.com/metaobjectsdev/metaobjects/issues/158)). Today, extend the metamodel from an app that constructs the loader directly. |
+| **Java / Kotlin** | Name the class in the `metaobjects-maven-plugin` `` list (or `-Dmeta.providers`), or declare it as a Spring `MetaDataTypeProvider` bean. Nothing is discovered from the classpath: a `META-INF/services` entry does not add the provider to the loader. See [`extending-with-providers.md`](extending-with-providers.md#java). |
+| **C#** | Name the built assembly and type under `csharp.providers: [{ assembly, type }]` in `.metaobjects/config.json`; `dotnet meta gen`, `verify`, `fmt` load it. See [`extending-with-providers.md`](extending-with-providers.md#c). |
 
 This split follows each ecosystem's norms — interpreted ports (TS / Python) name or
-import the provider module; compiled ports (JVM) discover it on the build classpath —
+import the provider module; compiled ports (JVM, C#) name the provider class in their build config —
 the same "idiomatic per port" principle as generator ownership ([Per port](#per-port)).
 
 ## Removed at 1.0, restored in 1.1
diff --git a/docs/recipes/extending-metaobjects-with-providers.md b/docs/recipes/extending-metaobjects-with-providers.md
index 0d2cf8793e..a2a555b60e 100644
--- a/docs/recipes/extending-metaobjects-with-providers.md
+++ b/docs/recipes/extending-metaobjects-with-providers.md
@@ -2,8 +2,8 @@
 
 This recipe walks through adding a new metamodel subtype to a downstream
 project — using the same `example-template-toolcall` example the conformance
-fixtures use, so the example here matches a real test that runs in TS, C#,
-and Python.
+fixtures use, so the example here matches a real test that runs in every
+port.
 
 For the conceptual reference (what a provider is, the error codes, the
 cross-port semantic rule), see
@@ -204,16 +204,9 @@ project, and any divergence is caught at the load boundary, not at runtime.
 
 ## The other languages
 
-Every port supports the same provider contract, wired in its own config: Python lists
-`module:symbol` under `providers` in `metaobjects.config.yaml`; C# lists the built assembly
-and type under `csharp.providers` in `.metaobjects/config.json`; Java and Kotlin list the
-class under the Maven plugin's `` (or declare a Spring `MetaDataTypeProvider`
-bean). A project whose metadata is loaded by more than one port's tools writes the provider
-once per port, with the same id and vocabulary; a TypeScript-only provider leaves the Java,
-Python and C# tools refusing the same file.
-
-[`../features/extending-with-providers.md`](../features/extending-with-providers.md#wiring-providers-into-the-loader)
-has a worked example per language and lists which commands load the provider in each.
+Every port takes a provider the same way in principle, wired in its own config. The per-language
+wiring, with a worked example for each, is in
+[`../features/extending-with-providers.md`](../features/extending-with-providers.md#wiring-providers-into-the-loader).
 
 ## When NOT to write a provider
 
diff --git a/server/java/README.md b/server/java/README.md
index 4bef7748f1..c9f270b60e 100644
--- a/server/java/README.md
+++ b/server/java/README.md
@@ -121,7 +121,7 @@ A single directory may freely mix `.json` and `.yaml` files — the loader disco
 
 ## Provider-based registration
 
-Java's `MetaDataTypeProvider` (`ServiceLoader`-discovered) is the implementation of the type-provider model adopted across every language port. New types, subtypes, and attributes are contributed by a provider — never by editing central registry files. Type-binding to native classes is resolved through `ObjectClassRegistry` at load time, not via reflection, so the registration stays AOT/native-image friendly (see [ADR-0001](../../spec/decisions/ADR-0001-cross-language-type-binding.md)).
+Java's `MetaDataTypeProvider` is the implementation of the type-provider model adopted across every language port. New types, subtypes, and attributes are contributed by a provider — never by editing central registry files. A project names its providers explicitly (Maven plugin ``, or Spring beans); see [extending-with-providers](../../docs/features/extending-with-providers.md). Type-binding to native classes is resolved through `ObjectClassRegistry` at load time, not via reflection, so the registration stays AOT/native-image friendly (see [ADR-0001](../../spec/decisions/ADR-0001-cross-language-type-binding.md)).
 
 ## Build + test