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
ActivationTokeninterface (6 fields, all readonly). - Define
MigrationProposalinterface. - Define
MigrationResultdiscriminated union. - Define
OnRejectionCallbacktype. - Define
MigrationOptionsinterface.
§2 — MigrationError (~10 lines):
Single error class.
§3 — Internal validators (~80 lines):
validateProposal(proposal)— 7 throws per contract §3.2validateCorpus(test_corpus)— 1 throw per §3.3validateCurrentEpoch(current_epoch)— 1 throw per §3.4validateOptions(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 underdocs/{audits,contracts,packets,verification}/). - No vault sync (out of T3 scope).
- No
colibri_code:frontmatter graduation (κ remainscolibri_code: noneuntil 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 migrateRulesetreturnsObject.freeze‘d results (every variant)ActivationTokenisObject.freeze‘d at constructiondivergence_exceeds_scopeisObject.freeze‘d at construction- All 12 input-shape errors throw
MigrationError target_epoch <= current_epochthrowsMigrationErroronRejectioninvoked exactly once per rejection (structurally — one call site per branch)npm run buildcleannpm run lintcleannpm testgreen; 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.