P3.4.1 — Signed Time Anchors (STA) — Behavioural Contract

1. Module name & path

src/domains/consensus/time-anchors.ts — a pure TypeScript module that exposes the θ Signed Time Anchor wire shape plus seven helpers (signAnchor, verifyAnchor, canonicalSerializeAnchor, eligiblePublishers, computeMedian, detectDrift, validateMonotonicity, rejectReplay). No I/O. No DB. No network. No async. No wall-clock reads. No Math.*.

2. Public surface (exact)

// Type
export interface STA {
  readonly publisher: string;
  readonly timestamp_ms: bigint;
  readonly epoch: bigint;
  readonly signature: Buffer;        // 64-byte Ed25519 PURE signature
}

export type DriftStatus = 'ok' | 'deprioritized';

export interface MonotonicityFault {
  readonly publisher: string;
  readonly prev_epoch: bigint;
  readonly prev_timestamp_ms: bigint;
  readonly next_epoch: bigint;
  readonly next_timestamp_ms: bigint;
}

// Functions
export function canonicalSerializeAnchor(anchor: STA): Buffer;
export function signAnchor(
  publisher: string,
  timestamp_ms: bigint,
  epoch: bigint,
  privateKey: KeyObject,
): STA;
export function verifyAnchor(anchor: STA, publicKey: KeyObject): boolean;
export function eligiblePublishers(
  reputationSnapshot: ReadonlyMap<string, bigint>,
  top_n?: bigint,          // default 7n
): ReadonlySet<string>;
export function isEligiblePublisher(
  publisher_id: string,
  reputationSnapshot: ReadonlyMap<string, bigint>,
  top_n?: bigint,          // default 7n
): boolean;
export function computeMedian(
  anchors: readonly STA[],
  current_epoch: bigint,
  k_epochs?: bigint,       // default 10n
): bigint | null;
export function detectDrift(
  local_clock_ms: bigint,
  sta_median: bigint,
  threshold_ms?: bigint,   // default 30_000n
): DriftStatus;
export function validateMonotonicity(
  anchors_per_publisher: ReadonlyMap<string, readonly STA[]>,
): readonly MonotonicityFault[];
export function rejectReplay(
  anchor: STA,
  current_epoch: bigint,
  replay_window?: bigint,  // default 10n
): boolean;

All exports are real values or types; no re-exports. The module also exports a re-throw of ConsensusSerializationError from ./messages.js — re-using one error class across the consensus domain keeps consumers’ catch-blocks narrow.

3. Behavioural invariants

I1. STA shape

{publisher: string, timestamp_ms: bigint, epoch: bigint, signature: Buffer} — exact field set; readonly on every field. signature is the 64-byte output of Ed25519 PURE-mode (RFC 8032) on the canonical body. No other fields, no Lamport clock (STA names physical milliseconds explicitly per source prompt §Common gotchas line 1635).

I2. eligiblePublishers — top-N by reputation

eligiblePublishers(snapshot, n):

  • Sort entries DESC by score (bigint comparison).
  • Take the first N entries.
  • Return their publisher_ids as a ReadonlySet<string>.
  • Default n = 7n.
  • If snapshot is empty: returns empty set.
  • If n >= snapshot.size: returns every publisher (single-arbiter is the degenerate case — sole publisher is trivially in top-7).
  • Stable on ties: JS Array.sort is stable on Node ≥ 12 (Array.prototype.sort was guaranteed stable in ES2019). Where two publishers share a score, the original Map iteration order is preserved.

isEligiblePublisher(pubId, snapshot, n) is a Boolean convenience — returns true iff pubId ∈ eligiblePublishers(snapshot, n). Snapshot lookup is O(N log N) for the sort once, then O(1) for the membership check. (We accept the redundant sort over caching for purity.)

I3. signAnchor / verifyAnchor — Ed25519 PURE over canonical body

signAnchor(publisher, timestamp_ms, epoch, privKey):

  1. Build a stripped record {publisher, timestamp_ms, epoch} (no signature field).
  2. canonicalSerialize → UTF-8 bytes via κ canonicalize (with Buffer → hex pre-pass — Buffers are absent here but the pre-pass is the shared path).
  3. Ed25519 PURE-mode sign — sign(null, body, privKey).
  4. Return {publisher, timestamp_ms, epoch, signature}.

verifyAnchor(anchor, pubKey):

  1. Build stripped record (deep clone, delete signature).
  2. canonicalSerialize.
  3. verify(null, body, pubKey, anchor.signature).
  4. Return the boolean. Never throws on bad signature; throws only on malformed body (canonical-encoding failure → ConsensusSerializationError).

Determinism: identical (publisher, timestamp_ms, epoch) produces byte-identical canonical body in every Node ≥ 20 process. Inherits κ P1.5.4’s full determinism guarantee. Two calls in the same process return signatures that compare equal byte-for-byte (Ed25519 PURE-mode is fully deterministic).

I4. computeMedian — bigint median over filtered anchors

computeMedian(anchors, current_epoch, k_epochs = 10n):

  1. Filter anchors:
    • Drop anchors with epoch < current_epoch - 10n (replay; tighter than the K window so replay protection is always on, even if K > 10).
    • Drop anchors with epoch < current_epoch - k_epochs.
    • Drop anchors with epoch > current_epoch (future-dated, defensive).
  2. Group remaining anchors per publisher; if a publisher has multiple anchors in the window, take the MOST RECENT (max epoch; tie-break on max timestamp_ms). Per-publisher monotonicity violations within the window cause the entire publisher’s contribution to be dropped (the median is over honest-publisher contributions only).
  3. Sort the per-publisher timestamps ascending.
  4. If list is empty → return null.
  5. If list length is odd → return middle element.
  6. If list length is even → return (left_mid + right_mid) / 2n (bigint division = floor).

Equal weight per publisher: the per-publisher max-anchor step ensures each publisher contributes exactly one timestamp to the median set.

I5. detectDrift — advisory, no reject

detectDrift(local, median, threshold_ms = 30_000n):

  • Compute delta = local - median if local >= median else median - local (bigint absolute value via subtraction without Math.abs).
  • If delta > threshold_ms → return 'deprioritized'.
  • Otherwise → return 'ok'.

Threshold is strict. A delta of exactly 30_000n is 'ok'. A delta of 30_001n is 'deprioritized'. Mirrors source-prompt test fixture (lines 1564-1565).

Never throws; never returns null; never has side effects.

I6. validateMonotonicity — soft fault flagging

validateMonotonicity(anchors_per_publisher):

  • For each publisher’s list, sort ascending by epoch.
  • Walk consecutive pairs (prev, next).
  • Flag a fault iff next.epoch > prev.epoch AND next.timestamp_ms < prev.timestamp_ms.
  • Same epoch is not a violation (it’s a non-advance; the caller can dedupe upstream).
  • Return the list of faults in order of detection. Empty list means all publishers’ anchor sequences are monotonic.

The function does NOT mutate input. The function does NOT reject the publisher — that’s the responsibility of the caller (typically the gossip / λ penalty layer).

I7. rejectReplay — boolean gate

rejectReplay(anchor, current_epoch, replay_window = 10n):

  • Return true iff anchor.epoch < current_epoch - replay_window.
  • Return false otherwise.
  • Replay window is exclusive: at current_epoch = 100n, an anchor at epoch = 90n is kept (100n - 10n = 90n, 90n < 90n is false); an anchor at epoch = 89n is rejected.

The function is purely arithmetic — bigint subtraction. No I/O.

I8. Determinism (κ-inherited)

  • All quantities are bigint (zero floats per ADR-008).
  • No Math.* or Math.random reference in module body.
  • No Date.now(), process.hrtime, performance.now, or any wall-clock read in module body.
  • All randomness comes from caller-supplied private keys (Ed25519 PURE is deterministic — no per-signature nonce — so a given (publisher, timestamp_ms, epoch, privKey) always yields the same signature).
  • Module-level state is zero. The Lamport counter from messages.ts is not imported (STA timestamps are physical, not logical).

I9. Error model

  • Bad input shape → ConsensusSerializationError (re-thrown from κ canonicalize).
  • Bad signature on verify → false return (no throw).
  • Empty median input → null return (no throw).
  • Empty publishers snapshot → Set<string> of size 0 (no throw).
  • All other invariants are not throw conditions — they are explicit return values (DriftStatus, boolean, fault list).

4. Forbidden tokens (κ-style scanner — replicated in test §Group 9)

Same posture as P3.1.1 messages.test.ts §Group 9. The test file scans the source body of time-anchors.ts (JSDoc and frontmatter stripped) for the following patterns and FAILS if any are found:

Pattern Why forbidden
Date.now Wall-clock read
process.hrtime Wall-clock read
performance.now Wall-clock read
Math.random Non-determinism
Math.floor / Math.ceil / Math.round Floating-point math; use bigint
crypto.sign( (dotted form) Must use named import
crypto.verify( Must use named import
setTimeout / setInterval Side effects
console. I/O
import * as crypto Wildcard crypto import

Named imports (import { sign, verify, createHash } from 'node:crypto') are permitted. The scanner strips JSDoc comments and template-string contents before checking, so describing forbidden tokens in this contract is safe; describing them in JSDoc inside the source file is also safe.

5. Re-use vs. duplication boundary

P3.1.1 messages.ts ships a private rewriteBuffersToHex and canonicalSerializeUnchecked — both function declarations, no export. P3.4.1 cannot import them. The clean choice (per Step 1 audit §3.1): duplicate the minimal canonical pipeline locally in time-anchors.ts.

What IS re-used from messages.ts:

  • ConsensusSerializationError (named import + re-export at top).

What is NOT re-used (duplicated for module boundary):

  • rewriteBuffersToHex — STA fields contain a single Buffer (signature) which is always stripped before signing; the duplicate helper is one tight if-tree.
  • canonicalSerializeUnchecked — STA’s canonicalSerializeAnchor inlines the κ-canonicalize + Buffer rewrite directly.

The duplication is intentional. Cross-module helpers in src/domains/consensus/internal.ts are a Wave 3+ refactor; Wave 2 ships two independent slices that share ConsensusSerializationError only.

6. Defaults rationale (π-governable)

Per source prompt §Common gotchas (line 1645): “The 30000ms constant is governable via π — leave it as a defaulted parameter, not a hardcode.”

Applied to every magic number:

Default Value Governable by
top_n 7n π (governance — tune cohort size)
k_epochs 10n π (governance — tune freshness window)
threshold_ms 30_000n π (governance — tune drift sensitivity)
replay_window 10n π (governance — tune replay tolerance)

All four are TYPED bigint and exposed as optional last positional args with ?? fallback inside the function body.

7. Tests required (Step 5 evidence)

Per source prompt §Files to create + §Ready-to-paste agent prompt test list (lines 1556–1567):

ID Test Function
T1 Sign + verify STA roundtrip signAnchor + verifyAnchor
T2 Verify fails on tampered signature verifyAnchor
T3 Verify fails on tampered timestamp verifyAnchor
T4 Sign deterministic — same input → same signature signAnchor
T5 Eligible publisher — top 7 of 10 by score eligiblePublishers
T6 isEligiblePublisher — pos / neg with rep=3 (in) and rep=2 (out) isEligiblePublisher
T7 Eligible — single arbiter trivially in top 7 eligiblePublishers
T8 Eligible — empty snapshot → empty set eligiblePublishers
T9 Eligible — tie-break: stable order on equal scores eligiblePublishers
T10 Median odd — 5 timestamps (1000…1040 ms) → 1020 computeMedian
T11 Median even — 4 timestamps (1000…1030 ms) → 1015 computeMedian
T12 Median empty → null computeMedian
T13 Median equal-weight per publisher — duplicate publisher dropped to most recent computeMedian
T14 Median replay-filter — anchor at current - 11 epoch excluded computeMedian
T15 Drift — local = 1_000_000n, median = 1_029_999n → ok detectDrift
T16 Drift — local = 1_000_000n, median = 1_030_001n → deprioritized detectDrift
T17 Drift — exactly at threshold → ok (strict-greater) detectDrift
T18 Drift — negative skew (local > median) → deprioritized detectDrift
T19 Monotonicity — single publisher, two anchors with epoch++ but ts−− → 1 fault validateMonotonicity
T20 Monotonicity — single publisher, two anchors with epoch++ and ts++ → no fault validateMonotonicity
T21 Monotonicity — same epoch, ts−− → no fault (non-advance, not violation) validateMonotonicity
T22 Monotonicity — multi-publisher mix → faults flagged per publisher validateMonotonicity
T23 Replay — epoch < current - 10 rejected rejectReplay
T24 Replay — epoch = current - 10 kept (boundary) rejectReplay
T25 Replay — epoch = current kept rejectReplay
T26 Replay — future-dated epoch (> current) handled per caller policy (this function only rejects past replay) rejectReplay
T27 Seeded-clock posture — all tests inject timestamps; production never reads wall clock (forbidden-token scanner)
T28 Forbidden-token scanner walks time-anchors.ts body (static scanner)
T29 Canonical body byte-identical across two Node processes — covered by single-process determinism check + κ test  
T30 ConsensusSerializationError re-exported import shape

Target: ~30 tests in time-anchors.test.ts. Test delta over the baseline 2687 should be ≈ +30. Each test contributes one Jest test() or it() plus optional shared beforeEach setup.

Every contract invariant maps to:

  • A function in time-anchors.ts (per §2 surface).
  • At least one test in §7.

Step 5 verification will cross-reference both.

9. Greenlight

Contract is complete and consistent with §P3.4.1 source. Defaults are named, types are nailed, error model is bounded, determinism is declared. Proceed to Step 3 (packet).


Back to top

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

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