P1.5.3 — Behavioral Contract
Step 2 of the 5-step executor chain. Specifies what src/domains/rules/activation.ts does, what it forbids, and what it returns under every scenario. The packet (§3) and tests (§Step 4) are derived from this contract.
1. Module identity
src/domains/rules/activation.ts — pure synchronous library that owns the κ ruleset activation lifecycle.
Public surface (locked at this contract):
- Re-exports:
ActivationToken(from./migration.js). - Types:
JournalEntry,JournalCause,GovernanceReviewHook,GovernanceReviewSnapshot. - Errors:
ActivationError. - Class:
ActivationJournal. - Functions:
scheduleActivation,applyActivation,rollback,governance_review_hook(default no-op stub).
No other names are exported. Adding to the surface requires a contract amendment.
2. Type definitions
2.1. JournalCause
export type JournalCause = 'initial' | 'migration' | 'rollback';
Discriminator string on JournalEntry. Identifies why a particular (epoch, version_hash) row exists:
'initial'— the first row in any journal. Created at construction time. Documents the version that was active before any migration.'migration'— appended byapplyActivation. Documents anActivationTokenhaving been applied at runtime epoch.'rollback'— appended byrollback. Documents a deliberate restoration of a prior version.
2.2. JournalEntry
export interface JournalEntry {
readonly epoch: bigint;
readonly version_hash: string;
readonly cause: JournalCause;
}
A single tuple in the journal. Frozen at append time (Object.freeze).
Field semantics:
epoch— the runtime epoch at which this entry’s version became active. Strictly monotonically increasing across the journal (seeActivationJournal.append). bigint, never number.version_hash— exactly the value carried by anActivationToken.version_hash(or the bootstrap'initial'value). Always a 71-char'sha256:'-prefixed hex string in production; the type isstringto keep the journal flexible during testing.cause— one of the three discriminator strings above.
2.3. GovernanceReviewSnapshot
export interface GovernanceReviewSnapshot {
readonly target_version: string;
readonly current_epoch: bigint;
readonly prior_current_entry: JournalEntry;
readonly journal_length: number;
}
Read-only snapshot delivered to the governance hook on dispute-window rollbacks. Frozen at construction (the snapshot — not the journal entry it references; prior_current_entry is the existing frozen entry).
prior_current_entry is the journal head before the rollback entry was appended. This lets a governance reviewer see “what was active right before the rollback” without re-walking the journal.
journal_length is the journal length after the rollback entry is appended (i.e. it includes the rollback row).
2.4. GovernanceReviewHook
export type GovernanceReviewHook = (snapshot: GovernanceReviewSnapshot) => void;
A pluggable seam. The default governance_review_hook is a no-op (§4.5). A real π verifier can be slotted in by passing an alternate implementation as the optional hook parameter to rollback.
The hook is not allowed to be async (the type is sync void). It runs after the rollback entry has been appended; it cannot veto the rollback. If it throws, the throw propagates and the journal is left in its post-rollback state.
2.5. ActivationError
export class ActivationError extends Error {
override readonly name = 'ActivationError';
constructor(message: string);
}
Thrown by every public function on:
- input shape error (e.g., wrong types),
- invariant violation (non-monotonic epoch, target_epoch <= current_epoch, apply-too-early, rollback target not found),
- journal misuse (e.g., calling
current()on an empty journal — disallowed by construction).
3. Class: ActivationJournal
3.1. Construction
new ActivationJournal(initial_version_hash: string, initial_epoch?: bigint)
initial_version_hash— the bootstrap version. A non-empty string. (Validated;ActivationErrorif empty/non-string.)initial_epoch— optional bigint. Defaults to0n. (Validated;ActivationErrorif not bigint.)
Construction appends one JournalEntry { epoch: initial_epoch, version_hash: initial_version_hash, cause: 'initial' } and freezes the entry. The journal therefore always has at least one entry.
3.2. append(entry: JournalEntry): void
- Validates:
entryis an object;entry.epochis bigint;entry.version_hashis a non-empty string;entry.cause ∈ {'initial', 'migration', 'rollback'}. (ActivationErroron shape mismatch.) - Validates monotonicity:
entry.epoch > journal.current().epoch. Strict greater than for migrations. Equal-epoch is rejected. (ActivationError“non-monotonic epoch”.) - Freezes the entry (the journal owns its own freezing — the caller’s reference may have been already frozen, which is idempotent).
- Pushes onto the internal
entriesarray.
'initial' is rejected by append if used after construction (only the constructor may insert an 'initial' entry). The first journal entry is always created via construction; later entries are 'migration' or 'rollback'.
3.3. current(): JournalEntry
Returns the last appended entry. Total — never throws because construction guarantees a non-empty journal.
3.4. at(epoch: bigint): JournalEntry
Returns the entry that was active at epoch — the entry with the largest e.epoch such that e.epoch <= epoch.
- If
epochis not bigint:ActivationError. - If
epoch < entries[0].epoch:ActivationError "no entry active at epoch < initial_epoch". (The journal does not know about pre-bootstrap history.) - Otherwise: linear scan from the end (typical lookups are recent). Returns the matching entry by reference (already frozen).
3.5. all(): readonly JournalEntry[]
Returns a frozen shallow copy of the entries array. Mutation of the returned array (e.g., .push) is rejected by the freeze; the underlying journal entries are also frozen, so deep mutation is also rejected.
The function returns a copy, not the live array, so an external caller cannot truncate the journal by clearing the returned array.
3.6. Forbidden operations
- No method may remove or mutate an existing entry. Append-only.
- No method may persist or read from disk.
- No method may use
Date,Math.random, network, or async constructs.
4. Function: scheduleActivation
4.1. Signature
export function scheduleActivation(
journal: ActivationJournal,
token: ActivationToken,
current_epoch: bigint,
): ActivationToken;
4.2. Rationale for accepting a pre-constructed token
The dispatch prompt’s source template (line 2537) suggests:
scheduleActivation(journal, new_version, target_epoch, current_epoch): ActivationToken
The CRITICAL OVERRIDE in the dispatch prompt and §3.1 of the audit make clear that the canonical 6-field ActivationToken (from migration.ts:136-143) cannot be reconstructed inside scheduleActivation: 5 of 6 fields require data only migrateRuleset has access to (scope_signature derived from declared_divergence_scope; issued_old_version derived from the old ruleset; parity_pass is the parity-pass sentinel; issued_at_epoch is captured at migration time; the migration’s current_epoch is not the same as the activation’s current_epoch).
The contract therefore takes the already-constructed token from migration.ts and validates its scheduling invariant. The function name “schedule” is preserved; semantics: “validate that this token may be scheduled, given the current runtime epoch.”
4.3. Semantics
- Validates:
journal instanceof ActivationJournal;tokenis an object with the 6 ActivationToken fields;current_epochis bigint. (ActivationErroron shape mismatch.) - Validates:
token.target_epoch > current_epoch. Strict greater than.target_epoch === current_epochis rejected because applying at exactly the same epoch races the in-flight evaluation. (ActivationError "target_epoch must be strictly greater than current_epoch".) - Returns the same
tokenreference (NOT a copy — the token is already frozen by migration.ts; passing it through preserves identity for caller equality checks).
scheduleActivation does not mutate the journal. Scheduling is a validation; application happens later via applyActivation. This separation is what lets a runtime hold a token between the “issue” instant and the “apply” instant without committing it to the journal until the activation actually fires.
4.4. Errors
| Error message | Trigger |
|---|---|
'journal must be an ActivationJournal' |
!(journal instanceof ActivationJournal) |
'token must be an object' |
token === null \|\| typeof token !== 'object' |
'token.version_hash must be a non-empty string' |
shape violation |
'token.target_epoch must be a bigint' |
typeof !== 'bigint' |
'token.issued_at_epoch must be a bigint' |
shape |
'token.parity_pass must be the literal true' |
shape |
'token.scope_signature must be a non-empty string' |
shape |
'token.issued_old_version must be a non-empty string' |
shape |
'current_epoch must be a bigint' |
typeof !== 'bigint' |
'target_epoch must be strictly greater than current_epoch (got target=<v>, current=<v>)' |
token.target_epoch <= current_epoch |
4.5. governance_review_hook (top-level)
export const governance_review_hook: GovernanceReviewHook = (_snapshot) => {
// Default no-op. π wiring lands in a later phase.
};
Exported so callers can compose: rollback(journal, v, e, true, governance_review_hook) is the default behaviour. The function name and shape are stable surface; replacing the implementation later is non-breaking.
5. Function: applyActivation
5.1. Signature
export function applyActivation(
token: ActivationToken,
journal: ActivationJournal,
current_epoch: bigint,
): void;
5.2. Semantics
- Validates:
tokenshape (same as §4.4);journalinstance;current_epochis bigint. - Validates:
current_epoch >= token.target_epoch. Greater-than-or-equal. This is the boundary case — applying atcurrent_epoch === token.target_epochis allowed; that is the earliest legal application. (ActivationError "current_epoch must be >= target_epoch".) - Constructs
JournalEntry { epoch: current_epoch, version_hash: token.version_hash, cause: 'migration' }, freezes it, and callsjournal.append(...). - Returns
undefined.
5.3. Why current_epoch is the journal entry’s epoch (not token.target_epoch)
A migration scheduled for epoch 100 may not actually apply until the runtime ticks to 100, 101, 102, … If application is delayed (e.g., the runtime was paused), the journal records the actual epoch at which the version became live. This matches the journal’s semantics: “what version was active at epoch E?” — not “what version was scheduled to be active.” Recording the scheduled epoch would lie about reality.
5.4. Errors
| Error message | Trigger |
|---|---|
| (same shape errors as §4.4) | shape violations |
'current_epoch must be >= target_epoch (got current=<v>, target=<v>)' |
current_epoch < token.target_epoch |
(propagated) 'non-monotonic epoch' |
the journal’s append rejects (e.g., current_epoch <= journal.current().epoch) |
The non-monotonic case in journal.append is the journal’s invariant; applyActivation does not pre-check it (the append is the canonical guard) so an attempt to apply at an epoch <= journal.current().epoch raises the journal’s error, not a separate one. The error message is “non-monotonic epoch” verbatim.
6. Function: rollback
6.1. Signature
export function rollback(
journal: ActivationJournal,
target_version: string,
current_epoch: bigint,
dispute_window_open: boolean,
hook?: GovernanceReviewHook,
): void;
6.2. Semantics
- Validates:
journalinstance;target_versionis non-empty string;current_epochis bigint;dispute_window_openis boolean;hook(optional) is function or undefined. - Validates:
target_versionis theversion_hashof some entryeinjournal.all()strictly prior tojournal.current(). (ActivationError "target_version not found in prior journal entries".)- “Strictly prior” means
e.epoch < journal.current().epoch. The current head is excluded — rolling back to the current version is a no-op and the contract treats it as misuse. - Multiple matches are tolerated; the existence of any prior match suffices. The journal does not need to identify which prior entry is being restored —
target_versionalone disambiguates.
- “Strictly prior” means
- Validates monotonicity:
current_epoch > journal.current().epoch. (ActivationError "non-monotonic epoch"propagated from journal.append, OR pre-checked here for clearer error context.) - Captures
prior_current_entry = journal.current()(the journal head before mutation). - Constructs
JournalEntry { epoch: current_epoch, version_hash: target_version, cause: 'rollback' }, freezes it, callsjournal.append(...). - If
dispute_window_open === true:- Builds
GovernanceReviewSnapshot { target_version, current_epoch, prior_current_entry, journal_length: journal.all().length }. (Frozen.) - Invokes
hook ?? governance_review_hookwith the snapshot. - Hook errors propagate; the journal is already updated.
- Builds
- Returns
undefined.
6.3. “Past events stand”
Rollback adds a new journal entry; it does not delete or modify any prior entry. Code that wants to know “what was the active version at epoch E?” calls journal.at(E) and gets the version that was actually active at E, not the version that would have been active had the rollback been retroactive.
This is a contract-layer guarantee. Tests verify it by:
- Creating a journal with version
vAat epoch 10. - Migrating to
vBat epoch 20. - Rolling back to
vAat epoch 30. - Calling
journal.at(25n)and asserting the result’sversion_hash === vB.
6.4. Errors
| Error message | Trigger |
|---|---|
'journal must be an ActivationJournal' |
shape |
'target_version must be a non-empty string' |
shape |
'current_epoch must be a bigint' |
shape |
'dispute_window_open must be a boolean' |
shape |
'hook must be a function or undefined' |
shape |
'target_version not found in prior journal entries' |
no prior entry has version_hash === target_version |
'non-monotonic epoch' |
propagated from journal.append (or pre-checked) |
7. Determinism
Pure module: 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 outputs for byte-identical inputs.
The governance_review_hook is exempt from this guarantee at the call site (a real hook may have side effects), but in tests, where the hook is a jest.fn() or no-op, determinism is preserved.
8. Test matrix (ties to Step 4 fixtures)
| Fixture | Coverage |
|---|---|
| F1 — Construction | (a) default initial epoch == 0n; (b) custom initial epoch; (c) reject empty initial_version_hash; (d) reject non-bigint initial_epoch; (e) initial entry is frozen; (f) initial entry has cause ‘initial’. |
F2 — append |
(a) accepts strict-greater epoch; (b) rejects equal epoch; (c) rejects lesser epoch; (d) rejects non-bigint epoch; (e) rejects non-string version_hash; (f) rejects bad cause; (g) frozen entry shape; (h) append rejects ‘initial’ cause after construction. |
F3 — current |
(a) returns last; (b) updates after append. |
F4 — at |
(a) returns initial for epoch == initial_epoch; (b) returns initial for epoch < first migration’s epoch; (c) returns migration entry; (d) returns rollback entry; (e) throws for epoch < initial_epoch; (f) throws for non-bigint. |
F5 — all |
(a) returns frozen array; (b) mutation of returned array is rejected; (c) returns shallow copy (subsequent appends do not appear in earlier returns). |
F6 — scheduleActivation happy path |
(a) target_epoch > current_epoch returns same token reference; (b) preserves all 6 fields. |
F7 — scheduleActivation rejects target == current |
per prompt fixture 1. |
F8 — scheduleActivation rejects target < current |
shape variant. |
F9 — scheduleActivation rejects shape |
(a) journal not instance; (b) token missing fields; (c) parity_pass not literal true; (d) target_epoch not bigint; (e) current_epoch not bigint. |
F10 — applyActivation rejects current < target |
per prompt fixture 2. |
F11 — applyActivation happy path |
(a) current == target succeeds; (b) current > target succeeds; (c) journal grows by exactly 1; (d) journal head’s version_hash === token.version_hash. |
| F12 — ActivationToken consumed verbatim | issue token via migrateRuleset; pass to applyActivation; assert journal.current().version_hash === token.version_hash. |
F13 — rollback happy path |
per prompt fixture 4: prior version restored, prior events still valid via at(). |
F14 — rollback invokes governance hook |
per prompt fixture 5: with dispute_window_open=true, the hook is called once with the correct snapshot. |
F15 — rollback does not invoke hook outside dispute window |
with dispute_window_open=false, the hook is not called. |
F16 — rollback rejects target_version not found |
target_version not in any prior entry. |
F17 — rollback rejects non-monotonic epoch |
per prompt fixture 6. |
F18 — rollback accepts custom hook |
passing an explicit hook parameter routes to it instead of the default. |
F19 — rollback propagates hook errors |
hook throws → journal already updated, error propagates. |
| F20 — Determinism corpus self-scan | activation.ts passes the determinism scanner. |
Total: ~50 cases across 20 fixture groups.
9. Forward coupling
P1.5.3 closes Phase 1 at 20/20. Downstream consumers (Phase 1.5+):
- θ consensus — will read
journal.at(epoch)to verify the version arbiters are signing. - ι fork-id derivation — will read
journal.current().version_hashat fork time. - π governance — will provide a real
GovernanceReviewHookimplementation; thegovernance_review_hookconst here is the seam.
These consumers may add additive surface (e.g., more methods on ActivationJournal) but must not break the existing contract.
10. Step 2 sign-off
Behavioural contract complete. The implementation in Step 4 is bound to it. The ActivationToken consumption strategy is accept-already-constructed (the migration module owns construction; this module owns scheduling/application/rollback). Step 3 (packet) follows.