P1.5.2 — Rule Migration — Behavioral Contract (Step 2)

Branch: feature/p1-5-2-migration Worktree: .worktrees/claude/p1-5-2-migration Base SHA: 0b124c17 Wave: R87 κ Wave 7 Author tier: T3 executor (autonomous mandate, T0 dated 2026-05-07) Audit upstream: docs/audits/p1-5-2-migration-audit.md (committed at 7e662cd8)


§1. Module identity

File: src/domains/rules/migration.ts

A pure synchronous module that orchestrates a κ ruleset migration. Given a proposal pairing an old version hash with a new candidate ruleset (plus a declared divergence scope and a target activation epoch), and given the test corpus, migrateRuleset returns a tagged MigrationResult indicating whether the migration was accepted (with an ActivationToken for P1.5.3 to consume), rejected for parity reasons, or rejected for version mismatch.

The module is pure. No I/O, no DB, no network, no env reads, no console output, no async, no clock, no RNG, no float math. Two callers on any host (Node ≥ 20, any platform, any locale) produce byte-identical MigrationResult outputs for byte-identical inputs.


§2. Public surface (LOCKED)

import type { CategorizedRule } from './engine.js';
import type {
  EventId,
  EventIdPattern,
  ParityEvent,
  ParityReport,
} from './parity-harness.js';

// Re-exports for ergonomics — most P1.5.2 consumers use only this module.
export type {
  CategorizedRule,
  EventId,
  EventIdPattern,
  ParityEvent,
  ParityReport,
};

// Activation token (forward-coupled to P1.5.3)
export interface ActivationToken {
  readonly version_hash: string;          // 'sha256:<64hex>' — recomputed new hash
  readonly target_epoch: bigint;          // when this token activates
  readonly issued_at_epoch: bigint;       // current_epoch from the migrateRuleset call
  readonly parity_pass: true;             // literal-typed sentinel
  readonly scope_signature: string;       // 'sha256:<64hex>' over canonical(normalized_scope)
  readonly issued_old_version: string;    // 'sha256:<64hex>' — recomputed old hash
}

// Migration proposal
export interface MigrationProposal {
  readonly old_version: string;
  readonly new_version: string;
  readonly old_ruleset: readonly CategorizedRule[];
  readonly new_ruleset: readonly CategorizedRule[];
  readonly declared_divergence_scope: readonly EventIdPattern[];
  readonly target_epoch: bigint;
}

// Migration outcome — discriminated union
export 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;
      readonly got: string;
    };

// Optional caller hook (stub — full ι wiring in Phase 5)
export type OnRejectionCallback = (
  result:
    | (MigrationResult & { status: 'rejected:parity' })
    | (MigrationResult & { status: 'rejected:version_mismatch' }),
) => void;

export interface MigrationOptions {
  readonly onRejection?: OnRejectionCallback;
  readonly engine_version?: string;       // forwarded to computeVersionHash; default ENGINE_VERSION
}

// Errors
export class MigrationError extends Error {
  override readonly name: 'MigrationError';
  constructor(message: string);
}

// Entry point
export function migrateRuleset(
  proposal: MigrationProposal,
  test_corpus: readonly ParityEvent[],
  current_epoch: bigint,
  options?: MigrationOptions,
): MigrationResult;

// Helper: hash a normalized scope (exported for testing + audit reconciliation)
export function computeScopeSignature(
  scope: readonly EventIdPattern[],
): string;

The exports are locked. No additions, removals, or shape changes after contract approval without a contract amendment.


§3. Algorithm

§3.1. migrateRuleset — full step list

function migrateRuleset(proposal, test_corpus, current_epoch, options?):
  // ----- Step 1: Input validation. -----
  validateProposal(proposal);                     // throws MigrationError
  validateCorpus(test_corpus);                    // throws MigrationError
  validateCurrentEpoch(current_epoch);            // throws MigrationError
  validateOptions(options);                       // throws MigrationError

  // ----- Step 2: Epoch invariant. -----
  if proposal.target_epoch <= current_epoch:
    throw new MigrationError(
      'target_epoch must be strictly greater than current_epoch ' +
      '(got target=<X>, current=<Y>)');

  // ----- Step 3: Recompute old version hash. -----
  oldRuleNodes = proposal.old_ruleset.map(c => c.rule);   // CategorizedRule -> RuleNode
  recomputed_old = computeVersionHash(oldRuleNodes, engine_version);

  // ----- Step 4: Verify proposer's old_version claim. -----
  if NOT verifyRuleVersion(proposal.old_version, recomputed_old):
    result = {
      status: 'rejected:version_mismatch',
      expected: proposal.old_version,
      got: recomputed_old,
    };
    invokeOnRejection(options, result);   // exactly once
    return Object.freeze(result);

  // ----- Step 5: Run parity harness. -----
  parityInput = {
    old_ruleset: proposal.old_ruleset,
    new_ruleset: proposal.new_ruleset,
    corpus: test_corpus,
    declared_divergence_scope: proposal.declared_divergence_scope,
  };
  parityReport = runParity(parityInput);

  // ----- Step 6: Parity decision. -----
  if NOT parityReport.pass:
    divergence_exceeds_scope = collectOutOfScopeDivergences(parityReport, proposal.declared_divergence_scope);
    result = {
      status: 'rejected:parity',
      divergence_exceeds_scope: Object.freeze(divergence_exceeds_scope),
      parity_report: parityReport,
    };
    invokeOnRejection(options, result);
    return Object.freeze(result);

  // ----- Step 7: Recompute new version hash. -----
  newRuleNodes = proposal.new_ruleset.map(c => c.rule);
  recomputed_new = computeVersionHash(newRuleNodes, engine_version);

  // ----- Step 8: Verify proposer's new_version claim. -----
  if NOT verifyRuleVersion(proposal.new_version, recomputed_new):
    result = {
      status: 'rejected:version_mismatch',
      expected: proposal.new_version,
      got: recomputed_new,
    };
    invokeOnRejection(options, result);
    return Object.freeze(result);

  // ----- Step 9: Build activation token. -----
  scope_signature = computeScopeSignature(proposal.declared_divergence_scope);
  activation_token = Object.freeze({
    version_hash: recomputed_new,
    target_epoch: proposal.target_epoch,
    issued_at_epoch: current_epoch,
    parity_pass: true,
    scope_signature,
    issued_old_version: recomputed_old,
  });

  // ----- Step 10: Return accepted. -----
  result = Object.freeze({
    status: 'accepted',
    activation_token,
    parity_report: parityReport,
  });
  return result;

§3.2. validateProposal — input shape checks

Throws MigrationError on the first violation. Aborts (does not aggregate).

Condition Message form
proposal === null or typeof proposal !== 'object' proposal must be an object
typeof proposal.old_version !== 'string' or proposal.old_version.length === 0 proposal.old_version must be a non-empty string
typeof proposal.new_version !== 'string' or proposal.new_version.length === 0 proposal.new_version must be a non-empty string
!Array.isArray(proposal.old_ruleset) proposal.old_ruleset must be an array of CategorizedRule
!Array.isArray(proposal.new_ruleset) proposal.new_ruleset must be an array of CategorizedRule
!Array.isArray(proposal.declared_divergence_scope) proposal.declared_divergence_scope must be an array of EventIdPattern
typeof proposal.target_epoch !== 'bigint' proposal.target_epoch must be a bigint

§3.3. validateCorpus

Condition Message form
!Array.isArray(test_corpus) test_corpus must be an array of ParityEvent

(Per-element corpus shape validation is delegated to runParity, which already rejects with ParityHarnessError. We do not duplicate that work.)

§3.4. validateCurrentEpoch

Condition Message form
typeof current_epoch !== 'bigint' current_epoch must be a bigint

§3.5. validateOptions

Condition Message form
options !== undefined and options !== null and typeof options !== 'object' options must be an object
options.onRejection !== undefined and typeof options.onRejection !== 'function' options.onRejection must be a function
options.engine_version !== undefined and (not non-empty string) options.engine_version must be a non-empty string

§3.6. collectOutOfScopeDivergences

function collectOutOfScopeDivergences(report, scope):
  out = []
  // both_admit_diverge is unconditionally out-of-scope per harness contract.
  for id in report.both_admit_diverge:
    out.push(id)
  // The other two divergence buckets are gated by scope membership.
  for id in report.old_admit_new_reject:
    if NOT matchesScope(id, scope):
      out.push(id)
  for id in report.old_reject_new_admit:
    if NOT matchesScope(id, scope):
      out.push(id)
  return out;

The output preserves the harness’s bucket order: both_admit_diverge events come first (in corpus order within the bucket), then old_admit_new_reject events, then old_reject_new_admit events. This gives stable, deterministic output for a given (report, scope) pair.

§3.7. invokeOnRejection

function invokeOnRejection(options, result):
  if options !== undefined and options !== null and typeof options.onRejection === 'function':
    options.onRejection(result);
  // No-op otherwise.

The callback is invoked exactly once per rejected outcome. The caller is responsible for any side effects (e.g. emitting a fork-trigger into ι, recording a governance event). Errors thrown from the callback propagate; migrateRuleset does not catch them.

§3.8. computeScopeSignature

function computeScopeSignature(scope):
  normalized = []
  for p in scope:
    if typeof p === 'string':
      normalized.push({ kind: 'string', value: p });
    else if p instanceof RegExp:
      normalized.push({ kind: 'regex', value: p.source, flags: p.flags });
    else:
      throw new MigrationError('unsupported EventIdPattern (must be string or RegExp)');
  body = canonicalize(normalized);
  hash = createHash('sha256').update(body, 'utf8').digest('hex');
  return 'sha256:' + hash;

The normalization is deterministic: a literal 'admit/foo' and a regex /^admit\/foo$/ produce different canonical bytes (different kind fields), so the signature distinguishes them. A regex’s flags are included so two regexes with identical .source but different flags (/foo/i vs /foo/) sign differently.

The function is exported so audit consumers (Phase 5 π governance) can recompute the signature and verify it matches a token’s scope_signature field.


§4. Determinism contract

§4.1. Bit-identical output

Two calls migrateRuleset(proposal, corpus, current_epoch, options) with byte-identical inputs (where options.onRejection is the same function or absent) produce byte-identical results: same status, same activation_token field-for-field, same parity_report (transitively deterministic via the harness contract), same divergence_exceeds_scope array contents in same order, same expected/got strings.

The Object.freeze calls do not affect equality semantics; frozen objects with identical own-property descriptors are deepEqual.

§4.2. No clock, RNG, network, env

The body uses none of:

  • Math.*, Date.*, new Date
  • setTimeout/setInterval/setImmediate
  • fetch/XMLHttpRequest
  • fs/node:fs imports
  • crypto.<member> (named import only)
  • process.hrtime/process.nextTick
  • await/async
  • Float literals
  • [native code] introspection

This is verified by Group 12 of determinism.test.ts (the corpus self-scan) AND by an explicit per-function self-scan in migration.test.ts F13.

§4.3. Frozen invariants

  • MigrationResult (every variant) is frozen at return time.
  • ActivationToken is frozen at construction.
  • divergence_exceeds_scope array is frozen at construction.
  • MigrationProposal is NOT frozen by this module (caller’s responsibility; treating it as readonly is sufficient — migrateRuleset never mutates it).
  • parity_report is frozen by runParity upstream — passes through unchanged.

§5. Errors

§5.1. MigrationError

Single error class, structured with the same shape as ParityHarnessError and VersionHashError:

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

Used for all input-shape errors per §3.2–§3.5 plus the strict-greater epoch invariant.

§5.2. Errors NOT raised

  • Parity failure → MigrationResult.status === 'rejected:parity' (NOT thrown)
  • Version mismatch → MigrationResult.status === 'rejected:version_mismatch' (NOT thrown)
  • onRejection callback throws → propagates unchanged (NOT caught)

§5.3. Errors propagated from upstream

  • VersionHashError from computeVersionHash (e.g. ruleset is not an array — but caller already validated this) — would only fire if our .map(c => c.rule) produces an unexpected shape; defensive.
  • CanonicalSerializationError from canonicalize (e.g. a Mutation contains a non-representable value, or a regex pattern’s source contains a non-canonicalizable value — but .source is always a string, so this should not fire). Defensive.
  • ParityHarnessError from runParity (e.g. corpus contains a duplicate event id) — propagates unchanged. The migration caller debugs at the harness layer, not here.

We do NOT wrap propagated errors in MigrationError. Wrapping would hide the actual layer where the bug lives.


§6. Performance budget

Single-corpus run of N events runs runParity once, which runs executeRuleset 2N times (once per ruleset per event). The migration overhead beyond runParity is:

  • 2 calls to computeVersionHash (one per ruleset)
  • 1 call to computeScopeSignature (linear in scope size)
  • 1 walk over (both_admit_diverge ∪ old_admit_new_reject ∪ old_reject_new_admit) — at most N events; per-event matchesScope call is linear in scope size
  • A handful of object/array constructions

Total overhead: O(N · |scope| + |old_ruleset| + |new_ruleset| + |scope|). The runParity cost dominates.

No explicit perf assertion in the test suite — the parity harness’s F9 fixture (10000 events < 5000 ms) covers the dominant cost. The P1.5.2 layer adds <100ms at worst on a 100-event corpus.


§7. Test plan (≥30 cases across 13 fixture families)

F1 — accepted path: identical rulesets

  • F1.1 — empty scope, identical rulesets, single event → accepted
  • F1.2 — empty scope, identical rulesets, 100-event corpus → accepted
  • F1.3 — non-empty scope, identical rulesets (no actual divergence) → accepted; scope signature recomputable
  • F1.4 — onRejection provided but not invoked (since accepted)

F2 — accepted path with in-scope divergence

  • F2.1 — old admits, new rejects, scope contains the event id → accepted
  • F2.2 — old rejects, new admits, scope contains the event id → accepted
  • F2.3 — mixed old-admit-new-reject + old-reject-new-admit, both in scope → accepted
  • F2.4 — scope is a RegExp matching all divergent events → accepted
  • F2.5 — accepted token’s scope_signature matches computeScopeSignature(scope) independently

F3 — rejected:parity: divergence outside scope

  • F3.1 — old admits, new rejects, empty scope → rejected:parity; divergence_exceeds_scope contains the event
  • F3.2 — old admits, new rejects, scope contains DIFFERENT event → rejected:parity; only off-scope events listed
  • F3.3 — both_admit_diverge (same admit, different effects) → rejected:parity even with id in declared scope
  • F3.4 — onRejection invoked exactly once with the parity-rejection result
  • F3.5 — divergence_exceeds_scope ordering: both_admit_diverge first, then old_admit_new_reject, then old_reject_new_admit

F4 — rejected:version_mismatch (old)

  • F4.1 — proposer claims wrong old_version, recomputed differs → rejected:version_mismatch
  • F4.2 — expected === proposal.old_version, got === recomputed_old
  • F4.3 — onRejection invoked exactly once
  • F4.4 — does NOT run parity harness (no parity_report in result)

F5 — rejected:version_mismatch (new)

  • F5.1 — proposer claims correct old_version, parity passes, but new_version is wrong → rejected:version_mismatch
  • F5.2 — expected === proposal.new_version, got === recomputed_new
  • F5.3 — onRejection invoked exactly once

F6 — MigrationError for target_epoch == current_epoch

  • F6.1 — target_epoch === current_epoch throws MigrationError
  • F6.2 — error message includes both values
  • F6.3 — no onRejection invocation (it’s an input-shape error)

F7 — MigrationError for target_epoch < current_epoch

  • F7.1 — target_epoch === 5n, current_epoch === 10n throws MigrationError
  • F7.2 — target_epoch === 0n, current_epoch === 1n throws (boundary)

F8 — MigrationError input-shape paths

  • F8.1 — proposal === null
  • F8.2 — proposal.old_version empty string
  • F8.3 — proposal.old_version non-string
  • F8.4 — proposal.new_version empty string
  • F8.5 — proposal.old_ruleset not an array
  • F8.6 — proposal.new_ruleset not an array
  • F8.7 — proposal.declared_divergence_scope not an array
  • F8.8 — proposal.target_epoch is 1 (number, not bigint)
  • F8.9 — test_corpus is not an array
  • F8.10 — current_epoch is not a bigint
  • F8.11 — options.onRejection is not a function
  • F8.12 — options.engine_version is empty string

F9 — Determinism (canonical-bytes equality across runs)

  • F9.1 — same proposal/corpus/epoch run twice; both accepted results have byte-identical activation tokens
  • F9.2 — same inputs, both rejected:parity results have byte-identical divergence_exceeds_scope
  • F9.3 — same inputs, both rejected:version_mismatch have byte-identical expected/got

F10 — both_admit_diverge unconditional rejection

  • F10.1 — same admit, different effects, event id IN declared scope → rejected:parity
  • F10.2 — divergence_exceeds_scope contains the event id

F11 — ActivationToken shape contract

  • F11.1 — Object.isFrozen(token) is true on accepted outcome
  • F11.2 — token has exactly the documented 6 fields
  • F11.3 — token.version_hash length is 71, prefix 'sha256:'
  • F11.4 — token.scope_signature length is 71, prefix 'sha256:'
  • F11.5 — token.parity_pass === true (literal)
  • F11.6 — token.target_epoch === proposal.target_epoch
  • F11.7 — token.issued_at_epoch === current_epoch
  • F11.8 — token.issued_old_version === recomputed_old

F12 — Corpus length

  • F12.1 — passing the 101-event DEFAULT_CORPUS from P1.5.5 with identical rulesets succeeds
  • F12.2 — passing a 100-event constructed fixture succeeds (AC requirement)

F13 — Determinism scanner self-scan

  • F13.1 — inspectFunctionForbidden(migrateRuleset.toString()) returns []
  • F13.2 — inspectFunctionForbidden(computeScopeSignature.toString()) returns []

Additional helper-tests (smoke)

  • H1 — computeScopeSignature is deterministic across calls
  • H2 — computeScopeSignature produces different signatures for 'foo' vs /foo/
  • H3 — computeScopeSignature rejects unsupported pattern types with MigrationError

Total cases: ~50.


§8. Out-of-scope (deferred)

  • Persisting MigrationResult to a database — Phase 1.5+; this is a pure-data slice. The caller persists if it wishes.
  • Wiring the fork-trigger callback to ι runtime — Phase 5; the onRejection callback is the hook only.
  • Proposal validation across multiple migration generations — out of P1.5.x. A proposal carrying inconsistent old/new pairs (e.g. the new ruleset is identical to the old) is technically admissible here but adds no value; π governance (Phase 5) gates such proposals.
  • Broadcasting ActivationToken to peers — out of κ; the consensus
    • admission layers (θ + α) consume tokens at the activation moment per P1.5.3.
  • Concurrent migrations — single-threaded by construction. Two parallel migrateRuleset calls with the same current_epoch are not forbidden but may produce two competing tokens. Resolution is a governance concern, not a migration concern.
  • Test-corpus mutation across runs — the corpus is treated as immutable input. We do not deep-freeze it on the caller’s behalf; if the caller mutates the corpus mid-run, behaviour is undefined.

§9. Forward coupling — P1.5.3 contract sketch

P1.5.3 will consume ActivationToken to gate the moment at which the runtime swaps from proposal.old_ruleset to proposal.new_ruleset. The Wave 8 contract for P1.5.3 must:

  • Accept ActivationToken as input
  • Verify token.target_epoch > current_runtime_epoch (else: rollback)
  • Verify token.parity_pass === true (the literal-typed sentinel makes this a as const check, not a runtime branch)
  • Apply the swap atomically; record token in audit log
  • Provide rollback(token) for the equal/lesser-epoch case

P1.5.3 MUST NOT extend the ActivationToken shape additively without a contract amendment to THIS document. Removing fields is forbidden.


§10. Status

Contract complete. Locked decisions (mirrors audit §10):

  • 3-variant MigrationResult
  • ActivationToken 6-field shape, parity_pass: true literal
  • MigrationError for shape errors only; MigrationResult for parity/version
  • onRejection exactly-once on every rejection
  • Both old_version AND new_version re-verified
  • divergence_exceeds_scope ordering: both_admit_diverge first
  • Scope signature: {kind, value, flags?} normalization + canonicalize + sha256

Proceeding to Step 3 (packet).


Back to top

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

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