Skip to content

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.md in 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)


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.json

signal_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.

Two kinds of contract, two different responsibilities:

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.

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 from Material or Person. 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.

As of 2026-05-20, every pack ships its own copy of all six contracts in <pack>/contracts/:

FileKindIdentical across packs?
gold_contract.v1.jsondomainno (different gold entities per pack)
silver_contract.v1.jsondomainno
signal_contract.v1.jsonmetamodelyes
verdict_contract.v1.jsonmetamodelyes
smebit_contract.v1.jsonmetamodelyes
bitbundle_contract.v1.jsonmetamodelyes

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.

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:

  1. Engine-level (jinflow/contracts/<contract>.json) — the canonical version for the current engine.
  2. 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.

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.

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 kind values 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.json

Format:

{
"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.

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:

  1. 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.
  2. Pack extension (Phase 5): packs that genuinely extend the metamodel need a per-pack file. The fallback path discovers it.
  3. Migration: deleting pack-level copies before all compilers are updated would break. Fallback enables phased rollout.

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:

pack/.jinflow.yml
metamodel_contracts:
signal: v1
verdict: v1
smebit: v1
bitbundle: v1

The 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.

  • 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.v1 contract that’s domain-shaped (each pack defines its own rules); the Sense 40 work is independent.

Not yet manifest in the engine. Status: proposed. See the implementation phases / open questions above for the path from this paper to running code.


These are decisions deferred until implementation begins. Each is a real design choice this Sense leaves unfixed.

  1. 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 via importlib.resources.
    • The latter is probably right for distribution — the deployed jinflow binary needs to find them at runtime, and bundling them with the package guarantees they ship with the engine.
  2. How does the deployed CLI binary discover the contracts?

    • If they live inside the Python package, importlib.resources or Path(__file__).parent / 'contracts'.
    • Worth confirming this works under PyInstaller’s frozen-binary mode (the deploy build).
  3. Do kind: metamodel contracts get an immutability flag?

    • Once they ship in an engine version, in-pack edits should be a compile error. Adding "frozen": true or 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.
  4. 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.json as a separate metamodel contract (even if it’s mostly a subset of signal.v1). Phase 1 can ship them as separate files; Phase 2’s kind discriminator makes the distinction clean.
  5. Migration command?

    • Worth a small CLI verb: jin contracts migrate that detects pack-level metamodel duplicates, compares against engine canonical, and either deletes safely (if byte-identical) or warns (if drifted). Saves manual cleanup.
  6. What about the smebit / bitbundle contracts that have legacy names?

    • smebit_contract.v1.json describes Subject Matter (display name). bitbundle_contract.v1.json describes 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.
  7. 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.
  8. Validator behaviour for unknown kind?

    • During the transition, contracts won’t have kind declared. 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.

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.

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.

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:.

  • 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 a kind discriminator 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

jazzisnow jinflow is a jazzisnow product
v0.64.7 · built 2026-09-20 19:48 UTC