Skip to content

Commit 04f46c0

Browse files
dmealingclaude
andcommitted
docs(adr): ADR-0001 cross-language type binding; record FR-003 jsonb POJO + binding decisions
Establish spec/decisions/ ADR practice. ADR-0001: build-time, domain-sliced, FQN-keyed binding (OO=ServiceLoader registry, data-oriented=static imports), never runtime reflection. CLAUDE.md gets the durable rule + ADR pointer. FR-003 updated: jsonb = typed mutable POJOs (always-reserialize-on-update, ValueObject fallback); new binding-registry capability per ADR-0001. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6653973 commit 04f46c0

4 files changed

Lines changed: 90 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,10 @@ These are the load-bearing principles that have emerged through implementation.
423423

424424
- **"Validated by spike" ≠ "right design".** A spike proves a technique works under a specific test. It doesn't prove it is the best production choice. Always ask "what's the UX cost?" alongside "does this work?"
425425

426+
- **Bind metadata→native types at build time, never runtime reflection.** Resolving an object's native class/module from its FQN must happen in generated code (static imports for data-oriented ports; a domain-sliced, FQN-keyed registry for OO ports), not via `Class.forName`/`Type.GetType`/`importlib` — runtime reflection is impossible in TS and breaks under GraalVM native-image / .NET AOT. See [ADR-0001](spec/decisions/ADR-0001-cross-language-type-binding.md).
427+
428+
- **Record significant cross-cutting decisions as ADRs.** Durable, cross-language/cross-feature architectural contracts live in `spec/decisions/` (Nygard format). Consult them before changing a cross-language contract; add a new ADR when you make one. Feature-level decisions stay in the FR spec; this file holds only the one-line rule + the pointer.
429+
426430
## Coding discipline (TS)
427431

428432
- **Named constants for metamodel strings — always.** Type names, subtype names, reserved JSON keys, special attribute names, structural separators, and wildcards live in `packages/metadata/src/constants.ts` — import and use them. Gets you compile-time typo safety.

docs/superpowers/specs/2026-05-22-fr-003-omdb-persistence-schema-migration-projections-design.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,19 @@ The required modules — `metadata`, `core`, `codegen-*`, `maven-plugin`, `core-
5050
### 2. Spring-transaction-aware connection
5151
`ObjectConnectionDB(Connection)` already accepts an externally-owned connection. Ship (likely in `metaobjects-core-spring`) a thin adapter that wraps `DataSourceUtils.getConnection(dataSource)` and does **not** close the connection (Spring owns the lifecycle), so OMDB operations join the caller's active `@Transactional` scope — including `REQUIRES_NEW` and pessimistic boundaries. No second, out-of-transaction connection.
5252

53-
### 3. jsonb fields modelled as value objects (opt-in)
54-
A jsonb column may be either opaque JSON **or** declare a nested MetaObject as its value type:
53+
### 3. jsonb fields modelled as typed value objects (opt-in)
54+
A jsonb column may be either opaque JSON **or** declare a nested MetaObject as its value type. When modelled, the value is a **code-generated, mutable, typed POJO** (the default — real objects, IDE/type-safe), with `ValueObject` (the dynamic Map container) as the **fallback** for intentionally-open/dynamic shapes:
5555

5656
```jsonc
5757
// opaque
5858
{ "field.object": { "name": "rawConfig", "@dbColumn": "raw_config", "@dbType": "jsonb" }}
5959

60-
// modelled — value is a (list/map of) ValueObject(s)
60+
// modelled — value is a generated typed POJO (list/map per cardinality)
6161
{ "field.object": { "name": "dispositions", "@dbColumn": "dispositions",
6262
"@dbType": "jsonb", "@objectRef": "Disposition", "@isArray": true, "@keyedBy": "subjectId" }}
6363
```
6464

65-
`PostgresDriver` gains a jsonb column type + a Jackson-backed value converter that reads the field's referenced MetaObject to (de)serialize `ValueObject` ⇄ jsonb. Cardinality via `@isArray` (List) and optional `@keyedBy` (Map). This shares one modelling path with projections (below).
65+
`PostgresDriver` gains a jsonb column type + a Jackson-backed converter that resolves the field's referenced MetaObject to its **bound Java class** (via the binding registry, §8 / ADR-0001) and (de)serializes the typed POJO ⇄ jsonb; cardinality via `@isArray` (`List<T>`) and optional `@keyedBy` (`Map<K,T>`); `ValueObject` when no class is bound. **Mutability matters:** these objects are read-modify-write (e.g. `realm.getMechanics().setX(…)`), not read-only DTOs — so generated jsonb value types are **mutable POJOs**, not immutable records. Because in-place mutation leaves the parent's field reference unchanged (so OMDB's dirty-field check can't detect it), **jsonb fields are always (re)serialized on `updateObject`** (deep-compare is a later optimization). This shares one modelling path with projections (below).
6666

6767
### 4. Metadata-driven schema migration (decoupled `meta migrate`)
6868
Extend `MetaClassDBValidatorService` + drivers from *create-if-missing* to a **diff-and-converge** engine: add tables/columns/indexes/FKs/sequences/views and additive ALTERs (widen type, add nullable/defaulted column). Surface it through a cross-language-aligned verb set, **not** as boot-time auto-apply:
@@ -78,11 +78,14 @@ A consuming app adopts its existing schema as the baseline; metadata must reprod
7878
A projection is `object.value`/`object.entity` + `source.dbView`, with fields carrying `origin.passthrough` (cross-entity ref) or `origin.aggregate` (`count`/`sum`/`avg`/`min`/`max` via dotted `@via`/`@of`). OMDB already creates views from `ViewDef` and verifies read mappings; add the **origin→`CREATE VIEW` SQL deriver** so view SQL is generated when expressible, falling back to explicit `dbViewSQL` only when it is not. Projection results materialize as `ValueObject`s (the same container used for opaque/value-object reads), so consumers can blend projected DB data with app-side values.
7979

8080
### 6. Codegen templates
81-
Mustache templates/generators (build-time, via the maven-plugin) for: entity base classes (POJO mapped via `PojoMetaObject`, no business logic), per-entity SQL-name constants (`Tables`/`Columns` — keep residual native SQL rename-safe + drift-checkable), projection value-object classes, and an OMDB-backed repository base (CRUD via `Expression`/`QueryOptions`; advanced queries via `dbViewSQL`/`executeQuery`). Generated artifacts carry no business logic; consumers extend them.
81+
Mustache templates/generators (build-time, via the maven-plugin) for: entity base classes (POJO mapped via `PojoMetaObject`, no business logic), per-entity SQL-name constants (`Tables`/`Columns` — keep residual native SQL rename-safe + drift-checkable), **typed mutable jsonb value-object POJOs** (§3), projection value-object classes, an OMDB-backed repository base (CRUD via `Expression`/`QueryOptions`; advanced queries via `dbViewSQL`/`executeQuery`), and **one `MetaDataTypeProvider` per generated package that registers its `FQN → class` bindings** (§8 / ADR-0001), `ServiceLoader`-discovered. Generated artifacts carry no business logic; consumers extend them.
8282

8383
### 7. Conformance fixtures
8484
Extend the existing `source-*`/`origin-*` corpus to cover the new vocabulary surfaced here (`@generation`, `@previousName`, jsonb value-object fields, projection origin→view derivation at the **metamodel** level). A full migration-output conformance corpus (byte-identical SQL across languages) is flagged **future** (consistent with the C# design); v1 asserts vocabulary + loader/serializer parity and diff *determinism* per language.
8585

86+
### 8. Metadata→Java-class binding registry (per ADR-0001)
87+
OMDB instantiates **typed objects** — entities, jsonb value-objects (§3), and projection results — by resolving a MetaObject's FQN to its concrete Java class. Per **[ADR-0001](../../../spec/decisions/ADR-0001-cross-language-type-binding.md)**, this is a **build-time, domain-sliced, FQN-keyed registry — never runtime reflection** (`Class.forName` is AOT/native-image-hostile; the cross-language contract forbids it). Java realization: codegen emits **one `MetaDataTypeProvider` per generated package** that registers its `FQN → Class` bindings; `ServiceLoader` discovers and merges them across packages/jars (the same mechanism core already uses for its 8 type providers). OMDB consults the merged registry to pick the class for `newInstance()`/jsonb deserialization, falling back to `ValueObject` when no class is bound. This FR builds the **runtime registry contract** (the registry API + OMDB consulting it, tested with hand-registered bindings); the codegen that *emits* the per-package providers ships with the codegen templates (§6). The deferred metadata overlay (`@object` FQN map) is replaced by this generated, compile-checked registration — and never reappears as a hand-maintained file.
88+
8689
## Versioning & compatibility
8790

8891
`6.x → 7.0.0`, by SemVer (and OSGi semantic versioning, since the project ships bundles): a major bump is mandated by **breaking public-API changes**, regardless of how much additive work rides along.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# ADR-0001 — Cross-language metadata→native-type binding
2+
3+
**Status:** Accepted — 2026-05-22
4+
**Applies to:** all language ports (TS, Java, Python, C#)
5+
**Related:** the OMDB persistence work (`docs/superpowers/specs/2026-05-22-fr-003-*`); `spec/metamodel.md`; `spec/conformance-tests.md`
6+
7+
## Context
8+
9+
Metadata is **language-agnostic**: an object is identified by a fully-qualified name (FQN) like `myapp::commerce::Program`. At runtime and/or codegen time, each language must bind that FQN to its native representation — the generated class to instantiate (OO ports), or the generated module/schema to use (data-oriented ports). The question is *how*, in a way that is consistent across languages, survives modern toolchains, and doesn't pollute the metadata.
10+
11+
Three naive approaches fail:
12+
13+
- **Runtime reflection by name** (`Class.forName` / `Type.GetType` / `importlib`): impossible in TS/JS (no runtime "class from string" reflection), and increasingly broken in Java/C# by **GraalVM native image** and **.NET Native AOT / trimming**, which remove reflection at build time. The industry is actively moving *away* from this (Spring AOT generates reflection-free hints; `System.Text.Json` requires source generators and lets you disable reflection entirely).
14+
- **A single global registry**: doesn't scale — codegen runs **per package/domain/target**, often into separate artifacts/jars; one monolithic registry can't be assembled from independently-generated slices.
15+
- **A hand-maintained (or generated-JSON) metadata overlay** mapping FQN→class: drifts from the code, pollutes the language-agnostic metadata with per-language class paths, and is only validated at runtime.
16+
17+
Industry precedent (cross-language schema tools): **Protobuf** uses a `TypeRegistry` keyed by FQN/type-URL, populated by **generated code in each `.proto`'s output that self-registers** (`proto.RegisterType`, etc.). **gRPC** emits **per-service** registration that composes at startup. **Jackson** prefers explicit `@JsonSubTypes` over reflection scanning. **class-transformer** (TS) requires explicit discriminator maps because TS has no reflection. **SQLAlchemy** centers a `registry` construct that declarative and imperative mapping both populate.
18+
19+
## Decision
20+
21+
**Resolve the binding at build/codegen time, in generated code — never via runtime reflection.** The binding is keyed by the canonical metadata FQN and is **domain-sliced and composable** (each codegen unit contributes its own bindings; the runtime aggregates them). The realization is idiomatic per language paradigm:
22+
23+
| Paradigm | Realization | Discovery / composition |
24+
|---|---|---|
25+
| **Data-oriented** (TS) | generated **static imports** (convention: namespace→file path) + lookup by name in the loaded metadata tree; **no registry object** (codegen emits plain types + schemas, not OO classes) | barrel re-export; the metadata tree *is* the runtime registry |
26+
| **OO** (Java / C# / Python) | generated, **domain-sliced registration** into an **FQN-keyed runtime registry** (typed objects are instantiated, so build-time static imports alone can't provide runtime polymorphic instantiation) | **Java:** one `MetaDataTypeProvider` **per generated package**, auto-discovered + merged via `ServiceLoader`. **C#:** source-generated registration per assembly via a module initializer (AOT-safe). **Python:** per-package registration at import / entry-points. |
27+
28+
The **durable contract** (conformance-describable) is: *the binding key is the metadata FQN, it is established in generated code at build time, and it is composable across independently-generated slices.* The **realization is idiomatic** per language (matching the project's "durable contract identical; runtime surface idiomatic" rule).
29+
30+
The language-agnostic metadata holds **no** native class paths. Any one-off divergence from convention is expressed as an inline `@object` attribute on a single MetaObject — never a whole overlay file.
31+
32+
## Consequences
33+
34+
**Positive**
35+
- Compile/type-checked in TS/Java/C# (rename a class → the generated registration won't compile); import-checked in Python. Drift caught at build, not runtime.
36+
- **AOT / native-image safe** (no runtime reflection-by-name).
37+
- **Domain-sliced & composable** — multi-package/multi-jar codegen each contributes a registration unit; the runtime composes them (Java `ServiceLoader` is purpose-built for exactly this; it already loads core's type providers).
38+
- Metadata stays language-agnostic; no driftable overlay.
39+
- Matches what the TS port already ships (this ADR validates and generalizes existing behavior, it does not re-architect it).
40+
41+
**Negative / costs**
42+
- OO ports emit one small registration unit per codegen slice (a generated artifact). This is folded into the per-target codegen output already produced, and is the same model protobuf/gRPC use, so the cost is minimal and idiomatic.
43+
- The runtime needs a registry abstraction + a discovery hook per language (a one-time investment per port).
44+
45+
## Alternatives considered (rejected)
46+
1. **Runtime reflection / convention-by-name** — impossible in TS; AOT/native-image-hostile in Java/C#. Rejected as a *universal* mechanism (it remains available only as a per-language optimization where reflection is acceptable).
47+
2. **Single global registry** — can't be assembled from independently-generated package slices.
48+
3. **Generated/maintained metadata overlay (FQN→class JSON)** — driftable, runtime-only checking, pollutes language-agnostic metadata.
49+
50+
## Realization status
51+
- **TS** — shipped (data-oriented: static imports + metadata-tree lookup; no registry object needed).
52+
- **Java** — in the OMDB persistence work: per-package `MetaDataTypeProvider` registry via `ServiceLoader`, consulted by ObjectManagerDB to instantiate entities and typed jsonb value-objects.
53+
- **C#** — future runtime work: source-generated registration per assembly.
54+
- **Python** — post-H3.
55+
56+
## Conformance note
57+
Runtime instantiation APIs are out of byte-identical conformance scope (per `spec/conformance-tests.md` — runtime surface is idiomatic). What is conformance-relevant is the **key**: the canonical FQN form (`pkg::Name`) used as the registry key, which the loader/serializer corpus already pins.

spec/decisions/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Architecture Decision Records (ADRs)
2+
3+
This directory records **significant, cross-cutting architectural decisions** for the MetaObjects standard — the durable "why" that outlives any single feature spec.
4+
5+
## What goes here vs. elsewhere
6+
7+
| Doc type | Location | Scope |
8+
|---|---|---|
9+
| **ADR** | `spec/decisions/` *(here)* | Durable, **cross-cutting, target-agnostic** contracts that bind multiple language ports (e.g. how metadata→native types are bound; the conformance model; persistence-engine choice). |
10+
| Feature design (FR spec) | `docs/superpowers/specs/` | A single feature's design, language- or area-scoped. |
11+
| Implementation plan | `docs/superpowers/plans/` | Task-by-task execution of a feature. |
12+
| Conventions / pointers | `CLAUDE.md` | Lean, always-loaded context: durable one-liners + pointers here. |
13+
14+
## When to write an ADR
15+
Write one when a decision is **cross-cutting** (affects more than one language port or more than one feature) and **durable** (future work must respect it). Examples: a metamodel binding contract, the wire-format guarantee, "migrations decoupled from the runtime." Do **not** write ADRs for feature-level or easily-reversible choices — those live in the FR spec.
16+
17+
## Format
18+
[Michael Nygard's ADR format](https://github.com/joelparkerhenderson/architecture-decision-record): **Context → Decision → Consequences → Status**, plus *Alternatives considered* and *Realization status* where useful. One decision per file. Status is `Proposed` | `Accepted` | `Superseded by ADR-NNNN`. Numbered sequentially; never renumber (supersede instead).
19+
20+
## Index
21+
- [ADR-0001 — Cross-language metadata→native-type binding](ADR-0001-cross-language-type-binding.md)*Accepted*

0 commit comments

Comments
 (0)