P1.5.2 — Rule Migration — Execution Packet (Step 3)

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: docs/audits/p1-5-2-migration-audit.md (committed at 7e662cd8) Contract: docs/contracts/p1-5-2-migration-contract.md (committed at 048431c4)


§1. Plan summary

Implement src/domains/rules/migration.ts per the locked contract. The module is ~280 lines of pure code covering:

  • 1 entry point: migrateRuleset
  • 1 helper: computeScopeSignature (exported for audit reconciliation + tests)
  • 5 internal validators
  • 1 internal walker (collectOutOfScopeDivergences)
  • 1 internal callback dispatcher (invokeOnRejection)
  • Type aliases + interfaces + 1 error class

Test file src/__tests__/domains/rules/migration.test.ts is ~750 lines covering 13 fixture families (~50 cases) per contract §7.

Determinism scanner integration: the migration body must satisfy determinism.test.ts §Group 12 (corpus self-scan over src/domains/rules/*.ts). This is enforced by file inclusion — the determinism scanner will pick up migration.ts automatically. F13 also self-scans the migration’s exported functions individually.


§2. File-by-file changes

§2.1. src/domains/rules/migration.ts (new file, ~280 lines)

Top-of-file JSDoc (40 lines) — overview, public surface, references to docs/audits/p1-5-2-migration-audit.md, docs/contracts/p1-5-2-migration-contract.md, docs/3-world/physics/laws/rule-engine.md §Rule versioning + §Test corpus parity.

Imports:

import { createHash } from 'node:crypto';

import { canonicalize } from './canonical.js';
import { runParity } from './parity-harness.js';
import type {
  EventId,
  EventIdPattern,
  ParityEvent,
  ParityInput,
  ParityReport,
} from './parity-harness.js';
import { computeVersionHash, verifyRuleVersion } from './versioning.js';
import { ENGINE_VERSION } from './versioning.js';
import type { CategorizedRule, RuleNode } from './engine.js';
// Note: RuleNode is re-exported from parser.js; engine.ts re-exports
// CategorizedRule. We use only what the contract surface promises.

§1 — Re-exports + types (~30 lines):

  • Re-export CategorizedRule, EventId, EventIdPattern, ParityEvent, ParityReport.
  • Define ActivationToken interface (6 fields, all readonly).
  • Define MigrationProposal interface.
  • Define MigrationResult discriminated union.
  • Define OnRejectionCallback type.
  • Define MigrationOptions interface.

§2 — MigrationError (~10 lines): Single error class.

§3 — Internal validators (~80 lines):

  • validateProposal(proposal) — 7 throws per contract §3.2
  • validateCorpus(test_corpus) — 1 throw per §3.3
  • validateCurrentEpoch(current_epoch) — 1 throw per §3.4
  • validateOptions(options) — 3 throws per §3.5

Pattern: each validator is function validateX(x: unknown): asserts x is ConcreteType. Use of asserts lets the entry point treat values as typed after validation. Note: TypeScript narrowing is at compile time; the runtime checks remain regardless.

§4 — collectOutOfScopeDivergences (~25 lines):

function collectOutOfScopeDivergences(
  report: ParityReport,
  scope: readonly EventIdPattern[],
): EventId[] {
  const out: EventId[] = [];
  for (const id of report.both_admit_diverge) {
    out.push(id);
  }
  for (const id of report.old_admit_new_reject) {
    if (!localMatchesScope(id, scope)) {
      out.push(id);
    }
  }
  for (const id of report.old_reject_new_admit) {
    if (!localMatchesScope(id, scope)) {
      out.push(id);
    }
  }
  return out;
}

localMatchesScope mirrors parity-harness.matchesScope. We could import the harness’s matchesScope, but we already import runParity from parity-harness. Importing one more function is fine; it keeps the walk symmetric with the harness’s own pass-decision logic. Decision: import matchesScope from parity-harness. Single source of truth.

Updated import block:

import { matchesScope, runParity } from './parity-harness.js';

§5 — invokeOnRejection (~10 lines):

function invokeOnRejection(
  options: MigrationOptions | undefined,
  result:
    | (MigrationResult & { status: 'rejected:parity' })
    | (MigrationResult & { status: 'rejected:version_mismatch' }),
): void {
  if (options !== undefined && options !== null && typeof options.onRejection === 'function') {
    options.onRejection(result);
  }
}

§6 — computeScopeSignature (~25 lines):

const SCOPE_SIGNATURE_PREFIX = 'sha256:';

interface NormalizedScopeEntry {
  readonly kind: 'string' | 'regex';
  readonly value: string;
  readonly flags?: string;
}

export function computeScopeSignature(
  scope: readonly EventIdPattern[],
): string {
  const normalized: NormalizedScopeEntry[] = [];
  for (const p of 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(
        'computeScopeSignature: unsupported EventIdPattern (must be string or RegExp)',
      );
    }
  }
  const body = canonicalize(normalized);
  const hash = createHash('sha256');
  hash.update(body, 'utf8');
  return SCOPE_SIGNATURE_PREFIX + hash.digest('hex');
}

The flags field is undefined for string entries. canonicalize must accept undefined-valued keys; if it doesn’t, we use a separate shape (no flags key on string entries). Verify: read canonical.ts to confirm. If undefined keys are dropped silently (the typical canonical-JSON behaviour), we’re fine. If they error, switch to a discriminated shape.

Verification of canonical.ts behaviour: per its docstring it raises CanonicalSerializationError for undefined. So we MUST omit flags on string entries. Updated:

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 });
}

The NormalizedScopeEntry type uses flags?: string, so the string variant simply doesn’t include the key. canonicalize of a plain object omits undefined properties when the key isn’t present (vs. when its value is undefined).

§7 — migrateRuleset (~80 lines): The main entry point per contract §3.1. Implementation follows the algorithm verbatim. Two Object.freeze calls per non-accepted result; single Object.freeze call per accepted result + token.

Key implementation note: for Step 3 (recompute old version), we map proposal.old_ruleset.map(c => c.rule) to extract RuleNode[] for computeVersionHash. computeVersionHash accepts readonly RuleNode[].

Bottom of file: barrel re-exports if needed by external consumers (none in Phase 1 — the test file imports directly).

§2.2. src/__tests__/domains/rules/migration.test.ts (new file, ~750 lines)

Top-of-file JSDoc (15 lines) — test matrix overview pointing to contract §7.

Imports:

import {
  ActivationToken,
  MigrationError,
  MigrationProposal,
  MigrationResult,
  computeScopeSignature,
  migrateRuleset,
} from '../../../domains/rules/migration.js';
import type { CategorizedRule } from '../../../domains/rules/engine.js';
import type { ParityEvent, EventId } from '../../../domains/rules/parity-harness.js';
import { DEFAULT_CORPUS, runParity } from '../../../domains/rules/parity-harness.js';
import { computeVersionHash } from '../../../domains/rules/versioning.js';
import { canonicalize } from '../../../domains/rules/canonical.js';
import { inspectFunctionForbidden } from '../../../domains/rules/determinism.js';
import type {
  EffectCall, Expression, GuardClause, IntLiteral,
  Location, RuleNode, VarRef, BoolLiteral,
} from '../../../domains/rules/parser.js';

Helpers (~80 lines): Reuse the same builder functions as parity-harness.test.ts lines 63–123: LOC, intLit, boolLit, varRef, guard, effect, mkRule, makeAdmittingRule, makeAdmittingRuleWithSet, makeRejectingRule.

Add migration-specific helpers:

function makeProposal(opts: {
  old_ruleset: CategorizedRule[];
  new_ruleset: CategorizedRule[];
  declared_divergence_scope?: readonly EventIdPattern[];
  target_epoch?: bigint;
  old_version_override?: string;
  new_version_override?: string;
}): MigrationProposal {
  const oldNodes = opts.old_ruleset.map(c => c.rule);
  const newNodes = opts.new_ruleset.map(c => c.rule);
  return {
    old_version: opts.old_version_override ?? computeVersionHash(oldNodes),
    new_version: opts.new_version_override ?? computeVersionHash(newNodes),
    old_ruleset: opts.old_ruleset,
    new_ruleset: opts.new_ruleset,
    declared_divergence_scope: opts.declared_divergence_scope ?? [],
    target_epoch: opts.target_epoch ?? 10n,
  };
}

function buildEvent(opts: {
  id: EventId;
  event?: Readonly<Record<string, unknown>>;
  state?: Readonly<Record<string, unknown>>;
  rule_version?: string;
  epoch?: bigint;
}): ParityEvent {
  return {
    id: opts.id,
    event: opts.event ?? {},
    state: opts.state ?? {},
    rule_version: opts.rule_version ?? 'v1',
    epoch: opts.epoch ?? 1n,
  };
}

13 fixture families (~600 lines) — one describe per family per contract §7. ~50 individual test() cases.

Determinism scanner self-scan — F13 imports inspectFunctionForbidden and asserts:

test('F13.1 migrateRuleset has no forbidden ops', () => {
  expect(inspectFunctionForbidden(migrateRuleset.toString())).toEqual([]);
});

test('F13.2 computeScopeSignature has no forbidden ops', () => {
  expect(inspectFunctionForbidden(computeScopeSignature.toString())).toEqual([]);
});

The corpus self-scan in determinism.test.ts §Group 12 is the project-level guard; F13 here is the slice-level guard.


§3. Execution order

Single commit (Step 4): feat(p1-5-2): rule migration orchestration. No pre-commit splits — implementation and tests land together since tests exercise the implementation surface.

1. cd E:/AMS/.worktrees/claude/p1-5-2-migration
2. Write src/domains/rules/migration.ts  (full file)
3. Write src/__tests__/domains/rules/migration.test.ts  (full file)
4. npm run build
5. npm run lint
6. npm test
7. git add src/domains/rules/migration.ts src/__tests__/domains/rules/migration.test.ts
8. git commit -m "feat(p1-5-2): rule migration orchestration"

If build errors: fix in migration.ts only; do not modify other files. If lint errors: fix style. If a test errors: edit migration.ts (impl) or the test file (fixture construction); never change another module.


§4. Risk register & mitigations

ID Risk Mitigation in this packet
RP1 canonicalize rejects undefined flags on string entries Use absent property, not undefined; verified against canonical.ts contract
RP2 runParity errors propagated wrong Don’t catch; let ParityHarnessError propagate as documented
RP3 computeVersionHash reject ruleset shape Caller’s validateProposal already gates this; the .map(c => c.rule) is a fresh array, never mutated
RP4 Frozen-result equality differs from POJO equality in Jest toEqual works on frozen objects; toBe on the discriminator + toEqual on the value is the pattern
RP5 onRejection accidentally invoked twice Single invokeOnRejection call site per branch; structurally enforced
RP6 The corpus self-scan picks up crypto.<X> somewhere Named import only; named import token doesn’t match the regex
RP7 Test file forgets to wire up engine_version consistently Helper makeProposal always uses default; F4/F5 use override-by-string instead of crafted hashes
RP8 Object.freeze on the parity report (already frozen by harness) is a no-op but works Harness freezes; we don’t re-freeze
RP9 divergence_exceeds_scope ordering Walk both_admit_diverge first, then old_admit_new_reject, then old_reject_new_admit; F3.5 explicitly asserts

§5. Out-of-scope for this packet

  • No edits to versioning.ts, parity-harness.ts, engine.ts, registry.ts, or any non-test file.
  • No changes to package.json, tsconfig.json, jest.config.*, or CI workflows.
  • No new schema, no new migrations, no new MCP tool registrations.
  • No edits to docs/3-world/physics/laws/rule-engine.md (concept doc is already correct; this slice only adds an audit/contract/packet/ verification trail under docs/{audits,contracts,packets,verification}/).
  • No vault sync (out of T3 scope).
  • No colibri_code: frontmatter graduation (κ remains colibri_code: none until the full κ phase ships per ADR-006).

§6. Acceptance — packet self-check

Before committing Step 4, verify:

  • All 6 contract surface exports are present in migration.ts
  • migrateRuleset returns Object.freeze‘d results (every variant)
  • ActivationToken is Object.freeze‘d at construction
  • divergence_exceeds_scope is Object.freeze‘d at construction
  • All 12 input-shape errors throw MigrationError
  • target_epoch <= current_epoch throws MigrationError
  • onRejection invoked exactly once per rejection (structurally — one call site per branch)
  • npm run build clean
  • npm run lint clean
  • npm test green; total count ≥ existing-base + 50 (50 new fixtures)
  • Determinism scanner self-scan returns [] for both exported functions
  • No edits to any pre-existing src file

§7. Status

Packet complete. Implementation can begin.


Back to top

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

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