Skip to content

feat(metamodel): drift checking and quarantine - #86

Merged
jimador merged 11 commits into
feat/metamodel-difffrom
feat/metamodel-drift
Sep 8, 2026
Merged

jimador merged 11 commits into
feat/metamodel-difffrom
feat/metamodel-drift

Conversation

@jimador

@jimador jimador commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

PR 4 of the metamodel train, stacked on #85. Drift checking and quarantine. DriftCheckRunner is a reporting-only contract: run() declares, stamps, observes, compares against the graph and against the swept baseline, and persists a DriftReport — its implementation holds no policy, no proposition store and no listener, so a check structurally cannot move a proposition. Acting on a check is a separate SPI, DriftSweepCapable: bounded, ContextId-scoped candidate paging, applyQuarantine, releaseFromQuarantine, and a defaulted sweep that persists what DriftQuarantinePolicy flags. Lossiness rules live in MentionTypeDriftQuarantinePolicy: type change, value/reference flip, or cardinality narrowing quarantines; widening never does; declared renames quarantine nothing by themselves, and former names are reachable however far a rename and a loss are separated. DriftReport/DriftReportStore carry the persistence contract with three explicitly scoped, bounded reads.

Changed in this review round:

  • Quarantine has its own status. PropositionStatus.QUARANTINED replaces the STALE-plus-metadata encoding, which the decay lifecycle used to undo: DecayStatusPolicy revived a quarantined proposition on the next sweep and the drift sweep re-held it, alternating forever. Now DecayStatusPolicy refuses QUARANTINED outright, reads filtering on ACTIVE exclude it structurally, isAlreadyQuarantined keys on status alone, and release is the only way out — restoring the recorded prior status, clearing the reason, emitting PropositionStatusChanged. A regression test proves three decay-then-drift rounds leave the hold in place.
  • The quarantine machinery moved to dice (com.embabel.dice.spi, beside StatusTransitionPolicy), so dice-metamodel drops its dice dependency and is again a leaf over the agent DataDictionary.
  • The runner backed off to report-only and the sweep became the explicit SPI above; declared-vs-previous (off the swept baseline, tracked apart from version history) now feeds quarantine, declared-vs-observed stays report-only.
  • Pinned propositions keep their cross-cutting immunity: a lossy change reports them as QuarantineDecision.Protected and never flips them.
  • The drift and lifecycle docs state the product rule once: a declared rename or alias changes how the diff and the drift check read old data; nothing rewrites a stored mention type.

Breaking changes: additive on the released surface, with three stated source-level exceptions — QuarantineDecision is sealed and gains Protected; PropositionStatus gains QUARANTINED, so an exhaustive when needs a branch (one internal site changed; a host that matches on status exhaustively recompiles); the quarantine types moved package before ever shipping. Persistence round-trips by enum name, so no stored value changes meaning.

Opt-in and status: EXPERIMENTAL. Nothing quarantines until a host calls DriftSweepCapable.sweep() on a diff it decided to act on; there is no scheduler and no default that moves a proposition.

flowchart TD
    A[DriftCheckRunner.run] --> B[diff declared vs previous swept baseline]
    A --> C[diff declared vs observed graph]
    B --> D[DriftReport, persisted]
    C --> D
    E[host calls DriftSweepCapable.sweep] --> F[DriftQuarantinePolicy]
    B --> F
    F --> G{pinned?}
    G -- yes --> H[Protected, reported and untouched]
    G -- no --> I{lossy change?}
    I -- yes --> J[QUARANTINED plus PropositionStatusChanged]
    I -- no --> K[untouched]
    J --> L[releaseFromQuarantine: prior status restored, reason cleared]
    M[decay sweep] -. cannot lift the hold .-> J
Loading

Next in stack: #87 Drivine drift persistence and observation, #88 wiring.

@jimador
jimador force-pushed the feat/metamodel-drift branch 2 times, most recently from 95a4d5e to f799a8a 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-drift branch from afc4c2e to 099e902 Compare September 1, 2026 12:25
@jimador
jimador force-pushed the feat/metamodel-drift branch from 099e902 to a8ce6bc Compare September 2, 2026 10:51
@jimador
jimador force-pushed the feat/metamodel-drift branch from a8ce6bc to ec7b957 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-drift branch from ec7b957 to 9d001d5 Compare September 2, 2026 20:52
Comment thread dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt
Comment thread dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt
@jimador
jimador force-pushed the feat/metamodel-drift branch from 1b9703f to ee2df40 Compare September 3, 2026 21:04
@igordayen

Copy link
Copy Markdown

From CODEX:

  1. dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt:452

This can miss propositions that should be quarantined.

Example:

  • schema type: com.example.Outer$Person
  • stored mention type: Person
  • later, that type loses the property age

That proposition should be quarantined because it refers to the type that changed.

But this policy only recognizes the type as:

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

It does not recognize Person as the same type. So when a proposition is stored under Person, this policy treats it as unrelated to
the changed type and leaves it unquarantined.

Conclusion:
Lossy changes can be missed when the stored label differs from the names this policy checks.

@igordayen

Copy link
Copy Markdown

From CODEX - inquiry on concurrency:

  1. dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt:69

markSwept() is last-write-wins with no guard against an older sweep finishing after a newer one, so the baseline can move backward.

Example:

  • sweep 1 starts for schema version A
  • schema changes, then sweep 2 starts for newer version B
  • sweep 2 finishes first and calls markSwept(B)
  • sweep 1 finishes later and calls markSwept(A)

After that, sweptVersion() returns A again even though B was already reconciled.

Why that matters:
The next drift check compares the current declaration against A, so it can re-report changes from A -> B that were already swept. In
other words, an older concurrent sweep can overwrite the newer baseline just by finishing later.

markSwept() needs some way to reject stale completions, or the caller has to guarantee sweeps for one schema never overlap.

@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 comments from me and Codex:) thank you

Comment thread dice-metamodel/pom.xml
Comment thread dice-metamodel/pom.xml Outdated
@jimador
jimador force-pushed the feat/metamodel-drift branch 2 times, most recently from 846a15a to d57052a Compare September 4, 2026 03:47
@jimador
jimador force-pushed the feat/metamodel-drift branch from d57052a to fbed94e Compare September 4, 2026 04:31
@jimador

jimador commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

On Codex finding 1:

The Outer$Person case is the nested-class spelling that sits outside the built-in dot cut, and it is handled the same way in the quarantine policy as in the differ on #85: the own-label KDoc names it as one of the two spellings a host maps itself through the TypeIdentity SPI. The policy's spellingsOf uses the same cut as the differ, so the two stay in agreement, and the consuming slice for TypeIdentity lands separately.

On finding 2 (concurrency):

Correct: markSwept is last-write-wins and DICE never calls it. The host does, after a sweep. The contract is now written on the interface in 8c2683b: sweeps of one schema must not overlap, and the call site runs them one at a time. A compare-and-set form, markSwept(version, expectedBaseline), is the follow-up if a host ever needs overlapping sweeps. I would take it as a separate issue.

@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-drift branch from fbed94e to 1fe86db Compare September 7, 2026 03:35
@jimador
jimador force-pushed the feat/metamodel-drift branch from 1fe86db to 5df4d34 Compare September 8, 2026 20:30
DriftReport with a bounded, explicitly scoped DriftReportStore contract
(scope-before-limit pinned by test), DriftCheckRunner with dryRun-first
semantics, and the quarantine layer: lossiness decided on property
signatures (narrowing quarantines, widening never), already-quarantined
classification independent of the current diff, non-destructive STALE
writes. DefaultDriftCheckRunner stamps the declared version every run so
report hashes always resolve, and takes the base PropositionStore port.
dice-metamodel now depends on dice core.

Refs #45; stacks on feat/metamodel-diff.
Comment and doc text only; no code change.
A paired rename quarantines nothing by itself: the diff folds the rename's
own propagation, so the policy only ever sees deltas that survived, and a
renamed property's delta is judged by the same narrowing rules as any
signature change. Candidate matching reads the declaration's accumulated
former names, and a removed type's former names from the older stamp, so
data labeled under any former name is caught however far the rename and the
loss are separated — while a former name redeclared as a live type is
excluded, and a deliberately retired one surfaces as undeclared drift
instead. Four type widenings stop reading as lossy: int to long, float to
double, and their boxed pair, pinned against the dictionary's real rendered
names.
The drift runner compared each declaration against the newest stored
version, which is the version it had just written itself, so a lossy
declared change was invisible to the very run that recorded it.

MetamodelVersionStore gains a sweptVersion/markSwept pair. The runner
reads the baseline before its history write and advances it last, only
when a live unscoped sweep completed. Both methods default-forward to
latestVersion/saveVersion, so existing stores compile unchanged and keep
the old behaviour until they override; InMemoryMetamodelVersionStore
tracks the pointer independently.

Alongside that:

- QuarantineDecision.Protected and QuarantineResult.protected report
  propositions a pin held back, which used to look like a plain skip.
- PropositionStatusChanged is emitted per quarantined proposition, and
  only when the status actually moved.
- The merged diff keeps MetamodelDiff's global ordering: removals lead as
  one sorted block whichever source produced them.
- @jvmoverloads on the runner constructor so Java callers can omit the
  listener.
The runner now evaluates and reports only: run() stamps the declared
version, writes the report, and touches no proposition and no swept
baseline. Sweeping is a host call through PropositionStoreDriftSweep,
whose candidate selection is bounded and ContextId-scoped, closing the
findAll path that materialized every tenant. Quarantine records the
prior status in metadata and release restores it while clearing the
reason, so a quarantine is reversible. sweptVersion and markSwept move
to SweptBaselineStore with no default bodies: saving a version never
implies a completed sweep, and a store that cannot track the baseline
lacks the surface to claim one. Reports carry declaredDiff, so a dry
report shows the facts a deliberate sweep would act on. The quarantine
policy matches declared types by own label, consistent with the differ.
Quarantine was a STALE plus a reason key, and the default decay policy
revives any STALE proposition whose utility clears the recovery
threshold with no look at the reason — so a quarantined proposition
with healthy confidence came back on the next decay sweep and the two
sweeps alternated. PropositionStatus.QUARANTINED ends that: reads that
filter on ACTIVE exclude it structurally, the lifecycle policies leave
it alone entirely, and release is an explicit transition that restores
the recorded prior status, clears the reason, and announces the change.
The quarantine machinery moves into dice beside StatusTransitionPolicy,
where the conflict it resolves lives, and dice-metamodel returns to a
leaf over DataDictionary with no dice dependency. Declared renames and
aliases change how the diff and the drift check read old data; nothing
rewrites a stored mention type.
The architecture doc inverted the module direction and still described
quarantine as STALE plus a reason, the lifecycle doc had no QUARANTINED
entry, and the QUARANTINE_REASON KDoc carried the old model. All three
now state what ships: dice-metamodel is a leaf, QUARANTINED is its own
status entered only by a deliberate sweep and left only by release, and
DriftMode is OFF and OBSERVE. The quarantine and swept-baseline entries
carry the EXPERIMENTAL marker and their opt-in triggers.
The lifecycle diagram showed one road in: ACTIVE to QUARANTINED. The
policy quarantines from any non-quarantined status, STALE included, and
release restores the recorded prior status. The diagram now draws both,
and the trigger list carries the new transitions the way every other
transition has one. The drift and sweep types carry
ApiStatus.Experimental.
…ontract

dice-metamodel declared org.jetbrains:annotations twice, the second time with a hard-coded version. The first declaration resolves from the root dependencyManagement and is the one that stays. markSwept is last-write-wins, so its KDoc now says that sweeps of one schema must not overlap and that the call site runs them one at a time.
Commit 959712c added a duplicate org.jetbrains:annotations block here as it did to dice-metamodel. The first declaration, with no version, is the correct one; the version comes from the root pom's dependencyManagement through jetbrains.annotations.version.
@jimador
jimador force-pushed the feat/metamodel-drift branch from 5df4d34 to 56d1bd6 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