P1.5.2 — Rule Migration — Audit (Step 1)

Branch: feature/p1-5-2-migration Worktree: .worktrees/claude/p1-5-2-migration Base SHA: 0b124c17 (origin/main, post-R87 κ Wave 6) Wave: R87 κ Wave 7 Round: R87 (continuing κ Phase 1) Author tier: T3 executor (autonomous mandate, T0 dated 2026-05-07)


§1. Task framing

P1.5.2 ships a migration orchestrator that takes a MigrationProposal (an old/new version pair plus a candidate RuleNode[] ruleset and a declared-divergence scope) and decides whether the migration is accepted (producing an ActivationToken for P1.5.3 to consume), rejected for parity reasons (declared scope was insufficient), or rejected for version mismatch (the old hash the proposer claims doesn’t match what migrateRuleset re-computes from proposal.old_ruleset).

This is the second-of-two load-bearing slices in the κ versioning chain:

  • P1.5.1 versioning.ts — produces the rule_version_hash that θ consensus signs. Already shipped at base; importable from ./versioning.js.
  • P1.5.5 parity-harness.ts — produces the ParityReport for an (old, new, corpus) triple. Already shipped at base; importable from ./parity-harness.js.
  • P1.5.2 migration.ts (this slice) — composes (1) and (2) with version verification + epoch scheduling to produce a binary admit/reject decision on a proposed upgrade, with all rejection paths typed.

Without P1.5.2, the κ engine has no orchestrated way to migrate from one rule version to another. P1.5.1 alone cannot reject a proposal; P1.5.5 alone has no notion of “current active version” or “target epoch”. P1.5.2 is the gluing contract.

P1.5.2 also opens a forward-coupling for P1.5.3 Activation Epoch + Rollback which will consume the ActivationToken shape this slice defines and ship the runtime “switch active ruleset at epoch N+1” mechanism. The token’s shape is THIS task’s deliverable; P1.5.3 implements its consumer.


§2. Existing surface (read-only inventory)

§2.1. P1.5.1 — src/domains/rules/versioning.ts

Public exports relevant to P1.5.2:

Export Kind Shape
ENGINE_VERSION const 'kappa-engine/1-0-0' (default mixin)
VERSION_HASH_PREFIX const 'sha256:'
VERSION_HASH_HEX_LENGTH const 64
VERSION_HASH_TOTAL_LENGTH const 71
VersionHashError class input-shape error
computeVersionHash(ruleset, engine_version?) fn returns 71-char sha256:<64hex>
verifyRuleVersion(expected, actual) fn constant-time string === string
stripLocations(value) fn recursive location key removal
canonicalizeRuleset(ruleset) fn strip + sort-by-name + canonicalize

Critical for P1.5.2: computeVersionHash is the single source of truth for rule_version_hash. P1.5.2 calls it twice — once on the candidate new_ruleset (to fingerprint the upgrade) and once on the proposal’s old_ruleset-equivalent (to verify the version-mismatch invariant). The returned hash format is fixed: 'sha256:' + 64 lowercase hex.

verifyRuleVersion(expected, actual) is the constant-time comparison. P1.5.2 uses it for the old_version check rather than === because the hash participates in θ consensus and a timing oracle on hash bytes is the canonical class of attack on this surface.

§2.2. P1.5.5 — src/domains/rules/parity-harness.ts

Public exports relevant to P1.5.2:

Export Kind Shape
EFFECT_HASH_PREFIX const 'sha256:'
EFFECT_HASH_HEX_LENGTH const 64
EFFECT_HASH_TOTAL_LENGTH const 71
EventId type string
EventIdPattern type string \| RegExp
ParityEvent interface { id; event; state; rule_version; epoch } (the κ “event”)
ParityInput interface { old_ruleset; new_ruleset; corpus; declared_divergence_scope }
ParityEventDetail interface { old_result; new_result; old_hash; new_hash }
ParityReport interface 5 buckets + pass: boolean + details_by_event: Map
ParityHarnessError class input-shape error
runParity(input) fn the gating call
effectHash(mutations) fn per-event canonical hash
matchesScope(id, patterns) fn scope-pattern match
DEFAULT_CORPUS const 101 hand-curated events

Critical for P1.5.2 — runParity(input).pass semantics (parity-harness.ts:514–530):

pass = (both_admit_diverge.length === 0) AND (every id in old_admit_new_reject ∪ old_reject_new_admit matches at least one pattern in declared_divergence_scope)

In words: the harness already encapsulates the “divergence ⊆ declared scope” gate. P1.5.2 does NOT re-implement this — it just consumes report.pass.

If the harness rejects, P1.5.2 produces { status: "rejected:parity", divergence_exceeds_scope: <list>, parity_report }. The divergence_exceeds_scope field is computed POST-HOC by re-walking the report’s two divergence bucket arrays and filtering for events that don’t match the declared scope. The harness’s own pass: false doesn’t tell us which events broke scope; the post-hoc walk does.

§2.3. P1.3.1 — src/domains/rules/engine.ts

P1.5.2 does not call executeRuleset directly. The parity harness already does that internally for both rulesets per corpus event. P1.5.2 only needs the type CategorizedRule (re-exported from parity-harness for ergonomics) to type the proposal’s new_ruleset field.

§2.4. P1.2.4 — src/domains/rules/registry.ts

The registry exports RuleRegistry (class), RulesetParseError, RulesetValidationError, AmbiguousRulesetError. P1.5.2 does NOT call the registry directly — the proposal carries an already-categorized RuleNode[] (the proposer is responsible for invoking RuleRegistry.loadRuleset upstream and serializing the categorized rules). P1.5.2 receives the RuleNode[] shape that computeVersionHash consumes (per its parameter type readonly RuleNode[]).

This is a deliberate choice — keeping migration agnostic to how a ruleset was constructed lets the same orchestrator work for tests (which build RuleNode literals directly) and production paths (which parse from text via the registry). The proposer who calls migrateRuleset is responsible for upstream registry construction.

§2.5. P1.5.4 — src/domains/rules/canonical.ts

Indirect dependency. computeVersionHash and effectHash both consume canonicalize. P1.5.2 imports neither directly — both calls flow through P1.5.1 / P1.5.5.

§2.6. P1.1.2 — src/domains/rules/determinism.ts

P1.5.2 must satisfy the corpus self-scan in src/__tests__/domains/rules/determinism.test.ts §Group 12 (line 819): “no forbidden tokens in src/domains/rules/*.ts (excluding determinism.ts + tests)”. The forbidden patterns are:

Pattern Hazard
\bMath\.[A-Za-z_]\w* non-deterministic math
\bDate\.[A-Za-z_]\w* wall-clock
\bnew\s+Date\b wall-clock
\b(?:setTimeout\|setInterval\|setImmediate)\b async timers
\b(?:fetch\|XMLHttpRequest)\b network
\brequire\s*\(\s*['"](?:fs\|node:fs)['"] filesystem I/O
\bfrom\s+['"](?:fs\|node:fs)['"] filesystem I/O
\bcrypto\.[A-Za-z_]\w* direct crypto access
\bprocess\.(?:hrtime\|nextTick)\b non-deterministic timing
\bawait\b async
\basync\s+(?:function\|\() async
(?<![0-9n])\b\d+\.\d+\b float literals
\[native code\] runtime introspection bypass

The scanner strips comments before matching, so JSDoc references to these tokens are safe. The migration module body uses none of these — version hashing happens via the P1.5.1 wrapper (which does its own createHash named-import dance), parity happens via P1.5.5, and bigint epoch arithmetic uses native > and + 1n.


§3. The MigrationProposal / MigrationResult contract — shape decisions

§3.1. MigrationProposal shape

interface MigrationProposal {
  readonly old_version: string;                       // 'sha256:<64hex>' as returned by computeVersionHash
  readonly new_version: string;                       // 'sha256:<64hex>' as returned by computeVersionHash
  readonly old_ruleset: readonly CategorizedRule[];   // current active ruleset (for re-hashing)
  readonly new_ruleset: readonly CategorizedRule[];   // candidate ruleset (for re-hashing + parity)
  readonly declared_divergence_scope: readonly EventIdPattern[];
  readonly target_epoch: bigint;                      // strictly > current_epoch
}

Naming notes:

  • EventIdPattern is the P1.5.5 type — re-exported, not redefined.
  • old_ruleset shape is readonly CategorizedRule[] (P1.3.1) NOT readonly RuleNode[] (P1.2.2). The parity harness needs categorized rules (it threads them through RuleRegistry). The version-hash function consumes only the .rule projection (extractable inline). Carrying the categorized form everywhere avoids round-tripping through registry semantics inside the migration module.

§3.2. MigrationResult discriminated union

type MigrationResult =
  | {
      readonly status: 'accepted';
      readonly activation_token: ActivationToken;
      readonly parity_report: ParityReport;
    }
  | {
      readonly status: 'rejected:parity';
      readonly divergence_exceeds_scope: readonly EventId[];
      readonly parity_report: ParityReport;
    }
  | {
      readonly status: 'rejected:version_mismatch';
      readonly expected: string;     // proposal.old_version
      readonly got: string;          // hash recomputed from proposal.old_ruleset
    };

The three statuses (and only three) cover the entire decision surface:

  • accepted — version OK + parity OK → emit token, schedule activation.
  • rejected:parity — version OK + parity NOT OK → return the report, list the events that broke scope. (The fork-trigger hook fires here.)
  • rejected:version_mismatch — version NOT OK → fail fast, before even running the parity harness. This is also where the target_epoch <= current_epoch rejection lands (we co-locate it as rejected:version_mismatch is too narrow; let’s see §3.3).

§3.3. Epoch validation — design choice

The acceptance criteria require: target_epoch == current_epoch → rejected (must be strictly greater).

Two encodings are possible:

  1. Add a fourth status rejected:epoch. Cleaner taxonomy, but inflates MigrationResult to 4 variants for a single rare failure path.
  2. Throw a typed error from migrateRuleset for epoch violations (treat them as caller-bug, not migration outcomes).

Decision: option 2. A bad epoch is an input-shape error — the proposer constructed an invalid proposal. It is conceptually identical to “old_ruleset is not an array” or “target_epoch is not a bigint”, which already throw MigrationError (input-shape). Hauling it into the MigrationResult union would force every caller to handle a path that indicates “your proposal is malformed” — which is exactly what an error class is for.

This also keeps MigrationResult symmetric: every variant is the outcome of running the migration logic. Epoch validation runs before the logic.

So: target_epoch <= current_epochthrow new MigrationError(...).

§3.4. ActivationToken shape (forward-coupled to P1.5.3)

interface ActivationToken {
  readonly version_hash: string;            // 'sha256:<64hex>' — the new version
  readonly target_epoch: bigint;            // when this token activates
  readonly issued_at_epoch: bigint;         // current_epoch from the migrateRuleset call
  readonly parity_pass: true;               // tautological — token only emitted on parity pass
  readonly scope_signature: string;         // 'sha256:<64hex>' over canonical(scope)
  readonly issued_old_version: string;      // 'sha256:<64hex>' — for audit traceability
}

Design rationale:

  1. version_hash — the primary cargo. P1.5.3 reads this to know which ruleset to swap to.
  2. target_epoch + issued_at_epoch — together they bound the pre-flight period. P1.5.3 uses the difference to compute when activation can fire; storing both lets a future audit reconstruct WHO scheduled what.
  3. parity_pass: true is a literal-typed sentinel (not a generic boolean). This makes “an ActivationToken whose parity_pass: false” uninhabitable at the type level — accepted-status tokens, by construction, must have passed parity.
  4. scope_signature — a canonical-JSON SHA-256 hash of the proposal’s declared_divergence_scope. This lets P1.5.3 verify that the scope declared at proposal time matches the scope that any audit log records. Without this, a migration’s “I declared this scope” claim can’t be cryptographically tied to an ActivationToken after the fact.
  5. issued_old_version — for forward auditability. P1.5.3 logs the chain (old → new); π governance (Phase 5) consumes this for the “no silent re-base” invariant.

The token is frozen (Object.freeze) and shape-locked. Forward compatibility considerations: P1.5.3 is the only consumer in Phase 1; if a Phase 2+ slice extends the shape, it does so additively (no field removal).

§3.5. Fork-trigger hook stub

The acceptance criteria require: “on-rejection fork-trigger callback invoked exactly once (stub callback for ι, hook name only)”.

Design:

  • MigrationOptions.onRejection?: (result: MigrationResult & { status: ... }) => void is an optional callback the caller can pass.
  • The migrator invokes it exactly once on every rejected:* outcome.
  • It is NOT invoked on accepted (no fork to trigger).
  • Default: no-op. This makes the API “pay-as-you-go” — production paths wire it; tests that don’t care leave it omitted.
  • The callback signature accepts the full MigrationResult so a future ι wiring can route on result.status === "rejected:parity" vs. "rejected:version_mismatch".

§3.6. MigrationError class

class MigrationError extends Error {
  override readonly name = 'MigrationError';
  constructor(message: string) { super(message); }
}

Used for:

  • proposal is null / not an object
  • proposal.old_version is not a non-empty string
  • proposal.new_version is not a non-empty string
  • proposal.old_ruleset is not an array
  • proposal.new_ruleset is not an array
  • proposal.declared_divergence_scope is not an array
  • proposal.target_epoch is not a bigint
  • current_epoch is not a bigint
  • target_epoch <= current_epoch (the strict-greater invariant)
  • test_corpus is not an array
  • onRejection is provided but not a function

NOT used for: parity failures, version mismatches. Those are MigrationResult outcomes.


§4. Algorithm — step-by-step

migrateRuleset(proposal, test_corpus, current_epoch, options?):
  1. Validate inputs → throw MigrationError on shape errors.
  2. Validate target_epoch > current_epoch → throw MigrationError.
  3. Recompute the OLD version hash from proposal.old_ruleset.
  4. verifyRuleVersion(proposal.old_version, recomputed_old) === false
     ⇒ result = { status: "rejected:version_mismatch",
                  expected: proposal.old_version,
                  got: recomputed_old }
       Invoke onRejection(result) once.
       Return result.
  5. Build ParityInput from
     (proposal.old_ruleset, proposal.new_ruleset, test_corpus,
      proposal.declared_divergence_scope).
  6. report = runParity(parityInput).
  7. report.pass === false:
     ⇒ Compute divergence_exceeds_scope by re-walking
       (report.old_admit_new_reject ∪ report.old_reject_new_admit) and
       filtering for ids that don't match the declared scope. ALSO append
       all of report.both_admit_diverge (those are unconditionally
       out-of-scope per harness contract).
     ⇒ result = { status: "rejected:parity",
                  divergence_exceeds_scope, parity_report: report }
       Invoke onRejection(result) once.
       Return result.
  8. report.pass === true:
     ⇒ Recompute the NEW version hash (independently, not trusting
       proposal.new_version).
     ⇒ verifyRuleVersion(proposal.new_version, recomputed_new) === false
       ⇒ This is also a version mismatch, but on the NEW side. We treat
         it as rejected:version_mismatch with expected=proposal.new_version,
         got=recomputed_new. (The proposer's claimed new hash is wrong.)
       Invoke onRejection(result) once.
       Return result.
     ⇒ Build ActivationToken {version_hash: recomputed_new,
                               target_epoch, issued_at_epoch: current_epoch,
                               parity_pass: true,
                               scope_signature: hashScope(scope),
                               issued_old_version: recomputed_old}.
     ⇒ result = { status: "accepted",
                  activation_token: token,
                  parity_report: report }.
     ⇒ Return result. (No onRejection invocation — accepted.)

Why both old_version AND new_version are verified: the old check tells us “is this proposer building from the right base”; the new check tells us “did the proposer compute the new hash correctly”. Both are cheap (a single SHA-256 each) and both prevent silent corruption. Without the new check, a proposer could construct an ActivationToken with the wrong hash and have it accepted; the parity harness alone cannot catch this because the hash isn’t part of its input.

Note: verifyRuleVersion returns false on length mismatch as well, so non-71-char strings are caught here without explicit length checks.


§5. Determinism guardrails

P1.5.2 must satisfy the §Group 12 corpus self-scan. The migration body uses:

  • verifyRuleVersion, computeVersionHash from ./versioning.js (already shipped, already self-scan-clean).
  • runParity, matchesScope from ./parity-harness.js (already shipped, already self-scan-clean).
  • canonicalize from ./canonical.js (already shipped) — needed for scope_signature hashing.
  • createHash named import from node:crypto — same pattern as versioning.ts:72 / parity-harness.ts:52.
  • Native bigint arithmetic, > comparison, Object.freeze, Array.isArray.

None of those produce forbidden-token matches in the body. The createHash import is named, so the corpus self-scan does not see crypto.createHash. Comments are stripped before scanning, so JSDoc freely cites tokens.

The forward path through parity-harness.ts §6’s matchesScope is also relied upon for the divergence_exceeds_scope post-hoc walk — we call it twice per non-passing event, which is cheap (linear in scope size).


§6. Test plan (preview — full plan in contract §7)

Headline matrix from prompt’s acceptance criteria:

  1. F1 — accepted path: 100-event corpus, identical behavior under old + new, non-empty scope still yields parity-pass since divergence set is empty. Result: accepted with token. onRejection not invoked.
  2. F2 — accepted path with in-scope divergence: divergence within declared scope. Result: accepted with token. onRejection not invoked.
  3. F3 — rejected:parity: divergence outside declared scope. Result: rejected:parity with divergence_exceeds_scope populated. onRejection invoked exactly once.
  4. F4 — rejected:version_mismatch (old): proposer claims old_version: '<wrong-hash>'. Result: rejected:version_mismatch with expected/got populated. onRejection invoked exactly once.
  5. F5 — rejected:version_mismatch (new): proposer claims new_version: '<wrong-hash>'. Result: rejected:version_mismatch with expected/got (the wrong-new-hash). onRejection invoked once.
  6. F6 — MigrationError (epoch equal): target_epoch == current_epoch throws. NO onRejection call (it’s an input-shape error, not a result).
  7. F7 — MigrationError (epoch less): target_epoch < current_epoch throws.
  8. F8 — MigrationError (input-shape paths): every documented invalid shape throws.
  9. F9 — Determinism: same proposal/corpus/epoch run twice produces reports whose canonical-JSON serialization is byte-identical. Token version_hash and scope_signature are byte-identical too.
  10. F10 — Both-admit-diverge unconditionally rejects: even if declared scope contains the divergent event id, both-admit-diverge cannot pass (per harness contract). Verifies P1.5.2 surfaces this.
  11. F11 — ActivationToken shape contract: token is frozen, has exactly the documented fields, hashes match the recomputed fingerprints.
  12. F12 — Corpus length: F1’s 100-event corpus exercises the AC requirement that the test corpus be ≥100 events. (DEFAULT_CORPUS has 101; we either consume that directly or ship a fixture-builder that generates 100.)
  13. F13 — Determinism scanner self-scan: the migration module body, fed to inspectFunctionForbidden, returns [].

Detailed test list (≥30 cases total) is in the contract §7.


§7. Risk register

ID Risk Mitigation
R1 divergence_exceeds_scope walk and harness pass calculation drift apart Fixture: every event in the divergence list either matches scope OR appears in divergence_exceeds_scope. Asserted in F3.
R2 Forgetting to invoke onRejection on the new-side version mismatch F5 fixture asserts the callback fires.
R3 Object.freeze semantics on token’s nested shape Token is single-level; no nested frozen structures.
R4 target_epoch <= current_epoch lets == slip through Explicit <= test, F6 + F7 cover both == and <.
R5 both_admit_diverge events not surfaced in divergence_exceeds_scope F10 fixture deliberately constructs same-admit-different-effects, places the event in declared scope, asserts that scope membership doesn’t unblock pass.
R6 Forward coupling to P1.5.3 — token shape ambiguity Contract §3.4 locks the shape; P1.5.3 is forbidden from reshaping.
R7 Scope signature canonicalization differs from canonicalize semantics We call canonicalize directly; if it errors on a RegExp pattern (since RegExp isn’t a plain JSON value), we’d fail. Contract §5 specifies that scope is canonicalized after string-only conversion: every RegExp becomes its .source string before hashing.
R8 corpus self-scan breaks because of a stray pattern in the migration body F13 fixture (determinism scanner self-scan) explicitly catches.

R7 deserves elaboration. The harness’s EventIdPattern = string | RegExp is fine for runtime matching but cannot be canonicalized as-is — canonicalize would reject the RegExp value. P1.5.2 normalizes the scope before hashing:

scopeForSignature = patterns.map(p =>
  typeof p === 'string'
    ? { kind: 'string', value: p }
    : { kind: 'regex',  value: p.source, flags: p.flags }
);
scope_signature = 'sha256:' + sha256(canonicalize(scopeForSignature));

The split tag {kind, value} shape is canonicalize-friendly and lets a regex ^foo$ be distinguished from a literal ^foo$.


§8. Files this slice creates

  • src/domains/rules/migration.ts — the orchestrator + types + token + error
  • src/__tests__/domains/rules/migration.test.ts — F1–F13 fixtures
  • docs/audits/p1-5-2-migration-audit.md (this file) — Step 1
  • docs/contracts/p1-5-2-migration-contract.md — Step 2
  • docs/packets/p1-5-2-migration-packet.md — Step 3
  • docs/verification/p1-5-2-migration-verification.md — Step 5

No edits to existing source files. No edits to existing schema. No new migrations. No new MCP tools registered (this is a library-only κ slice).


§9. Pre-flight reading completed

  • CLAUDE.md
  • AGENTS.md
  • .agents/skills/colibri-executor/SKILL.md (prompt-bundled context)
  • .agents/skills/colibri-tier1-chains/SKILL.md (prompt-bundled context)
  • .agents/skills/colibri-verification/SKILL.md (prompt-bundled context)
  • docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.5.2 (lines 2337–2477)
  • src/domains/rules/versioning.ts (P1.5.1 — full file)
  • src/domains/rules/parity-harness.ts (P1.5.5 — full file)
  • src/domains/rules/engine.ts (P1.3.1 — public surface §1)
  • src/domains/rules/registry.ts (P1.2.4 — public surface §1, sufficient)
  • docs/3-world/physics/laws/rule-engine.md §Rule versioning + §Test corpus parity requirement

§10. Status

Audit complete. Proceeding to Step 2 (contract) with locked decisions:

  • 3-variant MigrationResult: accepted / rejected:parity / rejected:version_mismatch
  • ActivationToken shape: 6 fields, frozen, literal-typed parity_pass: true
  • Epoch violations → MigrationError, not MigrationResult variant
  • onRejection callback: optional, invoked exactly once on every rejection
  • MigrationError class for shape errors (mirrors ParityHarnessError/VersionHashError precedent)
  • Both old AND new version hashes verified
  • divergence_exceeds_scope includes both old_admit_new_reject ∪ old_reject_new_admit events out of scope AND ALL both_admit_diverge events
  • Scope signature: canonicalize after {kind, value, flags?} normalization

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.