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 DatesetTimeout/setInterval/setImmediatefetch/XMLHttpRequestfs/node:fsimportscrypto.<member>(named import only)process.hrtime/process.nextTickawait/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.ActivationTokenis frozen at construction.divergence_exceeds_scopearray is frozen at construction.MigrationProposalis NOT frozen by this module (caller’s responsibility; treating it as readonly is sufficient —migrateRulesetnever mutates it).parity_reportis frozen byrunParityupstream — 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) onRejectioncallback throws → propagates unchanged (NOT caught)
§5.3. Errors propagated from upstream
VersionHashErrorfromcomputeVersionHash(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.CanonicalSerializationErrorfromcanonicalize(e.g. a Mutation contains a non-representable value, or a regex pattern’ssourcecontains a non-canonicalizable value — but.sourceis always a string, so this should not fire). Defensive.ParityHarnessErrorfromrunParity(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-eventmatchesScopecall 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 —
onRejectionprovided 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 —
acceptedtoken’sscope_signaturematchescomputeScopeSignature(scope)independently
F3 — rejected:parity: divergence outside scope
- F3.1 — old admits, new rejects, empty scope →
rejected:parity;divergence_exceeds_scopecontains 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:parityeven with id in declared scope - F3.4 —
onRejectioninvoked exactly once with the parity-rejection result - F3.5 —
divergence_exceeds_scopeordering:both_admit_divergefirst, thenold_admit_new_reject, thenold_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 —
onRejectioninvoked exactly once - F4.4 — does NOT run parity harness (no
parity_reportin result)
F5 — rejected:version_mismatch (new)
- F5.1 — proposer claims correct
old_version, parity passes, butnew_versionis wrong →rejected:version_mismatch - F5.2 —
expected === proposal.new_version,got === recomputed_new - F5.3 —
onRejectioninvoked exactly once
F6 — MigrationError for target_epoch == current_epoch
- F6.1 —
target_epoch === current_epochthrowsMigrationError - F6.2 — error message includes both values
- F6.3 — no
onRejectioninvocation (it’s an input-shape error)
F7 — MigrationError for target_epoch < current_epoch
- F7.1 —
target_epoch === 5n,current_epoch === 10nthrowsMigrationError - F7.2 —
target_epoch === 0n,current_epoch === 1nthrows (boundary)
F8 — MigrationError input-shape paths
- F8.1 —
proposal === null - F8.2 —
proposal.old_versionempty string - F8.3 —
proposal.old_versionnon-string - F8.4 —
proposal.new_versionempty string - F8.5 —
proposal.old_rulesetnot an array - F8.6 —
proposal.new_rulesetnot an array - F8.7 —
proposal.declared_divergence_scopenot an array - F8.8 —
proposal.target_epochis1(number, not bigint) - F8.9 —
test_corpusis not an array - F8.10 —
current_epochis not a bigint - F8.11 —
options.onRejectionis not a function - F8.12 —
options.engine_versionis empty string
F9 — Determinism (canonical-bytes equality across runs)
- F9.1 — same proposal/corpus/epoch run twice; both
acceptedresults have byte-identical activation tokens - F9.2 — same inputs, both
rejected:parityresults have byte-identicaldivergence_exceeds_scope - F9.3 — same inputs, both
rejected:version_mismatchhave byte-identicalexpected/got
F10 — both_admit_diverge unconditional rejection
- F10.1 — same admit, different effects, event id IN declared scope →
rejected:parity - F10.2 —
divergence_exceeds_scopecontains the event id
F11 — ActivationToken shape contract
- F11.1 —
Object.isFrozen(token)is true onacceptedoutcome - F11.2 — token has exactly the documented 6 fields
- F11.3 —
token.version_hashlength is 71, prefix'sha256:' - F11.4 —
token.scope_signaturelength 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_CORPUSfrom 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 —
computeScopeSignatureis deterministic across calls - H2 —
computeScopeSignatureproduces different signatures for'foo'vs/foo/ - H3 —
computeScopeSignaturerejects unsupported pattern types withMigrationError
Total cases: ~50.
§8. Out-of-scope (deferred)
- Persisting
MigrationResultto 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
onRejectioncallback 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
ActivationTokento 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
migrateRulesetcalls with the samecurrent_epochare 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
ActivationTokenas input - Verify
token.target_epoch > current_runtime_epoch(else: rollback) - Verify
token.parity_pass === true(the literal-typed sentinel makes this aas constcheck, not a runtime branch) - Apply the swap atomically; record
tokenin 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 ActivationToken6-field shape,parity_pass: trueliteralMigrationErrorfor shape errors only;MigrationResultfor parity/versiononRejectionexactly-once on every rejection- Both
old_versionANDnew_versionre-verified divergence_exceeds_scopeordering:both_admit_divergefirst- Scope signature:
{kind, value, flags?}normalization + canonicalize + sha256
Proceeding to Step 3 (packet).