P3.4.1 — Signed Time Anchors (STA) — Step 1 Audit
1. Scope
Inventory of the surface this slice must intersect with. Per
docs/guides/implementation/task-prompts/p3.1-theta-consensus.md
§P3.4.1 (lines 1469–1645), the slice ships:
src/domains/consensus/time-anchors.tssrc/__tests__/domains/consensus/time-anchors.test.ts
with no edits to existing files. The audit therefore inventories the read-side surface this module imports from and the spec surface it must honor.
2. Spec surface inventory
| Source | Path | Authority for |
|---|---|---|
| Source prompt | docs/guides/implementation/task-prompts/p3.1-theta-consensus.md §P3.4.1 lines 1469–1645 |
API shape, defaults, semantics |
| Consensus spec | docs/spec/s06-consensus.md §Signed time anchors (line 37–39) |
“High-reputation nodes periodically broadcast … median … >30s drift … deprioritized” |
| Gossip spec | docs/spec/s08-gossip.md §Signed Time Anchors (STA) (line 94–96) |
Field shape (timestamp, epoch, signature); peer-time formula peer_time = STA_median + local_offset |
| Concept | docs/3-world/physics/laws/consensus.md §Signed time anchors (intersection) |
Cross-domain framing — λ feeds θ |
| Task breakdown | docs/guides/implementation/task-breakdown.md §P3.4.1 |
Effort sizing M (4–8h) |
The s06 + s08 spec language is mutually consistent with the prompt and is non-contradicting with the prompt’s defaults (N=7, K=10, drift_threshold 30 000 ms, replay window 10 epochs). No STOP condition.
3. Code surface inventory (read-only intersections)
3.1 P3.1.1 messages.ts (sibling, R89 Wave 1 — shipped at e63a8bcf)
Path: src/domains/consensus/messages.ts
Re-used helpers — pure functions, no I/O:
| Helper | Purpose for STA |
|---|---|
canonicalize (κ P1.5.4, transitively via canonicalSerialize) |
Body bytes for signing |
rewriteBuffersToHex |
Already encapsulated inside canonicalSerializeUnchecked — Buffer → hex pre-pass before κ canonicalize |
Ed25519 PURE-mode (RFC 8032) via node:crypto.sign(null, body, privKey) and verify(null, body, pubKey, sig) |
Used by P3.4.1’s signAnchor / verifyAnchor |
ConsensusSerializationError |
Re-exported error class — STA reuses it on canonical failures |
P3.4.1 does not import any signed/unsigned Vote, Commit, Reveal,
ViewChange, or EquivocationProof shape. STA is a NEW leaf message,
not an envelope variant of ConsensusMessage. Per the source prompt, STA
is a standalone {publisher, timestamp_ms, epoch, signature} 4-tuple.
P3.4.1 needs its own canonical-serialize helper because:
- κ canonicalize requires plain objects.
- The signing path strips
signaturebefore encoding (same pattern asstripSignatureForSigin messages.ts). - A separate path documents that STA does not flow through
ConsensusMessage’smsg_typediscriminator.
The clean approach: implement an STA-local canonical helper in time-anchors.ts that delegates to the same κ canonicalize after a hand-rolled Buffer → hex pre-pass. This mirrors P3.1.1’s pattern without exposing internal helpers across module boundaries.
3.2 P2.1.1 reputation/schema.ts (λ Phase 2 — shipped at #226)
Path: src/domains/reputation/schema.ts
Re-used reader:
export function selectReputation(
db: Database.Database,
node_id: string,
): ReputationRow[]; // overload — returns all domains for a node
export function selectReputation(
db: Database.Database,
node_id: string,
domain: Domain,
): ReputationRow | null; // overload — single (node, domain)
The “eligible publishers” criterion in §P3.4.1 reads:
top N arbiters by λ.reputation.arbitration via selectReputation(domain=”arbitration”) ORDER BY score DESC LIMIT N
This is a per-node read in the public API of schema.ts. To get a
top-N leaderboard, callers can either:
- Pass an opaque snapshot map
arbiter_id → arbitration_scoreconstructed upstream (caller’s responsibility — production wires it toidx_reputations_leaderboarddirectly, test path passes a fixture Map). - Use a new helper that queries the leaderboard index directly.
The source prompt’s “Ready-to-paste agent prompt” pins option 1 (line 1543-1547):
isEligiblePublisher(publisher_id, reputationSnapshot: ReadonlyMap<string, bigint>, top_n: bigint = 7n)
The “library-level selectReputation” wording in the dispatch packet
matches option 1 — selectReputation is the public reader; an STA
caller constructs the snapshot Map by walking arbiters they know about
(or by a leaderboard query that lives outside this module’s surface).
Library-level means “this module reads at the schema-level via
selectReputation-derived snapshot, not via an MCP roundtrip” — it does
NOT mean “this module embeds a SELECT statement”.
Decision (recorded in contract §3): P3.4.1 takes a snapshot Map as input,
keeping time-anchors.ts pure (no DB handle). A separate convenience
helper that internally calls selectReputation for every arbiter in a
known set is over-engineering for Wave 2; the test corpus exercises it
with a fixture Map directly. This keeps the slice fully pure and unit
testable without a SQLite handle.
Index reference: idx_reputations_leaderboard ON reputations(domain, score DESC)
exists at src/db/migrations/007_reputation.sql:58. Future production
callers will use it; this Wave 2 slice does not.
3.3 BPS constants (κ Phase 1)
Path: src/domains/rules/bps-constants.ts
Not directly imported. STA’s reputation scores are read as opaque
bigints from the snapshot Map — bps-bound enforcement is λ’s
responsibility, already validated at insert time by ReputationRowSchema.
4. Existing tests touching consensus domain
Path: src/__tests__/domains/consensus/
| File | Purpose | Intersect |
|---|---|---|
messages.test.ts |
P3.1.1 vote-message coverage | Adjacent — no symbol overlap |
No other consensus tests exist. The P3.4.1 test file is a new file at
src/__tests__/domains/consensus/time-anchors.test.ts. No existing
suites need updating.
Forbidden-token scanner in messages.test.ts walks only messages.ts —
it does NOT need to be extended for time-anchors.ts. P3.4.1 ships its
own narrow scanner (per the κ-style discipline P3.1.1 established).
5. Conventions inventory (must conform to)
| Convention | Source | Applied to STA |
|---|---|---|
All quantities as bigint |
docs/architecture/decisions/ADR-008-bigint-arithmetic.md |
timestamp_ms, epoch, defaults |
| No wall-clock reads for signing | P3.1.1 module docstring §5 / s06 §Signed time anchors final clause | Tests use seeded clock; signing accepts an injected timestamp_ms |
No Math.* / Math.random |
κ determinism § | Median uses bigint arithmetic; no Math.floor |
| No floats | κ § | All math is bigint |
| Buffer → hex in canonical body | messages.ts §6 | Same pattern repeated |
Ed25519 PURE-mode (sign(null, body, priv)) |
messages.ts §8 | Same pattern repeated |
Named-import crypto (import { sign, verify } from 'node:crypto') |
messages.ts §2 / messages.test.ts §Group 9 | Same; STA scanner enforces |
Error class hangs cause chain |
messages.ts §3 | STA reuses ConsensusSerializationError |
| Defaulted parameters for π-governable constants | source prompt §Common gotchas (line 1645) | top_n = 7n, k_epochs = 10n, threshold_ms = 30000n, replay_window = 10n |
6. Risks identified
| Risk | Mitigation |
|---|---|
Even-count median floor — (a+b)/2 could surprise on bigint division |
bigint division IS already floor in TS — (1000n + 1010n) / 2n === 1005n. Explicit test case for even count. |
| Single-arbiter edge — top_N filter must accept the single publisher trivially | Test fixture: 1-element Map; isEligible returns true; median = sole anchor’s timestamp |
| Monotonicity false positives — same epoch, identical timestamp by same publisher | Spec wording is epoch_{n+1} > epoch_n AND timestamp_{n+1} >= timestamp_n — strict-> on epoch, non-decreasing on timestamp. Same epoch is rejected as a non-advance, not a violation. The function flags only the violation case (strict-> on epoch but ts decreasing). |
| Replay protection wording — “epoch < current_epoch - 10” vs. “epoch < current_epoch - 10n” — must be bigint subtraction | Use current_epoch - 10n literal in code |
| Drift “deprioritized” semantics — return must be advisory; no reject | Return type is "ok" \| "deprioritized" literal union, NEVER a throw or null |
| sort stability for top-N — JS Array.sort is stable on Node ≥ 12 | Documented in code comment |
| ConsensusSerializationError import — re-use vs. re-export | Re-use via direct import; no re-export at this slice |
7. Acceptance criteria mapping (forward-link to contract)
Source-prompt acceptance criteria (lines 1490–1500) crosswalk to contract invariants (Step 2):
| AC | Invariant ID | Realized by |
|---|---|---|
STA shape {publisher, timestamp_ms, epoch, signature} |
I1 | STA interface in time-anchors.ts |
| Eligible publishers — top N by reputation.arbitration | I2 | eligiblePublishers(snapshot, n) |
Anchor signature — Ed25519 over canonical (publisher, timestamp_ms, epoch) |
I3 | signAnchor / verifyAnchor |
| Median computation — last K epochs, equal weight per publisher | I4 | computeMedian(anchors, k_epochs, current_epoch) |
Drift detection — |local − median| > 30s → “deprioritized” (advisory, no reject) |
I5 | detectDrift(local, median, threshold_ms) |
| Monotonicity per publisher — epoch_{n+1} > epoch_n AND ts_{n+1} >= ts_n | I6 | validateMonotonicity(anchors_per_publisher) |
| Replay protection — epoch < current_epoch - 10 rejected | I7 | rejectReplay(anchor, current_epoch) |
| Even-count median — floor of (a+b)/2 | I4 (sub-clause) | bigint (a + b) / 2n |
| Tests use seeded clock | I8 | Test posture |
8. File-level outcome
Files to be created: 2 (matches source prompt §Files to create)
| File | Lines (estimate) |
|---|---|
src/domains/consensus/time-anchors.ts |
~400 |
src/__tests__/domains/consensus/time-anchors.test.ts |
~450 |
Files to be modified: 0
Existing public surfaces touched: 0 (no exports renamed or removed)
9. Greenlight
Audit signoff: every spec source agrees on shape, defaults, and semantics. λ Phase 2 dependency is shipped at #226. P3.1.1 patterns are clear and re-usable. No STOP condition. Proceed to Step 2 (contract).