Skip to content

feat(metamodel): observed-schema and diff contracts - #85

Merged
jimador merged 8 commits into
feat/metamodel-version-storefrom
feat/metamodel-diff
Sep 8, 2026
Merged

jimador merged 8 commits into
feat/metamodel-version-storefrom
feat/metamodel-diff

Conversation

@jimador

@jimador jimador commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

PR 3 of the metamodel train, stacked on #84. This slice defines the two comparisons the governance loop runs on. ObservedSchema/ObservedSchemaSource is the SPI a storage backend implements to report what a live graph actually contains — names only, since a graph exposes labels and relationship types without signatures. MetamodelDiff/MetamodelChange is a sealed change taxonomy: a property that kept its name and changed type, cardinality or kind reports as one PropertySignatureChanged entry. MetamodelDiffer compares two declared versions; DeclaredObservedDiffer compares a declaration against the graph. Declared renames pair in the diff (EXPERIMENTAL): EntityTypeRenamed, PropertyRenamed and EntityTypeAliasesChanged are new sealed members, pairing is one-to-one on the residual sets after name matching, comparison substitutes old-for-new in reference targets and label sets only, and a pure rename yields one change. Accumulated former names join the declared side of the observed diff, so old-labeled data under a declared rename is never drift.

Changed in this review round:

  • Declared and observed types compare by own label. JvmType.name is a fully qualified class name while the graph reports simple labels, so a healthy FQN schema previously came back both unobserved and drifted at once. DeclaredSchema.entityTypeOwnLabels (via DeclaredSchema.ownLabelOf) fixes both directions; reported spellings stay as the stamp declares them.
  • Known-but-ungoverned types stay out of observed drift, covering their own labels too.
  • Contested rename claims report as such; the differ never picks a winner silently.
  • TypeIdentity added (EXPERIMENTAL): the contract for mapping external type spellings onto declared names, with no implementation and no wiring yet.

Breaking changes: one stated source-level exception — MetamodelChange is sealed and gains members, so an external exhaustive when over it needs new branches. Binary compatibility is untouched; consumers recompile. Everything else is additive.

The two comparisons. Declared-vs-declared answers what a schema edit did; declared-vs-observed answers what the live graph holds that the declaration does not cover:

flowchart TD
    A[DeclaredSchema, version n] --> B[MetamodelDiffer.diff]
    C[DeclaredSchema, version n-1] --> B
    B --> D[declared vs declared: renames paired, signatures compared modulo renames]
    A --> E[DeclaredObservedDiffer.diffAgainstObserved]
    F[ObservedSchema, names only from a live graph] --> E
    E --> G[declared vs observed: undeclared types and relationship names, judged by own label]
    D --> H[MetamodelDiff: sealed MetamodelChange taxonomy]
    G --> H
Loading

Next in stack: #86 drift checking and quarantine.

@jimador
jimador force-pushed the feat/metamodel-diff branch 2 times, most recently from cd3a1d7 to 59b9e89 Compare August 31, 2026 19:40
@jimador
jimador marked this pull request as ready for review September 1, 2026 04:07
@jimador
jimador force-pushed the feat/metamodel-diff branch from 747641a to add28d0 Compare September 2, 2026 10:51
@jimador
jimador force-pushed the feat/metamodel-diff branch from add28d0 to 28031df Compare September 2, 2026 14:11
@jimador
jimador requested a review from igordayen September 2, 2026 20:41
@jimador
jimador force-pushed the feat/metamodel-diff branch from 28031df to e71e04d Compare September 2, 2026 20:52
@jimador
jimador force-pushed the feat/metamodel-diff branch from e71e04d to 2d5d072 Compare September 3, 2026 21:04

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - few minor comments about visibility. Codex report - coming. thank you

Comment thread CHANGELOG.md
@igordayen

Copy link
Copy Markdown

from Codex:

  1. The rename handling is broader for drift than it is for unobservedEntityTypes

Example:

  • Declared type name: Human
  • Declared aliases in the stamp: Person
  • Observed graph labels: Person

Expected result:

  • This type should not be reported as unobserved, because the graph contains data under the declared former name of the same type.

What the code does:

  • For drift, it treats declared former names as known, so Person is not reported as drift.
  • For unobservedEntityTypes, it checks only whether the graph contains:
    • the current declared type name, or
    • ownLabelOf(currentDeclaredTypeName)

For this example, those checks are:

  • Human
  • Human

They do not include the declared former name Person.

Conclusion:

  • The same graph can be interpreted as “not drift” and also “type unobserved”.
  • That means declared renames are only applied on one side of the declared-vs-observed comparison, not both.
  1. The observed-side match is narrower than the declared schema it is comparing against.

Example:

  • Declared type name: com.example.Outer$Person
  • Declared labels in the stamp: Person
  • Observed graph labels: Person

Expected result:

  • This type should count as observed, because the graph contains the label the declaration says this type uses.

What the code does:

  • For drift, it does consider the wider declared name set.
  • For unobservedEntityTypes, it checks only whether the graph contains:
    • the declared type name itself, or
    • ownLabelOf(declaredTypeName)

For this example, those checks are:

  • com.example.Outer$Person
  • Outer$Person

Neither matches the actual observed label Person.

Conclusion:

  • A type can be reported as unobserved even when the graph contains data under the label declared for that type.
  • That means the observed-side matching rule is not using all of the declared identity information already present in the stamp.

@jimador
jimador force-pushed the feat/metamodel-diff branch from 2d5d072 to 8d4aaf4 Compare September 4, 2026 03:15
@jimador
jimador force-pushed the feat/metamodel-diff branch from 8d4aaf4 to a962035 Compare September 4, 2026 03:47
@jimador

jimador commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Answering the Codex findings.

Finding 1 is right and is fixed in 6897662 on this branch, with the stack above restacked on it.

  1. unobservedEntityTypes now treats a declared former name as evidence the type is observed, the same way drift already does. isObserved takes the type's declared aliases and answers true when the current name or any former name is in the observed set, as spelled or as its own label. Three tests cover it: data only under the former name, data only under a qualified former name's label, and a guard that a type with no data under any of its names stays unobserved. The check widens to the type's own names only. A declared label set carries inherited parent labels, so accepting that as evidence would mark an empty Person observed whenever any Agent node exists.
  2. The Outer$Person spelling is outside the built-in cut on purpose. The own-label KDoc names the two spellings the dot cut does not handle, a nested class with $ and a platform that uppercases the first character, and says a host that meets either one maps its own names through the TypeIdentity SPI in this package. That SPI is a spec on this branch and its consuming slice lands separately, so this stays as is.

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - looks good, thank you!

@jimador
jimador force-pushed the feat/metamodel-diff branch from 6897662 to 4af1e0e Compare September 7, 2026 03:35
@jimador
jimador force-pushed the feat/metamodel-diff branch from 4af1e0e to 9164ff1 Compare September 8, 2026 20:30
ObservedSchema/ObservedSchemaSource SPI for what a live graph actually
holds, and a signature-aware diff layer: PropertySignatureChanged reports
a reshaped property as one entry, the declared set for drift carries the
full label closure so inherited labels never read as drift, and every
diff value object is JVM-immutable at the public boundary.

Refs #45; stacks on feat/metamodel-version-store.
Comment and doc text only; no code change.
Three new sealed changes: EntityTypeRenamed, PropertyRenamed, and
EntityTypeAliasesChanged. Pairing is sorted and one-to-one at both levels,
runs on the residual sets after name matching, and folds a rename's own
propagation — the name-implied label swap, the implied alias entry, and
referrer signatures and child labels compared modulo the paired rename map,
REFERENCE kinds only — into the rename itself, so a pure rename reads as one
change and nothing else. Alias-only deltas keep the empty-diff-iff-equal-hash
contract at both levels, and a renamed type's accumulated former names join
the declared side of the observed diff.
Pairing now requires an exclusive claim on both sides. Claims read as a
bipartite graph: a connected one-to-one group is a rename, anything
larger is contested — reported whole through two new sealed members and
fallen back to plain removed and added, never fed to modulo-rename
substitution. Report rather than throw, because both sides of a diff are
stamped persisted values a caller cannot edit. Typed accessors round out
the change list (the ambiguity pair, added and removed relationships),
with an eleven-change partition fixture that makes every accessor
load-bearing.
DeclaredSchema.from filtered the dictionary down to the governed types
before the observed comparison ever ran, so a type the host declared and
deliberately left outside governance arrived looking exactly like a type
nobody declared. The runner read it as a removal and the quarantine policy
could mark its propositions STALE — the opposite of what per-type
governance promises.

The declaration now carries the known-but-ungoverned type and relationship
names beside the governed stamp, as a third set distinct from the governed
names and from declared aliases, and diffAgainstObserved excludes them.
A selector governing no types now enforces no types: every dictionary type
stays ungoverned and only a name the dictionary never declared reads as
drift.

Refs #45; stacks on feat/metamodel-version-store.
Declared entity type names are JVM FQNs while observed mention types
arrive as bare simple names, so diffAgainstObserved reported every
declared type unobserved and could misread a same-named observation.
DeclaredSchema now derives entityTypeOwnLabels and the differ matches
either spelling, reporting drift under declared names. Ungoverned and
former names get the same treatment, proven by discriminating tests.
TypeIdentity ships as a spec-only SPI for mapping external type systems
onto declared identity: interface, contract KDoc and an OpenAPI example,
with no production references.
The drift side already treats a declared former name as known, so nodes
still labelled with a renamed type's old name are not drift. The
unobserved bucket only looked for the current name and its label, so
the same graph read as "no drift" and "type unobserved" at once. A type
whose data still carries its old label is observed, and both sides now
say so.
@jimador
jimador force-pushed the feat/metamodel-diff branch from 9164ff1 to e280a11 Compare September 8, 2026 20:47
@jimador
jimador merged commit 9d083d8 into main Sep 8, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants