P1.5.3 — Execution Packet
Step 3 of the 5-step executor chain. Operationalizes the behavioral contract (docs/contracts/p1-5-3-activation-contract.md) into a concrete implementation plan and test plan. The packet gates Step 4 — implementation may not begin without packet approval.
1. Layout
src/domains/rules/activation.ts
§1 — Imports
§2 — Re-exports (ActivationToken from migration)
§3 — Type declarations (JournalEntry, JournalCause, GovernanceReviewSnapshot, GovernanceReviewHook)
§4 — ActivationError
§5 — Internal validators (shape predicates)
§6 — ActivationJournal class
§7 — scheduleActivation (named function)
§8 — applyActivation (named function)
§9 — rollback (named function)
§10 — governance_review_hook (default no-op)
src/__tests__/domains/rules/activation.test.ts
Group 1 — Construction (F1 — 6 cases)
Group 2 — append (F2 — 8 cases)
Group 3 — current (F3 — 2 cases)
Group 4 — at (F4 — 6 cases)
Group 5 — all (F5 — 3 cases)
Group 6 — scheduleActivation happy path (F6 — 2 cases)
Group 7 — scheduleActivation rejections (F7+F8+F9 — 5 cases)
Group 8 — applyActivation rejection (F10 — 2 cases)
Group 9 — applyActivation happy path (F11 — 4 cases)
Group 10 — ActivationToken consumed verbatim (F12 — 1 case)
Group 11 — rollback happy path (F13 — 3 cases)
Group 12 — rollback governance hook (F14+F15+F18+F19 — 4 cases)
Group 13 — rollback rejections (F16+F17 — 2 cases)
Group 14 — Determinism corpus self-scan (covered by existing
src/__tests__/domains/rules/determinism.test.ts §Group 12,
which scans every src/domains/rules/*.ts; no new test needed,
we just have to keep activation.ts clean)
The grouping follows the order of migration.test.ts and parity-harness.test.ts for navigability across the κ corpus.
2. Implementation order
The order below minimizes wasted work when an early-stage type goes wrong.
- Skeleton
activation.ts— module header JSDoc, imports, re-exports, error class. Verifynpm run buildis green at this point (catches typos early). - Type declarations —
JournalCause,JournalEntry,GovernanceReviewSnapshot,GovernanceReviewHook.npm run buildagain. - Internal validators —
validateBigint,validateNonEmptyString,validateBoolean,validateActivationTokenShape,validateJournalEntryShape. (These are private — not exported.) ActivationJournalclass — constructor,append,current,at,all. Runnpm run buildafter each method to surface tsc errors early.governance_review_hook(default no-op).scheduleActivationfunction.applyActivationfunction.rollbackfunction.- Skeleton
activation.test.ts— imports + AST builder helpers + reusable token factory (mirrorsmigration.test.ts:60-100). Verifynpm test -- --testPathPattern=activationis green (no test cases yet, but the file should compile and import cleanly). - Test groups in numerical order. Run
npm test -- --testPathPattern=activationafter each group to catch local regressions before the next. - Final pass:
npm run build && npm run lint && npm test. All green required.
3. Detailed implementation notes
3.1. governance_review_hook (the export)
Required to be a const so the type is inferred as GovernanceReviewHook and the binding can be replaced in test fixtures via the explicit hook parameter without redefining the export. The body is a no-op ((_snapshot) => {}); the underscore-prefixed parameter is by lint convention to mark “intentionally unused.”
The export is named with snake_case (governance_review_hook) to mirror the prompt’s literal wording (line 2545: governance_review_hook()). The TypeScript convention for exported variables is camelCase; we override here for prompt fidelity, since this is a stable seam name that will be cited in documents downstream.
3.2. ActivationJournal entries array
The entries field is private and readonly (in TypeScript visibility — the readonly modifier prevents reassignment of the array reference; Object.freeze of individual entries prevents mutation of any single entry). We do not freeze the array itself — it must support push for append. Only individual entries are frozen.
The all() method returns Object.freeze(entries.slice()) — the slice is the immutable shallow copy; the freeze prevents callers from mutating the array (e.g., arr.length = 0).
3.3. at(epoch: bigint) algorithm
Linear scan from the end of the array (most recent first):
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (e !== undefined && e.epoch <= epoch) {
return e;
}
}
throw new ActivationError(...);
Rationale: typical lookups target recent epochs. Linear-from-end is O(N) worst case but O(1) for the common path. If profiling later shows hot-spot pressure, a binary search is a drop-in replacement (entries are sorted by epoch by construction).
The e !== undefined guard satisfies noUncheckedIndexedAccess (a strict TypeScript option that may or may not be enabled — using the guard is safe regardless).
3.4. applyActivation and the journal’s append guard
applyActivation does not pre-check current_epoch > journal.current().epoch — instead it relies on journal.append to throw 'non-monotonic epoch'. Rationale: pre-checking duplicates the guard, and the journal’s error message is canonical. The trade-off is that the user sees 'non-monotonic epoch' (less specific) rather than 'apply at non-monotonic epoch' (more specific). The contract §5.4 documents this explicitly.
3.5. rollback and the dispute window
Order of operations is load-bearing per the contract:
- Validate inputs.
- Validate
target_versionis in a prior entry. - Append the rollback entry to the journal. The journal mutates here.
- If
dispute_window_open === true, build the snapshot and invoke the hook.
If the hook throws, the journal is already updated. This is by design — the hook is a notification, not a veto. A π verifier that wants to prevent the rollback should run before rollback is called, not inside the hook.
Pre-checking monotonicity inside rollback: we DO pre-check current_epoch > journal.current().epoch before the append, so the journal_length in the snapshot is always “1 more than before this call.” Without the pre-check, we’d discover non-monotonicity inside journal.append, which would throw before the snapshot is built — but then the test for the dispute-window code path becomes harder to read. The pre-check is cleaner.
3.6. Forbidden patterns avoidance
activation.ts will pass the determinism corpus self-scan. Enforced by:
- No
Math.*, noDate.*, nocrypto.*, noawait, noasync, no float literals (\d+\.\d+), nosetTimeout, nofetch, no fs imports. - All comparisons of
bigintuse<,>,<=,>=,===,!==. No conversions. - No
cryptoimport — the module does no hashing.version_hashstrings are passed through as opaque values.
4. Test plan in detail
4.1. Reusable fixture: makeToken(version_hash: string, target_epoch: bigint, issued_at_epoch: bigint = 0n): ActivationToken
Constructs a fake-but-shape-correct ActivationToken for non-integration tests:
function makeToken(
version_hash: string,
target_epoch: bigint,
issued_at_epoch: bigint = 0n,
): ActivationToken {
return Object.freeze({
version_hash,
target_epoch,
issued_at_epoch,
parity_pass: true as const,
scope_signature: 'sha256:' + '0'.repeat(64),
issued_old_version: 'sha256:' + 'f'.repeat(64),
});
}
Used in F6–F11 + F13–F19. The shape is identical to what migrateRuleset returns; the field values are placeholders since these tests are not exercising migration logic, only activation logic.
4.2. Integration fixture: F12 — token consumed verbatim
This fixture imports migrateRuleset from ../../../domains/rules/migration.js and runs an end-to-end accepted migration to obtain a real token. The token is then handed to applyActivation. The assertion: journal.current().version_hash === token.version_hash (string equality).
The migration’s input ruleset is two trivial admit rules (mirrors migration.test.ts §F1). The corpus is DEFAULT_CORPUS (also imported from parity-harness.js). This validates that the field name version_hash (NOT the stale new_version) is the one used end-to-end.
4.3. F4.b — “returns initial for epoch < first migration’s epoch”
This case is subtle. Given a journal with:
entries[0] = { epoch: 0n, version_hash: 'A', cause: 'initial' }entries[1] = { epoch: 100n, version_hash: 'B', cause: 'migration' }
journal.at(50n) must return entries[0] (the initial). The algorithm finds the largest e.epoch <= 50n, which is entries[0] with e.epoch === 0n <= 50n.
This is the contract’s “the initial entry is active until the first migration.” Without this property, at could throw on epochs in the gap, which is wrong: the initial version IS active in that range.
4.4. F13 — past events stand
The fixture asserts:
- Journal: initial at epoch 0n with version
A. - Migration:
Bactivated at epoch 100n. - Rollback: to
Aat epoch 200n. journal.at(50n).version_hash === 'A'. (Initial era.)journal.at(150n).version_hash === 'B'. (Migration era — still ‘B’ even after rollback.)journal.at(250n).version_hash === 'A'. (Rollback era.)
Step 5 is the crucial one — it proves rollback does not retroactively rewrite the past.
4.5. F14 — governance hook invoked once with correct snapshot
const hook = jest.fn();
rollback(journal, 'A', 200n, true, hook);
expect(hook).toHaveBeenCalledTimes(1);
const arg = hook.mock.calls[0][0];
expect(arg.target_version).toBe('A');
expect(arg.current_epoch).toBe(200n);
expect(arg.prior_current_entry).toEqual(/* entry that was journal.current() pre-rollback */);
expect(arg.journal_length).toBe(/* post-rollback length */);
4.6. F19 — hook errors propagate
const hook = jest.fn(() => { throw new Error('π veto'); });
expect(() => rollback(journal, 'A', 200n, true, hook)).toThrow('π veto');
expect(journal.current().cause).toBe('rollback'); // journal was already updated
4.7. F20 — Determinism corpus self-scan (passive)
The existing src/__tests__/domains/rules/determinism.test.ts §Group 12 scans every *.ts file in src/domains/rules/. As long as activation.ts contains none of the forbidden tokens, this group passes without modification. We do not write a new test for it; we only verify by running npm test -- --testPathPattern=determinism after Step 4.
5. Build/lint/test gate
5.1. Sequential local runs (during implementation)
cd .worktrees/claude/p1-5-3-activation
npm run build # tsc, fast
npm test -- --testPathPattern=activation # only the new file
npm test -- --testPathPattern=determinism # corpus self-scan
5.2. Final gate (before push)
cd .worktrees/claude/p1-5-3-activation
npm run build && npm run lint && npm test
All three must pass. Exit code 0 on each. The && chain stops on the first failure — investigate before pushing.
Test count expectation: ~50 new cases on top of the post-Wave-7 baseline.
6. Commit hygiene
One commit per chain step. Step-4 commit message follows the dispatch prompt template.
audit(p1-5-3): inventory surface
contract(p1-5-3): behavioral contract
packet(p1-5-3): execution plan
feat(p1-5-3): activation epoch + rollback
verify(p1-5-3): test evidence
The feat commit body cites the canonical 6-field ActivationToken consumption strategy (accept-already-constructed) and references PR #216 as the upstream of the token shape.
7. PR body skeleton (used by Step 4 push)
Per the dispatch prompt — a ## Summary + ## β task + ## Test plan body with the required co-author line. The PR title is feat(p1-5-3-activation): activation epoch + rollback (R87 κ Wave 8 — Phase 1 close).
8. Risk acknowledgment
Pre-existing flakes documented in memory:
startup — subprocess smokeflake under full-suite load.
If hit, retry once. If stable on retry, proceed; the flake is not Wave-8-introduced.
Other risks:
noUncheckedIndexedAccessmay or may not be enabled. The implementation uses guards regardless.- ESM extension
.json TypeScript source imports — the existing convention in the rules domain (./migration.js,./parity-harness.js). We follow it.
9. Step 3 sign-off
Execution plan locked. Step 4 (implementation) follows the order in §2, the test groups in §1, and uses the reusable fixtures in §4. Step 5 (verification) records the results.