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_hashthat θ consensus signs. Already shipped at base; importable from./versioning.js. - P1.5.5 parity-harness.ts — produces the
ParityReportfor 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:
EventIdPatternis the P1.5.5 type — re-exported, not redefined.old_rulesetshape isreadonly CategorizedRule[](P1.3.1) NOTreadonly RuleNode[](P1.2.2). The parity harness needs categorized rules (it threads them throughRuleRegistry). The version-hash function consumes only the.ruleprojection (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 thetarget_epoch <= current_epochrejection lands (we co-locate it asrejected:version_mismatchis 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:
- Add a fourth status
rejected:epoch. Cleaner taxonomy, but inflatesMigrationResultto 4 variants for a single rare failure path. - Throw a typed error from
migrateRulesetfor 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_epoch → throw 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:
version_hash— the primary cargo. P1.5.3 reads this to know which ruleset to swap to.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.parity_pass: trueis a literal-typed sentinel (not a genericboolean). This makes “anActivationTokenwhoseparity_pass: false” uninhabitable at the type level — accepted-status tokens, by construction, must have passed parity.scope_signature— a canonical-JSON SHA-256 hash of the proposal’sdeclared_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 anActivationTokenafter the fact.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: ... }) => voidis 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
MigrationResultso a future ι wiring can route onresult.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:
proposalis null / not an objectproposal.old_versionis not a non-empty stringproposal.new_versionis not a non-empty stringproposal.old_rulesetis not an arrayproposal.new_rulesetis not an arrayproposal.declared_divergence_scopeis not an arrayproposal.target_epochis not a bigintcurrent_epochis not a biginttarget_epoch <= current_epoch(the strict-greater invariant)test_corpusis not an arrayonRejectionis 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,computeVersionHashfrom./versioning.js(already shipped, already self-scan-clean).runParity,matchesScopefrom./parity-harness.js(already shipped, already self-scan-clean).canonicalizefrom./canonical.js(already shipped) — needed forscope_signaturehashing.createHashnamed import fromnode:crypto— same pattern asversioning.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:
- F1 — accepted path: 100-event corpus, identical behavior under
old + new, non-empty scope still yields parity-pass since divergence
set is empty. Result:
acceptedwith token.onRejectionnot invoked. - F2 — accepted path with in-scope divergence: divergence within
declared scope. Result:
acceptedwith token.onRejectionnot invoked. - F3 — rejected:parity: divergence outside declared scope. Result:
rejected:paritywithdivergence_exceeds_scopepopulated.onRejectioninvoked exactly once. - F4 — rejected:version_mismatch (old): proposer claims
old_version: '<wrong-hash>'. Result:rejected:version_mismatchwithexpected/gotpopulated.onRejectioninvoked exactly once. - F5 — rejected:version_mismatch (new): proposer claims
new_version: '<wrong-hash>'. Result:rejected:version_mismatchwithexpected/got(the wrong-new-hash).onRejectioninvoked once. - F6 — MigrationError (epoch equal):
target_epoch == current_epochthrows. NOonRejectioncall (it’s an input-shape error, not a result). - F7 — MigrationError (epoch less):
target_epoch < current_epochthrows. - F8 — MigrationError (input-shape paths): every documented invalid shape throws.
- F9 — Determinism: same proposal/corpus/epoch run twice produces
reports whose canonical-JSON serialization is byte-identical. Token
version_hashandscope_signatureare byte-identical too. - 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.
- F11 — ActivationToken shape contract: token is frozen, has exactly the documented fields, hashes match the recomputed fingerprints.
- 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.)
- 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 + errorsrc/__tests__/domains/rules/migration.test.ts— F1–F13 fixturesdocs/audits/p1-5-2-migration-audit.md(this file) — Step 1docs/contracts/p1-5-2-migration-contract.md— Step 2docs/packets/p1-5-2-migration-packet.md— Step 3docs/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.mdAGENTS.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 ActivationTokenshape: 6 fields, frozen, literal-typedparity_pass: true- Epoch violations →
MigrationError, notMigrationResultvariant onRejectioncallback: optional, invoked exactly once on every rejectionMigrationErrorclass for shape errors (mirrorsParityHarnessError/VersionHashErrorprecedent)- Both old AND new version hashes verified
divergence_exceeds_scopeincludes bothold_admit_new_reject ∪ old_reject_new_admitevents out of scope AND ALLboth_admit_divergeevents- Scope signature: canonicalize after
{kind, value, flags?}normalization