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 by applyActivation. Documents an ActivationToken having been applied at runtime epoch.
  • 'rollback' — appended by rollback. 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 (see ActivationJournal.append). bigint, never number.
  • version_hash — exactly the value carried by an ActivationToken.version_hash (or the bootstrap 'initial' value). Always a 71-char 'sha256:'-prefixed hex string in production; the type is string to 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; ActivationError if empty/non-string.)
  • initial_epoch — optional bigint. Defaults to 0n. (Validated; ActivationError if 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: entry is an object; entry.epoch is bigint; entry.version_hash is a non-empty string; entry.cause ∈ {'initial', 'migration', 'rollback'}. (ActivationError on 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 entries array.

'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 epoch is 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; token is an object with the 6 ActivationToken fields; current_epoch is bigint. (ActivationError on shape mismatch.)
  • Validates: token.target_epoch > current_epoch. Strict greater than. target_epoch === current_epoch is 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 token reference (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: token shape (same as §4.4); journal instance; current_epoch is bigint.
  • Validates: current_epoch >= token.target_epoch. Greater-than-or-equal. This is the boundary case — applying at current_epoch === token.target_epoch is 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 calls journal.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: journal instance; target_version is non-empty string; current_epoch is bigint; dispute_window_open is boolean; hook (optional) is function or undefined.
  • Validates: target_version is the version_hash of some entry e in journal.all() strictly prior to journal.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_version alone disambiguates.
  • 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, calls journal.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_hook with the snapshot.
    • Hook errors propagate; the journal is already updated.
  • 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:

  1. Creating a journal with version vA at epoch 10.
  2. Migrating to vB at epoch 20.
  3. Rolling back to vA at epoch 30.
  4. Calling journal.at(25n) and asserting the result’s version_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_hash at fork time.
  • π governance — will provide a real GovernanceReviewHook implementation; the governance_review_hook const 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.