Sense 40: The Metamodel
Sense 40 · Forming · Last touched 2026-07-27
- last_verified: 2026-07-27
Synced from
docs/design/sense_40_the_metamodel.mdin the engine repo — that’s the source; this page is a build-time mirror.
A pack describes its world. The engine describes the language used to describe worlds.
Mixing the two looks fine until two packs need the same sentence and have to copy-paste it. Then it’s drift waiting to happen.
Status: proposed Date: 2026-05-20 Author: jazzisnow Architect + Claude (signal-contract-not-found incident, 2026-05-20) Adjacent: Sense 14 (defines SignalFinding), Sense 25 (identity is also metamodel-shaped), Sense 36 (pack identity), Sense 39 (governance rules are pack-level, but the shape of a rule is metamodel)
The motivating story
Section titled “The motivating story”While building hrcentral’s first perspective on the morning of 2026-05-20, the analyst (Claude, here) wrote a YAML that referenced entity: SignalFinding in its scope.time block. That’s the canonical perspective shape — perspectives aggregate across signal findings, so SignalFinding is the natural time-bearing entity.
The compiler error that followed was a silent surprise:
ERROR: signalcompile.py: FileNotFoundError: Contract not found: ...hrcentral/vai/afs/contracts/signal_contract.v1.jsonsignal_contract.v1.json had never existed in the hrcentral pack, because no signal or perspective in hrcentral had ever referenced SignalFinding before. The first reference made the compiler look up a file that the pack didn’t carry — and the pack didn’t carry it because nobody had needed it.
The diagnostic look revealed the deeper truth: signal_contract.v1.json in the numetrix pack and the hypothetical one needed in hrcentral are byte-identical. They describe the same row shape — polarity, score, direction, entity_type, entity_id, time_bucket, severity, money_at_risk — because that row shape is what every signal in every pack produces. The contract isn’t about numetrix’s world or hrcentral’s world. It’s about how the engine describes signals.
The same is true of verdict_contract.v1.json, smebit_contract.v1.json, and bitbundle_contract.v1.json. Four files, byte-identical across packs, drifting potential the moment anyone edits one and not the other.
Meanwhile gold_contract.v1.json and silver_contract.v1.json are legitimately pack-specific. numetrix gold has Material and BillingEvent; hrcentral gold has Person and Assignment. The pack’s domain dictates the entities.
The conflation is the problem. Pack-shaped contracts and engine-shaped contracts live in the same directory and look like the same kind of thing. They’re not.
The distinction
Section titled “The distinction”Two kinds of contract, two different responsibilities:
Domain contracts (pack-level)
Section titled “Domain contracts (pack-level)”What: schema for the data in the pack’s bronze / silver / gold layers.
gold_contract.v1.json— every pack defines its own. “In this pack, Person has these fields, Assignment has those fields.”silver_contract.v1.json— same logic, silver layer.
These describe the world the pack is about. They differ across packs because the worlds differ. They belong with the pack.
Metamodel contracts (engine-level)
Section titled “Metamodel contracts (engine-level)”What: schema for the analytical framework’s outputs — the rows the engine produces from domain data.
signal_contract.v1.json— describes the row a signal emits. Same row shape regardless of whether the signal sourced fromMaterialorPerson. Severity, score, polarity, entity_type/id, time_bucket, money_at_risk — pack-independent.verdict_contract.v1.json— VerdictFinding row shape. Pack-independent.smebit_contract.v1.json— SubjectMatterVerdict row shape + registry shape. Pack-independent.bitbundle_contract.v1.json— Dossier + DossierMembership row shape. Pack-independent.
These describe the language the engine uses to describe worlds. They’re identical across packs by construction. They belong with the engine.
The smell test is simple. If two packs’ copies of a contract are byte-identical and would always remain byte-identical for the same engine version, it’s a metamodel contract. If they differ legitimately (and SHOULD differ), it’s a domain contract.
The current state
Section titled “The current state”As of 2026-05-20, every pack ships its own copy of all six contracts in <pack>/contracts/:
| File | Kind | Identical across packs? |
|---|---|---|
gold_contract.v1.json | domain | no (different gold entities per pack) |
silver_contract.v1.json | domain | no |
signal_contract.v1.json | metamodel | yes |
verdict_contract.v1.json | metamodel | yes |
smebit_contract.v1.json | metamodel | yes |
bitbundle_contract.v1.json | metamodel | yes |
Across the current pack inventory (numetrix, hrcentral, millesime, alptrack, interlogic, and a few more in development) the metamodel contracts are duplicated 5-6 times with no enforcement of identity. Drift is one accidental edit away.
The incident on 2026-05-20 happened because hrcentral hadn’t yet duplicated signal_contract.v1.json — its first encounter with the perspective surface revealed it was missing. The same incident has been latent for every pack that hasn’t yet used a metamodel-referencing artifact. The next pack that gains its first perspective, smebit, or bitbundle will rediscover the missing file the same way.
The proposed shape
Section titled “The proposed shape”One engine-level contracts directory
Section titled “One engine-level contracts directory”A new directory at the engine root:
jinflow/ contracts/ signal_contract.v1.json # was duplicated across packs verdict_contract.v1.json smebit_contract.v1.json bitbundle_contract.v1.json perspective_contract.v1.json # (extracted from signal_contract today)Each metamodel contract has one canonical version per engine version. Packs no longer ship them.
Compiler resolution: engine first, pack fallback
Section titled “Compiler resolution: engine first, pack fallback”Each compiler that loads a contract resolves it in this order:
- Engine-level (
jinflow/contracts/<contract>.json) — the canonical version for the current engine. - Pack-level (
<pack>/contracts/<contract>.json) — fallback for legacy packs that haven’t migrated yet OR (rare) packs that explicitly extend the metamodel.
The fallback exists for two reasons:
- Migration safety: existing packs keep working until they’re migrated.
- Pack extension: a pack may legitimately extend the metamodel (e.g., add domain-specific fields to the SignalFinding row that downstream signals will populate). Phase 3 codifies how extension works — for now, fallback handles the rare case.
Domain contracts (gold_contract.v1.json, silver_contract.v1.json) continue to resolve pack-level only — there’s no engine-level domain contract because there’s no engine-level domain.
A contract type taxonomy
Section titled “A contract type taxonomy”Each contract carries a top-level kind field declaring its scope:
{ "version": "signal.v1", "kind": "metamodel", "description": "Signal findings contract...", "entities": { "SignalFinding": { ... } }}{ "version": "gold.v1", "kind": "domain", "description": "Pack gold layer entities for hrcentral...", "entities": { "Person": { ... }, "Assignment": { ... } }}The validator enforces the rule: kind: domain contracts must live pack-side; kind: metamodel contracts must live engine-side. Misplaced contracts produce a compile error with a clear remediation message.
Phasing
Section titled “Phasing”Phase 1 — Lift the four metamodel contracts to engine
Section titled “Phase 1 — Lift the four metamodel contracts to engine”Mechanical. The engine repo grows a contracts/ directory; the four metamodel contracts move there. The current pack-level copies stay in place during transition (they’re the fallback path).
Risk: ~0. The fallback resolution preserves current behaviour for every pack.
Effort: ~30 minutes.
Phase 2 — Update compilers to engine-first resolution
Section titled “Phase 2 — Update compilers to engine-first resolution”signalcompile.py, verdictcompile.py, smebitcompile.py, bitbundlecompile.py each gain a _resolve_contract(name) helper that looks engine-first, pack-fallback. The helper is a small refactor — currently each compiler reads from <afs_root>/contracts/<name>.json directly.
Effort: ~30 minutes (compilers + a test or two per compiler).
Phase 3 — Add the kind discriminator + validator
Section titled “Phase 3 — Add the kind discriminator + validator”Every contract file gains a kind: domain | metamodel field. The validator (or a small new contractcheck.py) enforces correctness:
- Metamodel contracts must live engine-side; finding one in a pack triggers a compile error with the suggested move.
- Domain contracts must live pack-side; finding one engine-side triggers the same.
- The two
kindvalues are exhaustive — no contract can be both or neither.
This is the layer that prevents future drift. With it in place, a future pack author can’t accidentally duplicate a metamodel contract.
Effort: ~1 hour (validator + per-file annotation + a regression test).
Phase 4 — Remove pack-level metamodel copies
Section titled “Phase 4 — Remove pack-level metamodel copies”Once all packs in the engine’s test ladder have rebuilt clean with engine-first resolution, the pack-level copies are removed via jin afs reset or manual deletion. The fallback resolution remains in the compilers as a safety net for old packs that might still ship them.
Effort: 5 minutes × N packs.
Phase 5 (optional) — Pack extension protocol
Section titled “Phase 5 (optional) — Pack extension protocol”For the rare case where a pack legitimately wants to extend a metamodel contract (e.g., numetrix adds a cost_centre field to SignalFinding that downstream signals populate), the contract resolver supports a pack extension file:
packs/numetrix/contracts/extensions/signal_contract.v1.extension.jsonFormat:
{ "extends": "signal.v1", "added_fields": { "cost_centre": "string?", "facility_id": "string?" }}The resolver merges the extension into the engine-level contract at compile time. The pack’s signals can use the added fields; downstream consumers across all packs see the same base shape with optional pack-specific extras.
This is the “open for extension, closed for modification” pattern applied to contracts. Not in scope for the initial migration — most packs won’t need it. Add when a real use case demands.
Effort: ~half-day, deferred.
Cross-cutting design notes
Section titled “Cross-cutting design notes”The metamodel boundary is not arbitrary
Section titled “The metamodel boundary is not arbitrary”The choice of which contracts are metamodel vs domain isn’t aesthetic — it follows the producer/consumer asymmetry:
- A signal consumes domain entities (Person, Material) and produces SignalFinding. The producer side is pack-defined; the consumer side (what SignalFinding looks like) is engine-defined.
- A verdict consumes SignalFinding (engine-shaped) and produces VerdictFinding (engine-shaped). Both sides engine-shaped — verdicts are pure metamodel-to-metamodel transforms.
- A perspective consumes SignalFinding (engine) and produces SignalFinding (engine, with perspective-type). Same.
When the engine evolves the analytical framework (Sense 14 added polarity/score; Sense 39 will add governance findings), every pack should pick up the new shape automatically. With per-pack contract duplication, the engine update requires touching every pack. With engine-level contracts, the update lives in one file.
Why not just delete the pack-level copies and assume engine?
Section titled “Why not just delete the pack-level copies and assume engine?”The fallback resolution path costs nothing once it’s there, and it handles three real edge cases cleanly:
- Old engine + new pack: a pack rebuilt to use engine-only resolution would fail against an engine that doesn’t yet ship the metamodel contracts. Fallback handles this.
- Pack extension (Phase 5): packs that genuinely extend the metamodel need a per-pack file. The fallback path discovers it.
- Migration: deleting pack-level copies before all compilers are updated would break. Fallback enables phased rollout.
Versioning
Section titled “Versioning”Engine-level contracts carry a version (signal.v1, etc.). When the engine bumps to a new version (signal.v2), it ships both alongside. Packs declare which version they’re built against in their manifest:
metamodel_contracts: signal: v1 verdict: v1 smebit: v1 bitbundle: v1The compiler resolves the version matching the pack’s declaration. This gives a clean migration path when the engine introduces breaking metamodel changes — old packs keep building against v1 until they explicitly opt into v2.
For the initial Sense 40 rollout, every pack is v1 across the board. The version selector is forward-looking.
What this Sense does NOT do
Section titled “What this Sense does NOT do”- It doesn’t restructure the analytical framework itself. SignalFinding, VerdictFinding, etc. keep their current shape. Only where the contract describing them lives changes.
- It doesn’t introduce a new contract type. The four moves are all existing files, in their current shape.
- It doesn’t replace pack-side contract loading entirely. Domain contracts (gold/silver) keep their pack-level home.
- It doesn’t gate on Sense 39 (Governance). Governance rules carry a pack-level
governance_rule.v1contract that’s domain-shaped (each pack defines its own rules); the Sense 40 work is independent.
How this manifests in jinflow
Section titled “How this manifests in jinflow”Not yet manifest in the engine. Status: proposed. See the implementation phases / open questions above for the path from this paper to running code.
Open questions
Section titled “Open questions”These are decisions deferred until implementation begins. Each is a real design choice this Sense leaves unfixed.
-
Where exactly does the engine-level
contracts/directory live?- At the engine repo root (
jinflow/contracts/) — closest to the source of truth. - Inside the Python package (
jinflow/jinflow/contracts/) — bundled with the binary, discoverable viaimportlib.resources. - The latter is probably right for distribution — the deployed
jinflowbinary needs to find them at runtime, and bundling them with the package guarantees they ship with the engine.
- At the engine repo root (
-
How does the deployed CLI binary discover the contracts?
- If they live inside the Python package,
importlib.resourcesorPath(__file__).parent / 'contracts'. - Worth confirming this works under PyInstaller’s frozen-binary mode (the deploy build).
- If they live inside the Python package,
-
Do
kind: metamodelcontracts get an immutability flag?- Once they ship in an engine version, in-pack edits should be a compile error. Adding
"frozen": trueor similar to the contract JSON makes this explicit. - Alternatively: the validator just checks that pack-side metamodel contracts (during the transition period) are byte-identical to the engine version, and refuses to compile if they’ve drifted.
- Once they ship in an engine version, in-pack edits should be a compile error. Adding
-
What about
perspective_contract.v1?- Today, perspectives use
contract: "signal.v1"because they emit the same row shape as signals. This is a convenient overload but conceptually muddled. - Cleaner: introduce
perspective_contract.v1.jsonas a separate metamodel contract (even if it’s mostly a subset of signal.v1). Phase 1 can ship them as separate files; Phase 2’skinddiscriminator makes the distinction clean.
- Today, perspectives use
-
Migration command?
- Worth a small CLI verb:
jin contracts migratethat detects pack-level metamodel duplicates, compares against engine canonical, and either deletes safely (if byte-identical) or warns (if drifted). Saves manual cleanup.
- Worth a small CLI verb:
-
What about the smebit / bitbundle contracts that have legacy names?
smebit_contract.v1.jsondescribes Subject Matter (display name).bitbundle_contract.v1.jsondescribes Dossier. The filenames keep legacy slugs by convention (CLAUDE.md is explicit). This is fine — the file is named for the slug, the content describes the display concept.
-
Tenant-level overrides?
- Could a single tenant override the metamodel contract for itself? Probably no — that defeats the cross-tenant analytical comparability. If a tenant needs a different SignalFinding shape, it should be a pack-level extension (Phase 5), not a tenant-level override.
-
Validator behaviour for unknown
kind?- During the transition, contracts won’t have
kinddeclared. Default to: if a contract file lives engine-side, treat as metamodel; pack-side, treat as domain. Phase 3 promotes this from inference to explicit declaration.
- During the transition, contracts won’t have
Why now, why this shape
Section titled “Why now, why this shape”Why now
Section titled “Why now”The 2026-05-20 incident is the immediate trigger, but the deeper reason is the growing pack inventory. With one pack (the original numetrix), the duplication question was theoretical. With six packs (numetrix, hrcentral, millesime, alptrack, interlogic, lexflow) it’s a concrete maintenance burden, and every new pack compounds it. The longer the metamodel contracts live in pack-land, the more places drift can creep in.
Closing this now — while there are still only six packs to migrate — costs ~2 hours. Closing it after 12 packs costs 4 hours plus the drift cleanup. The slope is steep.
Why this shape and not a smaller one
Section titled “Why this shape and not a smaller one”The smaller shape (“just keep copy-pasting the contracts; we’ll fix it later”) has the same operational cost forever and adds drift risk indefinitely. It’s a tax that compounds.
The larger shape (“redesign the whole contract system”) is unnecessary — the existing contracts work fine, they’re just in the wrong place. Moving them is mechanical.
The proposed shape is the minimal architectural surgery that fixes the actual problem: make the engine-vs-pack distinction explicit, then resolve contracts accordingly.
Why the kind discriminator matters
Section titled “Why the kind discriminator matters”Without it, every contract still looks like the same kind of thing — a JSON file with entities. The discriminator names what is currently implicit. Once named, the validator can enforce. Without naming, the validator can’t.
This is a small instance of a recurring jinflow design value: the artifact that makes a distinction visible is more valuable than the distinction itself. Sense 39 (Governance) made the temporal-validity distinction explicit in YAML. Sense 36 (Lineage) made the pack-of-origin distinction explicit in .pack-init.yml. Sense 40 makes the engine-vs-domain contract distinction explicit in kind:.
Summary
Section titled “Summary”- jinflow contracts come in two flavours: domain (pack-specific, describes data) and metamodel (engine-level, describes the analytical framework’s outputs).
- The two are conflated today: all six contracts live in
<pack>/contracts/, even the four that should be engine-level. - The 2026-05-20 hrcentral perspective incident is the canonical motivating story: missing metamodel contract caused a confusing error that wasn’t really about hrcentral at all.
- Fix: lift the four metamodel contracts to
jinflow/contracts/. Update compilers to engine-first, pack-fallback resolution. Add akinddiscriminator and a validator. Remove pack-level copies once migrated. - Effort: ~2 hours of focused work. Five phases, the last one optional.
- No new analytical machinery; the row shapes stay the same. Only where the contract describing them lives changes.
- Cross-cutting benefit: future engine evolutions of the metamodel touch one file, not N. Packs pick up changes automatically.
Status: proposed. Next step: sequence with other in-flight Sense docs (notably Sense 39 — Governance — which depends on the contract system being clean), then move to implementation.
Numerical neighbors: ← Sense 39: The Governance · Sense 41: The Relevance — Which Rings Are For You →