diff --git a/docs/adr/0018-application-logic-as-smart-contracts.md b/docs/adr/0018-application-logic-as-smart-contracts.md new file mode 100644 index 0000000..3cf8384 --- /dev/null +++ b/docs/adr/0018-application-logic-as-smart-contracts.md @@ -0,0 +1,114 @@ +# 0018. Deploying application logic as smart contracts on this framework + +- Status: Proposed +- Date: 2026-06-20 + +## Context + +This project is, structurally, a small **smart-contract execution substrate**: +application logic is expressed as `Command` types (`consensus/types.ts`) that flow +through an ordered, replicated log and are applied by a deterministic state +machine (`stateMachine.ts`), with a tamper-evident hash-chained audit trail +(`replicatedStateMachine.ts`) and exactly-once semantics. That is the same shape +as "deterministic state transitions over an append-only, agreed-upon log" that a +contract platform provides. + +A recurring question is therefore whether a conventional ("formal") API server's +application logic should be deployed *as smart contracts on this framework* — +i.e. modelling each mutating endpoint as a `Command` variant + an `apply()` case ++ a leader-side builder (`models/book.ts`), instead of an imperative handler over +a database. This ADR records the analysis and the conditions under which that is +(and is not) the right model. + +The decisive fact shaping the whole analysis: **Raft is Crash-Fault-Tolerant +(CFT), not Byzantine-Fault-Tolerant (BFT).** It assumes peers are honest but may +crash; it does not tolerate malicious participants. The framework gives the +*form* of smart contracts (deterministic, replicated, audited execution) without +the *substance* of trustlessness (agreement among mutually-distrusting parties). +The "trustless agreement" framing in `docs/PHILOSOPHY.md` is aspirational in this +sense. + +## Decision + +Characterize the framework as a **CFT replicated-state-machine contract +substrate, scoped to a single trust domain**, and adopt the following guidance +for modelling application logic as contracts on it: + +- **Recommended** where the goal is replication + tamper-evidence + exactly-once + within *one organization that controls all nodes*: compliance ledgers, + financial-ops journals, inventory/settlement, audit-critical CRUD. This is + effectively "event-sourcing + Raft + a hash-chained audit," which the codebase + already demonstrates. +- **Not recommended** where the motivation is trust-minimization across + distrusting parties, public/permissionless participation, adversarial node + operators, large state, or logic requiring external I/O. Those are a category + mismatch for CFT consensus; use an actual BFT/blockchain platform. + +When logic *is* modelled this way, it must respect the existing invariants: +determinism in `apply()` (ADR-0003), snapshot-relative indexing, leader-resolved +non-determinism, and side effects kept off the apply path (leader/edge "oracle" +pattern). + +## Consequences + +### Positive + +- **The execution model is already enforced.** Ordered, replicated, reproducible + state transitions come for free; the `apply()` switch is exhaustiveness-checked + and non-determinism is pushed to leader-side builders. +- **Tamper-evident history = on-chain ledger.** `GET /audit` / `/audit/verify` + give a verifiable, replicated execution log per call, with no extra + event-sourcing infrastructure. +- **Exactly-once semantics = transaction nonce.** A replayed `requestId` returns + the cached result, preventing double-application of stateful operations. +- **Fault tolerance with no central DB.** Logic and state survive a minority of + node failures; there is no single database to lose. +- **Determinism forces clean architecture.** Pure state transitions separated + from effects mirror smart-contract best practice and are trivially unit-testable. +- **Cheaper and faster than a real blockchain.** No PoW/PoS, no gas market, no + global network; sub-second commits and optional linearizable reads. + +### Negative + +- **Not trustless / not BFT — the dealbreaker if trust-minimization is the goal.** + The leader builds every command and could forge `actor`/`timestamp`/`id`; + followers largely trust it. Any operator running modified `apply()` code + diverges silently. The `matchIndex` clamp in `replicateTo()` is the only + adversarial hardening. +- **Determinism is a convention, not a sandbox.** Nothing in the runtime prevents + `Date.now()`/`Math.random()` inside `apply()`; it would silently diverge + replicas (the worst class of bug). No metering, no static guarantee. +- **No gas / no resource metering.** A looping or over-allocating command blocks + the single-threaded apply path on every node, with no bound — it can halt the + cluster. +- **Mutable logic undermines immutability.** Deployed TypeScript can be changed + and redeployed across a restart. The audit chain records *that* a command ran + and its result, but not *which code version* produced it — "tamper-evident" + covers the data, not the logic. +- **Determinism excludes most real API work.** No external calls, randomness, or + wall-clock in `apply()`. Payment gateways, email, third-party reads need an + off-chain/leader-side oracle pattern — the genuinely awkward part of contract + development. +- **Single-leader writes + full replication.** Mutating calls serialize through + one leader (no parallel execution); every node holds the full in-memory state + and an unbounded audit log. Same scaling pain as a full blockchain node. +- **Schema/state migration friction.** State serializes as `RsmSnapshot`; + evolving its shape requires careful snapshot-format migration or `restore()` + breaks. + +## Alternatives considered + +- **Keep the conventional API-server model (imperative handlers over a shared + database).** Simpler for arbitrary side-effecting logic and large state, but + re-centralizes on one store and forfeits the replication, tamper-evident audit, + and exactly-once guarantees the substrate provides — the very properties that + motivate the contract framing. +- **Deploy on an actual BFT / smart-contract platform (EVM, Cosmos/Tendermint, + Hyperledger Fabric).** The right choice when trust-minimization across + distrusting parties is required, with structural determinism (sandbox), gas + metering, and code-committing immutability. Rejected as default here because it + is far heavier and unnecessary within a single trust domain, where CFT + audit + already meets the need at a fraction of the cost. +- **Hybrid: model only audit-critical, deterministic core logic as contracts and + keep side-effecting logic at the edge.** The pragmatic middle ground implied by + the guidance above; preferred over an all-or-nothing migration. diff --git a/docs/adr/0019-native-backend-blockchain-benefits.md b/docs/adr/0019-native-backend-blockchain-benefits.md new file mode 100644 index 0000000..d1c740f --- /dev/null +++ b/docs/adr/0019-native-backend-blockchain-benefits.md @@ -0,0 +1,236 @@ +# 0019. Bringing blockchain benefits to backend development natively + +- Status: Proposed +- Date: 2026-06-20 + +## Context + +ADR-0018 concluded that this framework provides the *form* of smart contracts +(deterministic, replicated, audited execution) but not the *substance* of +trustlessness, and is best scoped to a single trust domain (CFT, not BFT). It +also enumerated the gaps that make "application logic as contracts" awkward for +real backends: determinism is a convention not a guarantee, there is no story for +external I/O, the single leader and in-memory state limit scale, the audit proves +data but not logic, and there is no resource bound. + +This ADR answers the follow-up: **how can the framework deliver blockchain +benefits (CFT availability, exactly-once, tamper-evident/provable audit, +deterministic replay) natively in everyday backend development, without +compromising the fundamental principles of backend systems** (developer +productivity, side effects / external I/O, rich queries and large datasets, +horizontal scale, schema evolution, a standard API contract, security and +observability)? + +The decisive insight: backends are inherently *effectful* while a replicated +state machine must be *pure and deterministic*. Rather than forcing backends to +become pure, **split the system in two and connect the halves with the log**: + +``` + Effectful EDGE Deterministic CORE Effectful EDGE (out) + (oracle resolution, → (consensus + audit + → (committed-intent + auth, validation) exactly-once, state) executor / outbox) + │ + ▼ + Read projections (CQRS) + rich queries, big data +``` + +The deterministic core gets the blockchain properties; the edges do the real +backend work; the committed log is the exactly-once, auditable boundary between +them. The existing seams (`Command`/`apply()`, the `Transport` and `RaftStorage` +interfaces, leader-side builders, `CommandMeta`, the audit chain, snapshots) are +the foundation this builds on. + +## Decision + +Adopt a layered "deterministic core + effectful edge" architecture and evolve the +framework along the following pillars. Each closes a specific ADR-0018 gap while +preserving a backend principle. + +1. **Module SDK (developer productivity).** Replace the four-touchpoint workflow + (extend the `Command` union, add an `apply()` case, add a leader-side builder, + wire a route) with one declarative unit: `defineModule({ state, commands, + queries, effects })`. Developers write **pure reducers** plus a schema; the + framework generates the namespaced command variants, the `apply()` dispatch, + REST routes + OpenAPI, and validation. The REST surface stays standard so + clients remain unaware they talk to a consensus cluster. + +2. **Determinism as a guarantee, not a convention.** Inject a deterministic `ctx` + (`ctx.now`, `ctx.random()`, `ctx.id()`) into every reducer, resolved on the + leader and baked into `CommandMeta` before the entry enters the log + (generalizing what `models/book.ts` already does for ids/timestamps). Run + reducers in a sandbox with frozen globals shadowing `Date`/`Math.random`/ + `crypto`, add a lint rule, and add a CI check that replays the log on two + nodes and diffs state hashes. + +3. **Effects and external I/O via committed intents (the unblocker).** Reducers + never perform I/O; they **return declarative effect intents** that commit to + the log as part of the deterministic result. After commit, a single designated + executor (leader or leased worker) performs the effect exactly-once keyed by + `requestId`, then feeds the outcome back as a new command. Consequences: the + replicated log *is* a transactional outbox (exactly-once, for free), and + inbound external data is resolved on the leader before entering the log + (leader-as-oracle), keeping replicas identical. + +4. **Scale without compromising consensus.** + - *Reads / rich queries / big data:* project the committed log into a queryable + read model (CQRS) — a derived, rebuildable cache, not a central database, so + ADR-0002 still holds and the log remains source of truth. + - *Writes:* partition into multiple Raft groups (multi-Raft), one per keyspace/ + aggregate, each with its own leader; cross-shard operations use a saga or 2PC + over groups. Small services stay single-group. + - *State > RAM:* swap the in-memory `Map` for a pluggable embedded LSM/KV store + behind the same interface (the `RaftStorage` seam); snapshots become store + checkpoints. + +5. **Auditability upgraded to real tamper-evidence.** Replace the linear hash + chain with a Merkle tree/accumulator (O(log n) inclusion proofs and a compact + root that can be externally anchored), and record a hash of the deployed module + code in a `DEPLOY` log entry referenced by each command's meta — so the audit + proves *which logic version* produced each result, closing ADR-0018's + data-but-not-logic gap. + +6. **Resource safety (gas analog).** Bound reducer execution with a deterministic + step/instruction budget (count operations, not wall-clock), validated on the + leader so over-budget commands are rejected before entering the log, plus input + size limits at the edge. No gas market — just a guard against a runaway command + halting the single-threaded apply path on every node. + +7. **Stay CFT, add accountability cheaply.** Sign commands with the originating + actor's key so the leader cannot forge `actor` in `CommandMeta`. Keep full BFT + (PBFT/Tendermint) as an optional swap behind the consensus seam, only if + multi-party trustlessness is ever required — not paid for by default. + +### Phased roadmap + +1. Determinism runtime + module SDK (pillars 1–2) — biggest DX/safety win, no + architecture change. +2. Effect intents + post-commit executor/outbox (pillar 3) — unblocks real + backend use cases. +3. CQRS read projections + pluggable state store (pillar 4) — queries and big data. +4. Merkle audit + committed code version + signed commands (pillars 5, 7) — real + tamper-evidence. +5. Multi-Raft sharding + step budget (pillars 4, 6) — scale and safety hardening. + +## Consequences + +### Positive + +- Blockchain benefits — CFT availability, exactly-once, provable/tamper-evident + audit with committed code versions, deterministic replay, a built-in outbox — + become available through ordinary-looking backend code. +- Each fundamental backend principle is preserved: productivity (module SDK), + side effects (committed intents), rich queries/big data (CQRS + pluggable + store), horizontal scale (read replicas + multi-Raft), schema evolution + (logged, auditable `DEPLOY` migrations), standard API contract (unchanged REST), + security/observability (kept at the edge, off the deterministic path). +- Builds on existing abstractions rather than replacing them; several pillars are + additive against current seams. + +### Negative + +- Materially larger surface area: a module runtime/sandbox, an effect executor, + projection infrastructure, multi-Raft routing, and a Merkle audit are each + non-trivial subsystems — well beyond a POC's current scope. +- The committed-intent/outbox model adds eventual-consistency semantics for + side effects (an effect runs after commit, asynchronously), which application + authors must reason about. +- Multi-Raft introduces cross-shard transactions (saga/2PC), the hardest part of + distributed data systems. +- Sandboxing and step-budgeting add per-command overhead and constrain what + reducer code may do. +- Signed commands and Merkle proofs add cryptographic machinery and key + management that the current trust-domain-internal model does without. + +## Alternatives considered + +- **Keep the framework as a focused POC and document the gaps only (ADR-0018).** + Lowest effort, but leaves the framework unusable for mainstream backend work; + this ADR is the constructive counterpart. +- **Adopt an existing platform instead of evolving this one** — an event-sourcing + framework (Axon, EventStoreDB) for the core, or a permissioned ledger + (Hyperledger Fabric) for the contract/audit properties. Pragmatic, but forfeits + the single-substrate integration (consensus + audit + exactly-once + REST in one + place) that is this project's thesis, and Fabric pays BFT-class cost unneeded in + a single trust domain. +- **All-at-once rewrite rather than the phased roadmap.** Higher risk; the phases + are independently valuable and each ships a usable increment, so incremental is + preferred. +- **Make backends pure instead of splitting core/edge.** Rejected outright: + forbidding external I/O contradicts the fundamental nature of backend systems; + the core/edge split is precisely what avoids that compromise. + +## Prototype status (2026-06-20) + +A working prototype lives under `src/runtime/` (see +[`src/runtime/README.md`](../../src/runtime/README.md)), built and reviewed in +seven incremental milestones. The first three are pure runtime layered on top of +the consensus core; M4–M7 wire it into real Raft and harden it. + +- **M1 — Module SDK + deterministic runtime (pillars 1–2).** `defineModule`, a + `ModuleHost` that dispatches commands to pure reducers, and a deterministic + `ReducerContext` (`now`/`random`/`id`) derived entirely from a leader-resolved + `Seed`. A convergence test proves two independent hosts fed the same seeded + command stream reach byte-identical state. +- **M2 — Committed-intent effects + outbox executor (pillar 3).** Reducers emit + declarative `EffectIntent`s into a deterministic outbox; a post-commit + `EffectExecutor` runs each effect and feeds the result back as a committed + `EffectResultEntry`. Exactly-once at the state level is enforced by outbox + dedup + idempotent result application + an in-flight guard; handler execution + is honestly at-least-once. +- **M3 — Merkle audit + committed code version (pillar 5).** A Merkle + accumulator with O(log n) inclusion proofs and a domain-separated root, plus a + per-command module code-version hash so the audit proves which logic version + produced each result. +- **M4 — Consensus wiring (pillars 1–3 end-to-end).** The runtime plugs into the + pluggable `StateMachine` seam (ADR-0017): a `ModuleStateMachine` adapts the + `ModuleHost` so a `ModuleAppCommand` (carrying a leader-resolved seed) is an + ordinary application command — no bespoke consensus-core support. A 3-node + cluster converges on identical module state *and* Merkle audit root, with + substrate idempotency and the seed flowing through the log. +- **M5 — Determinism enforcement + resource bounds (pillars 2, 6).** A static + determinism lint rejects reducers that touch non-deterministic globals + (`defineModule` strict-by-default), and a deterministic `413` resource bound + caps effect count and result size — rejected uniformly on every replica. A + shared canonical serializer removes the audit/size-bound drift hazard. +- **M6 — CQRS read projections (pillar 4, reads).** A derived, rebuildable read + model folded from the committed command stream, answering indexed queries the + raw module state doesn't — strictly off the apply path; `rebuild()` proves it + is a cache, not a source of truth (ADR-0002 upheld). +- **M7 — Signed commands (pillar 7).** Module commands are ed25519-signed by the + originating actor over the *logical* payload (not the leader's seed); every node + verifies on the apply path against an actor→key registry, so a forged `actor` is + rejected `401` identically on all nodes. Opt-in and fully backward-compatible. +- **M8 — Pluggable key-oriented state store (pillar 4, state > RAM).** A + `StateStore` abstraction (in-memory default; the seam a persistent embedded + KV/LSM drops into) plus a transactional keyed module model (`defineKeyedModule`) + where reducers touch individual records through a copy-on-write `StoreView` — + only the touched keys in memory per command. Sorted iteration + clone-on-read + keep it deterministic (a cross-incidental-order test proves the store converges + regardless of apply order of independent commands); keyed and whole-state + modules coexist in snapshot/restore. Additive — the consensus core is untouched. +- **M9 — `vm` determinism sandbox + step meter (pillars 2, 6).** Opt-in + (`defineModule(def, { sandbox: true })`) execution of whole-state reducers inside + a Node `vm` context that enforces determinism by *removal* — `Date`, timers, + `crypto`, `Intl` and the locale-sensitive prototype methods are deleted/thrown, + so a careless reducer reaching for ambient non-determinism throws instead of + silently diverging replicas. The apply path runs the sandbox timeout-free + (deterministic); a leader-side `admit()` dry-runs with a `vm` wall-clock timeout + to reject runaways before they enter the log (the gas/CFT model). Honestly + scoped: `vm` is a determinism aid, not a security boundary. +- **M10 — Multi-Raft sharding (pillar 4, write scaling; see ADR-0020).** Multiple + independent Raft groups (shards) with a deterministic `sha256(key) mod N` shard + router, so writes to different shards commit in parallel and state is + partitioned. Cross-shard transactions use a saga (try/compensate) — atomicity by + compensation, not isolation; a throwing compensation is surfaced + (`compensationFailures`), not hidden. Additive: each shard is an ordinary + single-group cluster, so the consensus core is untouched. + +**Remaining frontier:** an optional **BFT consensus swap** (pillar 7) for +multi-party trustlessness beyond the single trust domain. This is a protocol-level +replacement (different messages, `3f+1` quorums, signed votes) well beyond a +prototype increment; **ADR-0021** records the honest design, the real seam (the +commit-ordered-log contract above the log, not the Raft `Transport`), and why it +is deliberately not built. Everything that ports unchanged under BFT — the +deterministic state machine, Merkle audit, signing, and the core/edge split — is +already in place, so a future BFT effort starts from a clean boundary. diff --git a/docs/adr/0020-multi-raft-sharding.md b/docs/adr/0020-multi-raft-sharding.md new file mode 100644 index 0000000..18c7590 --- /dev/null +++ b/docs/adr/0020-multi-raft-sharding.md @@ -0,0 +1,111 @@ +# 0020. Multi-Raft sharding for write scaling + +- Status: Proposed +- Date: 2026-06-21 + +## Context + +ADR-0019 pillar 4 names a hard limit of the prototype: **all writes serialize +through a single Raft leader.** One leader orders every committed entry, so write +throughput is bounded by one node, and the entire state must fit on every member. +M6 (CQRS read projections) and M8 (pluggable key-oriented state store) scale +*reads* and *state size*, but neither scales *writes* — a single Raft group is +still one ordering bottleneck. + +The standard answer, used by every production system built on Raft (CockroachDB, +TiKV, YugabyteDB, etcd-at-scale), is **multi-Raft**: partition the keyspace into +shards, give each shard its own independent Raft group with its own leader, and +let writes to different shards proceed in parallel. The cost is that an operation +spanning two shards is no longer a single atomic log append — it needs a +cross-shard transaction protocol. + +This ADR records the design for a multi-Raft *prototype* on top of the existing +runtime, and the decision to demonstrate cross-shard atomicity with a **saga** +(try/compensate) rather than blocking two-phase commit. + +## Decision + +Introduce a **shard router + per-shard Raft groups + a saga coordinator**, built +additively on the existing consensus core and runtime (no changes to `RaftNode`): + +- **Sharding key.** A deterministic function maps each command to exactly one + shard. For keyed modules (M8) the shard is derived from the record key + (`hash(key) mod N`, or an explicit range map); for whole-state modules the + whole module is pinned to one shard. The mapping is configuration, identical on + every participant, so routing is deterministic. +- **Per-shard groups.** Each shard is an independent Raft cluster — its own set of + `RaftNode`s, its own leader election, its own replicated log and `ModuleHost`. + A write to shard *s* is proposed only to *s*'s leader; shards commit in parallel. + Within a shard, every guarantee from M1–M9 still holds unchanged. +- **Shard router.** A thin front door maps an incoming command to its shard and + submits to that shard's current leader (forwarding as today). It holds no state + of its own — it is derivable from the shard map and each group's leadership. +- **Cross-shard transactions via saga.** An operation touching shards *A* and *B* + (e.g. transfer from an account on *A* to one on *B*) runs as an ordered set of + per-shard local steps, each with a **compensating** step. The coordinator + executes forward; on a step failure it runs the compensations for the steps that + already committed, in reverse. Each step and each compensation is an ordinary, + idempotent, single-shard command (so it inherits exactly-once via the requestId + dedup). This yields **atomicity by eventual compensation**, not isolation. + +### Why saga, not 2PC + +- Two-phase commit holds locks across shards for the duration of the transaction + and blocks if the coordinator fails mid-commit — it trades availability for + isolation, which is at odds with the project's availability-first thesis. +- A saga keeps each shard independently available and uses the runtime's existing + idempotency + audit to make steps safe to retry and compensations safe to + replay. It surfaces intermediate states (no isolation), which is the accepted + trade-off and is exactly what the hash-chained audit is there to record. + +## Consequences + +### Positive + +- Writes to different shards commit in parallel — throughput scales with shard + count instead of being pinned to one leader. +- State is partitioned, so no single node must hold the whole dataset. +- Built additively: each shard is just an existing single-group cluster, so all + per-shard correctness (determinism, snapshots, signed commands, sandbox, audit) + carries over untouched. +- The saga steps are ordinary signed/audited module commands, so a cross-shard + transaction is itself fully audit-traceable across shards. + +### Negative + +- **No cross-shard isolation.** A saga exposes intermediate states (the debit is + visible before the credit). Reads spanning shards can observe a partially + applied transaction; only per-shard linearizability is preserved. +- **Compensation, not rollback.** A failed cross-shard transaction is undone by + forward compensating actions, which must be designed per operation and be + idempotent — more application-author burden than a single-shard command. +- **Cross-shard ordering is not globally defined.** There is no single log across + shards, so there is no global total order; the per-shard audit roots no longer + compose into one root (a cross-shard transaction appears as correlated entries + in two audits). A global audit needs a separate cross-shard correlation id. +- **Resharding is out of scope.** Changing the shard count/map (rebalancing) is a + hard problem (key migration between groups) and is not addressed here. +- **Routing/membership complexity.** The router must track each shard's current + leader, and a client must retry across leader changes per shard. + +## Alternatives considered + +- **Stay single-group (status quo).** Simplest and globally ordered, but the write + bottleneck and whole-state-on-every-node limit are exactly what this addresses. +- **Two-phase commit / Percolator-style transactions.** Gives cross-shard + isolation (snapshot/serializable), but blocks on coordinator failure and holds + locks — rejected for the prototype in favor of availability; a real system might + layer a transaction protocol on top of the same per-shard groups later. +- **Single big Raft group with parallel apply.** Doesn't help: the bottleneck is + *ordering/commit* at one leader, not apply. +- **Hash- vs range-sharding.** Hash spreads load evenly but kills range scans; + range keeps locality but risks hotspots. The prototype uses a simple, + configurable map and treats the choice as orthogonal. + +## Prototype scope (tracked under ADR-0019 milestone M10) + +A runtime-level prototype: a deterministic `ShardRouter` over N independent +in-process groups, a `Saga` coordinator with compensations, and a demonstrated +cross-shard account transfer (debit on shard A, credit on shard B, with +compensation when the credit leg fails). Resharding, range scans, and a global +cross-shard audit root are explicitly out of scope. diff --git a/docs/adr/0021-pluggable-bft-consensus-seam.md b/docs/adr/0021-pluggable-bft-consensus-seam.md new file mode 100644 index 0000000..976892a --- /dev/null +++ b/docs/adr/0021-pluggable-bft-consensus-seam.md @@ -0,0 +1,95 @@ +# 0021. Pluggable BFT consensus — the seam, and why it is not built + +- Status: Proposed +- Date: 2026-06-21 + +## Context + +ADR-0018 established that this framework is **Crash-Fault-Tolerant (CFT)**, not +**Byzantine-Fault-Tolerant (BFT)**: Raft assumes peers are honest-but-may-crash. +ADR-0019 (pillar 7) listed an "optional BFT consensus swap behind the existing +consensus seam" as a way to extend the system to **multi-party trustlessness** +(mutually-distrusting operators) when a single trust domain is not enough. M7 +(signed commands) already added per-actor accountability, but it does not make +the *ordering* Byzantine-tolerant — a malicious leader can still equivocate +(propose different logs to different followers) or censor, and CFT consensus does +not defend against that. + +This ADR honestly assesses what a BFT swap would actually require on this +codebase, and records the decision **not** to build it in the prototype. + +## The honest state of the "seam" + +ADR-0019 implies BFT could drop in "behind the existing consensus seam." On +inspection that is optimistic. The current seam is the `Transport` interface +(`src/consensus/transport.ts`), whose methods are **Raft-specific**: +`sendRequestVote`, `sendAppendEntries`, `sendInstallSnapshot`. A BFT protocol +(PBFT, Tendermint) does not use those messages at all — it uses a different, +multi-phase exchange (e.g. pre-prepare → prepare → commit, plus view-change), +needs `3f+1` members to tolerate `f` Byzantine faults (vs Raft's `2f+1` for `f` +crashes), and requires every consensus message to be **signed** and cross-checked +so no participant can be taken at its word. + +So the real seam is not `Transport` but the boundary *above the log*: the +`RaftNode` API the rest of the system depends on — `submit(command) → committed, +ordered, applied result`, plus leadership/term/membership status. Everything in +`src/runtime/` (M1–M10) and the HTTP layer depends only on that +commit-ordered-log contract, **not** on Raft internals. That contract is the true +pluggability point. + +## Decision + +**Do not implement BFT in the prototype.** Instead: + +1. **Name the real seam.** Define (in documentation, and as a refactoring target) + a `Consensus` interface capturing exactly what the runtime needs: `submit`, + `readBarrier`, membership, and a leadership/status view — the subset of + `RaftNode` that `ReplicatedStateMachine` and the controllers actually use. A + BFT engine would implement *that*, not the Raft `Transport`. +2. **Reuse M7 as a building block.** BFT requires signed, non-repudiable messages; + the ed25519 signing + `KeyRegistry` from M7 is the cryptographic primitive a + BFT layer would build on (signing *consensus votes*, not just client commands). +3. **Keep determinism as the invariant that ports unchanged.** The deterministic + state machine, the Merkle audit (M3), the canonical serialization (M5), and the + leader-resolved-seed discipline are all consensus-agnostic — they work + identically under BFT, because they only assume a committed, totally-ordered + command log. This is the payoff of the core/edge split (ADR-0019): the *edge* + and *core* do not change when the *ordering* protocol does. +4. **Treat BFT as a distinct project** gated on a real multi-party requirement, + not a milestone of this prototype. + +## Consequences + +### Positive + +- Honest scoping: we do not ship a fake or half-correct BFT (a subtly-wrong BFT is + worse than none — it gives false trust-minimization guarantees). +- The work that *does* port — determinism, audit, signing, the core/edge split — + is already done and validated, so a future BFT effort starts from a clean + log-contract boundary rather than a rewrite of the runtime. +- Documents a real correction to ADR-0019's "swap behind the existing seam" + framing, so future readers aren't misled into thinking it's a `Transport` swap. + +### Negative + +- The system remains single-trust-domain (CFT) — unsuitable for adversarial, + multi-organization deployments, exactly as ADR-0018 concluded. +- The application boundary *below* the log — the `StateMachine` interface — is now + extracted (ADR-0017), so applications already plug in cleanly. But the ordering + boundary *above* the log (a `Consensus` interface a BFT engine would implement) + is still not extracted: `RaftNode` is referenced directly (e.g. `ShardRouter`, + controllers), so a real BFT swap would first require that second refactor. + +## Alternatives considered + +- **Build a minimal PBFT/Tendermint engine now.** Rejected: thousands of lines, + a different protocol with view-changes and `3f+1` quorums, and high risk of + subtle safety bugs — not a prototype increment, and dangerous to ship half-done. +- **Claim the `Transport` interface is the BFT seam (as ADR-0019 implied).** + Rejected as inaccurate: BFT does not use Raft's RPCs; pretending otherwise would + mislead. +- **Adopt an external BFT platform (Tendermint/CometBFT, HotStuff) and run the + deterministic state machine on top via ABCI.** The most realistic path to actual + BFT — the state machine, audit, and signing port directly onto an ABCI-style + app interface. Recorded as the recommended direction *if* multi-party + trustlessness is ever required; deliberately out of scope here. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7daf7e5..54b2e98 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,10 @@ supersedes the old one rather than editing history. | [0015](./0015-dynamic-cluster-membership.md) | Dynamic cluster membership via single-server changes | Accepted | | [0016](./0016-crash-consistent-snapshot-persistence.md) | Crash-consistent snapshot/log persistence and snapshot transfer | Accepted | | [0017](./0017-generic-pluggable-state-machine.md) | Generic pluggable state machine (framework core) | Accepted | +| [0018](./0018-application-logic-as-smart-contracts.md) | Deploying application logic as smart contracts on this framework | Proposed | +| [0019](./0019-native-backend-blockchain-benefits.md) | Bringing blockchain benefits to backend development natively | Proposed | +| [0020](./0020-multi-raft-sharding.md) | Multi-Raft sharding for write scaling | Proposed | +| [0021](./0021-pluggable-bft-consensus-seam.md) | Pluggable BFT consensus — the seam, and why it is not built | Proposed | ## Template diff --git a/src/index.ts b/src/index.ts index 75dbf8e..87461a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,3 +40,39 @@ export { isControlCommand, isConfigCommand } from './consensus/types'; export { MetricsRegistry, metrics } from './platform/metrics'; export { createLogger } from './platform/logger'; export type { Logger } from './platform/logger'; + +// ---- Module runtime (ADR-0019): an opinionated application you can plug in via +// the StateMachine seam above, instead of writing one by hand. Write business +// logic as pure module reducers and inherit determinism enforcement, an +// exactly-once effect outbox, a Merkle audit, signed commands, a keyed store, +// CQRS read projections, and cross-shard sagas. See src/runtime/README.md. +export { ModuleStateMachine } from './runtime/moduleStateMachine'; +export { ModuleHost } from './runtime/moduleHost'; +export { defineModule } from './runtime/defineModule'; +export { defineKeyedModule } from './runtime/keyedModule'; +export type { KeyedModuleDefinition, KeyedReducer } from './runtime/keyedModule'; +export { buildModuleCommand, buildSignedModuleCommand } from './runtime/command'; +export { resolveSeed, createContext } from './runtime/context'; +export { EffectExecutor } from './runtime/effectExecutor'; +export { defineProjection } from './runtime/projection'; +export { ProjectionHost } from './runtime/projectionHost'; +export { MemoryStateStore, StoreView } from './runtime/stateStore'; +export { MerkleAudit, EMPTY_ROOT } from './runtime/merkleAudit'; +export { generateActorKeypair, signCommand, verifyCommand, KeyRegistry } from './runtime/signing'; +export { ShardRouter, NoShardLeaderError } from './runtime/shardRouter'; +export { runSaga } from './runtime/saga'; +export type { + ModuleDefinition, + ModuleAppCommand, + ModuleCommand, + ModuleApplyResult, + Reducer, + ReducerContext, + EffectIntent, + EffectHandler, + Seed, +} from './runtime/types'; +export type { StateStore } from './runtime/stateStore'; +export type { ProjectionDefinition, ProjectionEvent } from './runtime/projection'; +export type { SagaStep, SagaResult } from './runtime/saga'; +export type { ShardHandle, ShardNode } from './runtime/shardRouter'; diff --git a/src/runtime/README.md b/src/runtime/README.md new file mode 100644 index 0000000..6e5805e --- /dev/null +++ b/src/runtime/README.md @@ -0,0 +1,101 @@ +# Runtime — ADR-0019 prototype + +A prototype of the **deterministic core + effectful edge** design from +[ADR-0019](../../docs/adr/0019-native-backend-blockchain-benefits.md): a way to +write ordinary-looking backend modules that nonetheless inherit the blockchain +benefits of the consensus substrate — deterministic replicated execution, +exactly-once effects, and a provable tamper-evident audit. + +This layer plugs into the consensus core through the pluggable `StateMachine` +seam (ADR-0017): a `ModuleStateMachine` adapts the `ModuleHost` to the framework +contract, so the whole runtime rides real Raft as an ordinary application — no +bespoke consensus-core support. The book service is just *another* `StateMachine`; +this is the recommended one. + +## The model + +``` + EDGE (effectful) CORE (deterministic) EDGE (effectful) + resolveSeed(), → ModuleHost.apply(): → EffectExecutor.drain(): + oracle reads, pure reducers over runs handlers once, + validation replicated state + feeds results back as + outbox + Merkle audit committed entries +``` + +Non-determinism (time, randomness, ids, external data) is resolved **once on the +edge** and baked into the committed command/seed, so every replica applies the +exact same values — the same discipline `src/models/book.ts` already uses for the +book demo. + +## Pieces + +| File | Role | Milestone | +|------|------|-----------| +| `types.ts` | `ModuleDefinition`, `Reducer`, `ReducerContext`, `EffectIntent`, `Seed`, audit/outbox/host-option types | M1–M5 | +| `defineModule.ts` | Declarative module definition + validation (rejects `__`-reserved names; strict determinism lint) | M1, M5 | +| `context.ts` | `resolveSeed()` (the sole edge entropy site) and the deterministic `ReducerContext` | M1 | +| `moduleHost.ts` | `ModuleHost`: dispatch to pure reducers, queries, outbox, Merkle audit, resource bounds, signature check, snapshot/restore | M1–M7 | +| `effectExecutor.ts` | Post-commit executor: runs effect handlers exactly-once and submits results back | M2 | +| `merkleAudit.ts` | Append-only Merkle accumulator: inclusion proofs + domain-separated root | M3 | +| `codeHash.ts` | Deterministic module code-version hash (POC stand-in for hashing the built artifact) | M3 | +| `canonical.ts` | Single shared canonical (sorted-key) JSON serializer used by every hash/size path | M5 | +| `determinism.ts` | Static determinism lint + `canonicalBytes` for the deterministic resource bound | M5 | +| `moduleStateMachine.ts` | Adapts `ModuleHost` to the framework `StateMachine` seam (ADR-0017) — plug it into a `RaftNode` | M4 | +| `command.ts` | Leader-side `buildModuleCommand` / `buildSignedModuleCommand` (resolve seed, optionally sign) | M4, M7 | +| `signing.ts` | ed25519 sign/verify over the logical payload + `KeyRegistry` (actor→authorized key) | M7 | +| `projection.ts` / `projectionHost.ts` | CQRS read side: derived, rebuildable read model off the apply path | M6 | +| `stateStore.ts` | `StateStore` interface + in-memory `MemoryStateStore` + copy-on-write `StoreView` (the seam for a persistent KV) | M8 | +| `keyedModule.ts` | `defineKeyedModule` / `KeyedReducer` — record-addressed modules that touch only the keys they need | M8 | +| `sandbox.ts` | `vm` determinism sandbox (enforce-by-removal) + leader-side step/CPU meter (`admit`) | M9 | +| `shardRouter.ts` / `saga.ts` | Multi-Raft sharding: deterministic shard router over independent groups + cross-shard saga (try/compensate) | M10 | +| `modules/` | Demo modules: `counter`, `notes` (leader-resolved id/time via `ctx`), `payments` (effect → settle), `accounts` (keyed), `compute` (sandboxed) | M1–M2, M8–M9 | +| `projections/` | Demo projection: `noteIndex` (notes indexed by actor) | M6 | + +The runtime rides **real consensus** (M4): a `ModuleAppCommand` is an ordinary +application command, and `ModuleStateMachine` is the application a `RaftNode` +replicates — so a multi-node cluster converges on identical module state and +audit root with no special core support. See +`tests/runtime/consensusIntegration.test.ts` and `tests/runtime/signing.test.ts` +for the end-to-end proofs. + +## Writing a module + +```ts +import { defineModule } from '../defineModule'; + +export const counter = defineModule<{ value: number }>({ + name: 'counter', + version: '1', + initialState: () => ({ value: 0 }), + commands: { + // Pure: state in, { state, result?, effects? } out. ctx is the only + // source of "now"/randomness/ids, all derived from the leader's seed. + increment: (state, input: any, _ctx) => ({ + state: { value: state.value + (input?.by ?? 1) }, + }), + }, + queries: { + value: (state) => state.value, + }, +}); +``` + +## Invariants (do not break) + +- **Reducers are pure.** Never read `Date`/`Math.random`/`crypto`/network inside a + reducer — use `ctx.now` / `ctx.random()` / `ctx.id()`. Divergence is silent and + severe (same rule as ADR-0003 for the core state machine). +- **Effects are described, not performed, in reducers.** Return `EffectIntent`s; + the `EffectExecutor` performs them after commit and feeds results back as + committed entries. +- **Everything hashed uses canonical (sorted-key) serialization** so replicas + compute identical audit roots. + +## Tests + +```bash +LOG_SILENT=true npx jest tests/runtime +``` + +Covers determinism/convergence, snapshot-restore fidelity, exactly-once effects, +and Merkle proof/verify + audit convergence. diff --git a/src/runtime/canonical.ts b/src/runtime/canonical.ts new file mode 100644 index 0000000..6ec4217 --- /dev/null +++ b/src/runtime/canonical.ts @@ -0,0 +1,91 @@ +/** + * Single shared canonical-JSON serializer (ADR-0019 runtime). + * + * Canonical JSON means: stringify with object keys recursively SORTED, so two + * logically-equal values serialize to the SAME bytes regardless of property + * insertion order. This is the determinism linchpin for both consumers below — + * without sorted keys, two replicas building the same object with different key + * order would produce different bytes and diverge. + * + * WHY ONE MODULE: there were previously two near-identical copies of this logic + * (one for audit-leaf hash preimages in `merkleAudit.ts`, one for the size bound + * in `determinism.ts`). Two copies of a determinism-critical serializer are a + * drift hazard — a fix or feature applied to one but not the other could make a + * leaf hash and a size measurement disagree about the same value. This module is + * the single source of truth; the only sanctioned variation is how `undefined` + * is handled, expressed as an explicit option (see below). + * + * THE TWO `undefined` MODES (and why they differ): + * - `'throw'` (default) — for HASH PREIMAGES (audit leaves). A hash preimage + * must be LOSSLESS and unambiguous: bare `JSON.stringify(undefined)` yields + * the value `undefined` (not a string), and a property whose value is + * `undefined` is silently dropped. Either would corrupt a leaf hash or make + * it ambiguous, so we refuse `undefined` loudly rather than hash garbage. + * - `'drop'` — for the SIZE bound. A byte-length measurement is not a preimage, + * so it can be lenient: a property whose value is `undefined` is dropped and + * an `undefined` array element is normalized to `null`, exactly mirroring + * `JSON.stringify`. This never throws on the state shapes reducers return. + */ + +/** Controls how a `undefined` value is treated during serialization. */ +export interface CanonicalOptions { + /** + * `'throw'` (default): reject `undefined` anywhere — required for lossless + * hash preimages. `'drop'`: omit `undefined` object properties and normalize + * `undefined` array elements to `null`, matching `JSON.stringify` — used for + * the lenient size bound. + */ + onUndefined?: 'throw' | 'drop'; +} + +/** + * Serialize `value` to canonical (recursively sorted-key) JSON. + * + * The byte output is identical to the previous per-module serializers for each + * caller's chosen `onUndefined` mode — `'throw'` reproduces `merkleAudit.ts`'s + * old `canonical()` exactly (so audit-leaf hashes and the Merkle root are + * unchanged), and `'drop'` reproduces `determinism.ts`'s old `canonicalJson()`. + */ +export function canonicalJson(value: unknown, opts: CanonicalOptions = {}): string { + const onUndefined = opts.onUndefined ?? 'throw'; + + if (value === undefined) { + if (onUndefined === 'throw') { + throw new Error('canonical: undefined is not serializable (would corrupt the hash preimage)'); + } + // 'drop' mode only reaches here for a top-level/array `undefined`; array + // callers normalize to null below, so this serves the top-level case. + return JSON.stringify(null); + } + + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value + .map((v) => { + // In 'drop' mode, `undefined` array holes/elements become `null` + // exactly as `JSON.stringify` would; in 'throw' mode the recursive + // call rejects them. + if (v === undefined && onUndefined === 'drop') { + return 'null'; + } + return canonicalJson(v, opts); + }) + .join(',')}]`; + } + + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const parts: string[] = []; + for (const k of keys) { + // In 'drop' mode, skip `undefined`-valued properties (matching + // `JSON.stringify`); in 'throw' mode the recursive call rejects them. + if (obj[k] === undefined && onUndefined === 'drop') { + continue; + } + parts.push(`${JSON.stringify(k)}:${canonicalJson(obj[k], opts)}`); + } + return `{${parts.join(',')}}`; +} diff --git a/src/runtime/codeHash.ts b/src/runtime/codeHash.ts new file mode 100644 index 0000000..ab87b8a --- /dev/null +++ b/src/runtime/codeHash.ts @@ -0,0 +1,61 @@ +import { createHash } from 'crypto'; + +/** + * Module code-version hashing (ADR-0019 pillar 5). The audit records a hash of + * the LOGIC that produced each result, so the history proves not just *what* the + * data became but *which version of the code* computed it — closing ADR-0018's + * "tamper-evident data but not logic" gap. + * + * PROTOTYPE CAVEAT (stated honestly): `fn.toString()` is a STAND-IN for hashing + * the built/deployed artifact. It captures a reducer's source text, which is + * enough to detect that the logic changed (a different body or a bumped + * `version` yields a different hash) and to bind that change into the audit. It + * is NOT a production code-provenance mechanism: source text omits captured + * closure variables and imported helpers, can be defeated by semantically-equal + * rewrites, and is sensitive to incidental formatting. A real deployment would + * hash the immutable build output recorded in a `DEPLOY` log entry. For this + * POC, source-text identity is sufficient to demonstrate the audit captures + * which logic version ran. + * + * DETERMINISM: command names are sorted and the material is assembled with no + * clock/randomness, so every replica computes the identical code hash for a + * given module definition. + */ + +/** sha256 hex of a string. */ +function sha256(s: string): string { + return createHash('sha256').update(s).digest('hex'); +} + +/** + * The minimal structural shape `moduleCodeHash` needs. Both whole-state + * `ModuleDefinition` and `KeyedModuleDefinition` satisfy it (each has a `name`, + * an optional `version`, and a `commands` record of functions), so the hash + * accepts the heterogeneous registry's union without an `as any` cast at the + * call site — it only reads `name`/`version`/the reducers' source text. + */ +interface HashableModule { + name: string; + version?: string; + commands: Record unknown>; +} + +/** + * Deterministic identity hash of a module's logic. Built from the module name, + * its `version` (defaulting to `'0'` when unset), and each command name paired + * with its reducer's source text, with command names SORTED for a stable order. + */ +export function moduleCodeHash(def: HashableModule): string { + const commandNames = Object.keys(def.commands).sort(); + const commandMaterial = commandNames + // `JSON.stringify` on the name + body keeps the delimiter unambiguous so + // distinct (name, body) pairs cannot alias into the same concatenation. + .map((name) => `${JSON.stringify(name)}:${JSON.stringify(def.commands[name].toString())}`) + .join(','); + const material = [ + `name:${JSON.stringify(def.name)}`, + `version:${JSON.stringify(def.version ?? '0')}`, + `commands:[${commandMaterial}]`, + ].join('|'); + return sha256(material); +} diff --git a/src/runtime/command.ts b/src/runtime/command.ts new file mode 100644 index 0000000..29fa343 --- /dev/null +++ b/src/runtime/command.ts @@ -0,0 +1,75 @@ +import { resolveSeed } from './context'; +import { signCommand } from './signing'; +import { ModuleAppCommand } from './types'; + +/** + * LEADER-SIDE builder for a module application command (ADR-0019). This is where + * the runtime's non-determinism is resolved up front: {@link resolveSeed} + * captures the ambient clock + a fresh random nonce ONCE on the leader, and that + * seed is baked into the command before it enters the replicated log. Every + * replica then derives the identical `ReducerContext` from the committed seed, so + * module reducers stay pure and the cluster converges — exactly the discipline + * `src/models/book.ts` uses to resolve ids/timestamps for the book demo, + * generalized to the module runtime. + * + * The returned command is an ordinary `AppCommand` (type `'MODULE'`): it plugs + * into a `RaftNode` whose state machine is a {@link ModuleStateMachine}, with no + * bespoke consensus-core support. `actor`/`requestId` are carried on the command + * (the adapter has no separate meta) and MUST match the `CommandMeta` it is + * submitted with. Followers/replicas never call this; they apply the committed + * command as-is. + */ +export function buildModuleCommand( + module: string, + command: string, + input: unknown, + meta: { actor: string; requestId: string }, +): ModuleAppCommand { + return { + type: 'MODULE', + module, + command, + input, + seed: resolveSeed(), + actor: meta.actor, + requestId: meta.requestId, + }; +} + +/** + * Build a SIGNED module command (ADR-0019 pillar 7). The originating actor signs + * the LOGICAL command — `{ module, command, input, actor, requestId }` — with its + * private key BEFORE this reaches the leader; the leader then resolves the + * deterministic `seed` and forwards the signature unchanged. + * + * The `seed` is INTENTIONALLY excluded from the signed payload: the leader picks + * it after the actor signs, so the actor cannot have signed over it, and (being + * a non-security convergence value) it does not need to be authenticated. On + * apply, every node recomputes this same logical payload from the committed + * command and verifies the signature against the actor's registered key, so a + * leader that forged `actor` could not have produced a matching signature. + */ +export function buildSignedModuleCommand( + module: string, + command: string, + input: unknown, + opts: { actor: string; requestId: string; privateKeyPem: string }, +): ModuleAppCommand { + const sig = signCommand(opts.privateKeyPem, { + module, + command, + input, + actor: opts.actor, + requestId: opts.requestId, + }); + return { + type: 'MODULE', + module, + command, + input, + seed: resolveSeed(), + actor: opts.actor, + requestId: opts.requestId, + sig, + }; +} diff --git a/src/runtime/context.ts b/src/runtime/context.ts new file mode 100644 index 0000000..8340032 --- /dev/null +++ b/src/runtime/context.ts @@ -0,0 +1,69 @@ +import { createHash, randomBytes } from 'crypto'; +import { ReducerContext, Seed } from './types'; + +/** + * Seed resolution and deterministic context construction (ADR-0019 pillar 2). + * + * `resolveSeed` is the ONLY place real non-determinism enters; it runs on the + * leader. `createContext` then rebuilds a fully deterministic `ReducerContext` + * from that seed, so any replica (or a fresh host during replay) reconstructs + * the identical clock/PRNG/id stream and converges byte-for-byte. + */ + +/** + * LEADER-SIDE ONLY. Capture the ambient clock and a fresh random nonce, exactly + * as `models/book.ts` resolves ids/timestamps up front before a command enters + * the log. The resulting seed is replicated verbatim; replicas never call this. + */ +export function resolveSeed(): Seed { + return { + timestamp: new Date().toISOString(), + nonce: randomBytes(16).toString('hex'), + }; +} + +/** Derive a 32-bit unsigned seed integer from the nonce via sha256. */ +function seedStateFromNonce(nonce: string): number { + const digest = createHash('sha256').update(nonce).digest(); + // Take the first 4 bytes as a big-endian uint32. `>>> 0` forces unsigned. + return digest.readUInt32BE(0) >>> 0; +} + +/** + * Build a deterministic context from a seed. Every capability below is a pure + * function of `seed` (plus per-context call counters), so the same seed always + * yields the same `now`/`random()` stream/`id()` stream. + */ +export function createContext(seed: Seed, meta: { actor: string; requestId: string }): ReducerContext { + // mulberry32: a tiny, fast, well-distributed PRNG. Deterministic given its + // 32-bit state, which we derive from the nonce. We keep `randomState` in a + // closure so successive `random()` calls advance the same stream. + let randomState = seedStateFromNonce(seed.nonce); + const random = (): number => { + // mulberry32 step. + randomState = (randomState + 0x6d2b79f5) >>> 0; + let t = randomState; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + // Deterministic id stream: sha256(nonce:counter) hex, truncated to 32 chars. + // The counter is independent of the PRNG so the two streams don't interfere. + let idCounter = 0; + const id = (): string => { + const hex = createHash('sha256') + .update(`${seed.nonce}:${idCounter}`) + .digest('hex'); + idCounter += 1; + return hex.slice(0, 32); + }; + + return { + now: seed.timestamp, + random, + id, + actor: meta.actor, + requestId: meta.requestId, + }; +} diff --git a/src/runtime/defineModule.ts b/src/runtime/defineModule.ts new file mode 100644 index 0000000..13a0c92 --- /dev/null +++ b/src/runtime/defineModule.ts @@ -0,0 +1,128 @@ +import { lintReducer } from './determinism'; +import { ModuleDefinition } from './types'; + +/** + * Options for {@link defineModule}. + */ +export interface DefineModuleOptions { + /** + * Enforce the determinism lint at definition time (ADR-0019 pillar 2). + * Defaults to TRUE: a reducer that references a non-deterministic global + * (`Date.now`, `Math.random`, `crypto`, network, timers, …) is REJECTED with + * a thrown error before the module can ever be registered. + * + * OPT-OUT: pass `{ strict: false }` for a vetted reducer whose flagged token + * is a false positive (e.g. a banned word appearing only in a string/comment) + * or that has been audited by other means. Opting out does not silence the + * lint — the violations are attached to the returned definition under the + * reserved `__lint` field for inspection/logging — it only skips the throw. + */ + strict?: boolean; + /** + * Opt into the `vm` determinism sandbox (ADR-0019 pillars 2, 6 — M9). When + * set here it is stamped onto the returned definition so the host compiles the + * reducers into a frozen `vm` context at registration. Additive and + * default-off; the static lint stays on as defense-in-depth (a sandboxed + * reducer is self-contained and still passes the lint). See `sandbox.ts` for + * the honest "not a security boundary" scoping. WHOLE-STATE modules only. + */ + sandbox?: boolean; + /** + * Wall-clock budget (ms) for the leader-side admission step meter. Only + * meaningful with `sandbox: true`. Stamped onto the returned definition. + * Default applied by the host: 50. + */ + stepBudgetMs?: number; +} + +/** + * A module definition that failed the determinism lint with `strict: false`. + * The violations are recorded under the reserved `__lint` field so an operator + * can still surface them, even though registration was allowed to proceed. + */ +export type LintedModuleDefinition = ModuleDefinition & { __lint?: string[] }; + +/** + * Validate and return a module definition (ADR-0019 pillar 1: the single + * declarative unit that replaces the four-touchpoint command workflow). + * + * Validation happens here, at definition time, so a malformed module fails loud + * on startup rather than surfacing as a confusing dispatch error later. The + * function is otherwise an identity — it returns the same object, typed — which + * keeps authoring a module a single `defineModule({ ... })` call. + * + * Determinism lint (ADR-0019 pillar 2): every command reducer is statically + * linted for non-deterministic globals. With `strict` (the default), any + * violation THROWS, turning the "reducers must be pure" convention into an + * enforced guarantee. With `{ strict: false }` the violations are recorded on + * the returned definition's `__lint` field instead of throwing — the documented + * escape hatch for a vetted reducer. + */ +export function defineModule( + rawDef: ModuleDefinition, + opts: DefineModuleOptions = {}, +): LintedModuleDefinition { + const strict = opts.strict ?? true; + // Stamp the sandbox opt-in (ADR-0019 M9) from the options onto the definition + // so the host sees it at registration. A field set directly on `def` wins over + // the option (explicit on the definition is the more specific intent); options + // are the ergonomic place to pass it alongside `strict`. + const def: ModuleDefinition = { ...rawDef }; + const sandbox = rawDef.sandbox ?? opts.sandbox; + if (sandbox !== undefined) def.sandbox = sandbox; + const stepBudgetMs = rawDef.stepBudgetMs ?? opts.stepBudgetMs; + if (stepBudgetMs !== undefined) def.stepBudgetMs = stepBudgetMs; + if (!def.name || def.name.trim() === '') { + throw new Error('Module definition requires a non-empty name'); + } + + // Names starting with `__` are reserved for runtime internals — the snapshot + // stores the outbox under the reserved `__outbox` key in the same flat + // module-states object, so a `__`-prefixed module would silently collide. + // Fail closed at definition time rather than corrupt a snapshot later. + if (def.name.startsWith('__')) { + throw new Error( + `Module name "${def.name}" is reserved: names starting with "__" are reserved for runtime internals`, + ); + } + + const commandNames = Object.keys(def.commands ?? {}); + if (commandNames.length === 0) { + throw new Error(`Module "${def.name}" must define at least one command`); + } + + // Validate names exactly as they will be dispatched (untrimmed keys are what + // `moduleHost.apply` looks up), so validation can't diverge from dispatch. + // `Object.keys` already collapses duplicate literal keys, so only the + // empty-name case needs rejecting here; an empty name makes dispatch ambiguous. + for (const name of commandNames) { + if (name === '') { + throw new Error(`Module "${def.name}" has a command with an empty name`); + } + } + + // Determinism lint (ADR-0019 pillar 2): scan every reducer's source for + // non-deterministic globals. This is the always-on static stand-in for the + // deferred vm/worker sandbox — it catches the common footgun at definition + // time, before a single command can be applied. + const violations: string[] = []; + for (const name of commandNames) { + violations.push(...lintReducer(name, def.commands[name])); + } + + if (violations.length > 0) { + if (strict) { + throw new Error( + `Module "${def.name}" failed the determinism lint (ADR-0019 pillar 2):\n` + + violations.map((v) => ` - ${v}`).join('\n') + + `\nReducers must be pure: use ctx.now / ctx.random() / ctx.id() instead of ambient ` + + `globals, or pass { strict: false } to defineModule for a vetted reducer.`, + ); + } + // strict: false — record the violations rather than throw, so a vetted + // module can opt out while the findings remain inspectable. + return Object.assign({}, def, { __lint: violations }); + } + + return def; +} diff --git a/src/runtime/determinism.ts b/src/runtime/determinism.ts new file mode 100644 index 0000000..c95734a --- /dev/null +++ b/src/runtime/determinism.ts @@ -0,0 +1,127 @@ +/** + * Determinism lint + size accounting (ADR-0019 pillar 2 "determinism as a + * guarantee, not a convention", and pillar 6 "resource safety / gas"). + * + * Pillar 2 calls for turning the *convention* "reducers must be pure" into an + * enforced *guarantee*. The ideal enforcement is a `vm`/worker sandbox with + * frozen globals; that needs a separate execution context and is explicitly + * DEFERRED (see the ADR's prototype-status note). This module is the cheap, + * always-on first line of defence instead: a STATIC lint that inspects a + * reducer's source for the common non-determinism footguns and rejects them at + * registration, before any command is ever applied. + * + * Pillar 6 wants a deterministic resource bound. `canonicalBytes` below is the + * size-accounting primitive the host uses to cap result/state amplification — + * every replica computes the identical byte length and rejects identically, so + * the bound never causes divergence (unlike a wall-clock CPU meter would). + */ + +import { canonicalJson } from './canonical'; + +/** + * Non-deterministic access patterns to detect in reducer source. + * + * Each entry is a targeted regex chosen to match the ACCESS to a banned global, + * not an innocent identifier that merely shares a name. For example we match + * `Math.random` (the property access), not a local variable or object field + * literally named `random` — so a module that has a `random` field in its state + * is not flagged. `ctx.now` / `ctx.id()` / `ctx.random()` are the SANCTIONED + * deterministic substitutes and go through `ctx`, never these globals, so the + * demo modules (counter/notes/payments) pass cleanly. + * + * This list is the documented footgun set, not an exhaustive proof of purity. + */ +export const BANNED: { pattern: RegExp; reason: string }[] = [ + // Clock + randomness: the two classic determinism breakers. + { pattern: /\bMath\s*\.\s*random\b/, reason: 'Math.random (use ctx.random())' }, + { pattern: /\bDate\s*\.\s*now\b/, reason: 'Date.now (use ctx.now)' }, + // Any `new Date(` or bare `Date(` is suspicious: even `new Date(arg)` is a + // footgun magnet, and we cannot statically prove the arg is deterministic. + // Authors should derive time from ctx.now instead of constructing Dates. + { pattern: /\bnew\s+Date\s*\(/, reason: 'new Date( (use ctx.now)' }, + { pattern: /(?(); + + constructor( + private readonly handlers: Record, + private readonly submit: (entry: EffectResultEntry) => void | Promise, + ) {} + + /** + * Run every pending intent whose key is not already in flight. On success, + * resolve a `Seed` here on the edge (committed verbatim so any `onResult` + * reducer stays deterministic on every replica), package the handler's result + * into an `EffectResultEntry`, and `submit` it back + * into the log. On handler failure, leave the effect pending (do NOT submit) so a + * later drain retries it. + */ + async drain(pending: EffectIntent[]): Promise { + await Promise.all(pending.map((intent) => this.run(intent))); + } + + private async run(intent: EffectIntent): Promise { + const key = intent.idempotencyKey; + // In-flight guard: a second concurrent drain seeing the same pending key + // skips it rather than re-running the handler. + if (this.inFlight.has(key)) { + return; + } + + const handler = this.handlers[intent.kind]; + if (!handler) { + // No handler for this kind: leave it pending. A handler may be + // registered later; we never drop the intent or fabricate a result. + return; + } + + this.inFlight.add(key); + try { + const result = await handler(intent); + // The seed is resolved once on the edge by the executor, then committed + // verbatim so every replica applies the same value (convergence comes + // from committing the value, not from where it was generated). That + // deterministic seed keeps any consuming `onResult` reducer's `ctx` + // identical on every replica. + const entry: EffectResultEntry = { idempotencyKey: key, result, seed: resolveSeed() }; + await this.submit(entry); + } catch { + // Handler failed (e.g. network error): swallow and leave the effect + // pending so the next drain retries. Submitting nothing keeps the + // outbox entry `pending`, preserving at-least-once handler semantics. + } finally { + this.inFlight.delete(key); + } + } +} diff --git a/src/runtime/keyedModule.ts b/src/runtime/keyedModule.ts new file mode 100644 index 0000000..6170fc3 --- /dev/null +++ b/src/runtime/keyedModule.ts @@ -0,0 +1,123 @@ +/** + * Keyed (key-oriented, transactional) module model (ADR-0019 pillar 4, the + * "state larger than RAM" half). + * + * A WHOLE-STATE module (`defineModule`) hands each reducer the entire module + * state and takes back the next whole state. A KEYED module instead hands the + * reducer a transactional {@link StoreView} over a per-module {@link StateStore}: + * the reducer READS and WRITES individual records by key and returns no state + * blob at all. The host commits the view's buffered writes only on success, so a + * keyed reducer gets the SAME atomicity guarantee as a whole-state one — without + * ever loading the whole dataset. + * + * This is ADDITIVE. Keyed modules carry `kind: 'keyed'` so the host can route + * them separately; whole-state modules are unchanged and keep `kind` absent + * (treated as `'whole'`). + */ + +import { lintReducer } from './determinism'; +import { StateStore, StoreView } from './stateStore'; +import { EffectIntent, ReducerContext } from './types'; + +/** + * A keyed reducer. Mutations go through `store` (a transactional {@link StoreView} + * buffering its writes); the reducer returns NO next-state — only an optional + * explicit `result` to surface to the caller and any `effects` to enqueue. Same + * purity contract as a whole-state reducer: read `now`/randomness/ids from `ctx` + * only, never ambient globals. + */ +export type KeyedReducer = ( + store: StoreView, + input: unknown, + ctx: ReducerContext, +) => { result?: unknown; effects?: EffectIntent[] }; + +/** + * A read query over a keyed module's store. Receives the module's + * {@link StateStore} (read surface; reads return clones) and the query args. + */ +export type KeyedQuery = (store: StateStore, args: unknown) => unknown; + +/** + * A keyed module definition. The `kind: 'keyed'` discriminant lets the + * {@link ModuleHost} tell it apart from a whole-state `ModuleDefinition` (which + * has no `kind`). + */ +export interface KeyedModuleDefinition { + name: string; + version?: string; + kind: 'keyed'; + commands: Record; + queries?: Record; +} + +/** + * A keyed module that failed the determinism lint with `strict: false`. As with + * `defineModule`, the violations are recorded under the reserved `__lint` field + * rather than thrown. + */ +export type LintedKeyedModuleDefinition = KeyedModuleDefinition & { __lint?: string[] }; + +/** Options for {@link defineKeyedModule}, mirroring `defineModule`. */ +export interface DefineKeyedModuleOptions { + /** + * Enforce the determinism lint at definition time (ADR-0019 pillar 2). + * Defaults to TRUE — identical semantics to `defineModule`: a violation + * throws, or with `{ strict: false }` is recorded under `__lint` instead. + */ + strict?: boolean; +} + +/** + * Validate and return a keyed module definition. Mirrors `defineModule`'s + * validation exactly (non-empty name, reject `__`-reserved names, ≥1 command, + * run the determinism lint strict-by-default over each reducer) and stamps + * `kind: 'keyed'` so the host routes it through the keyed (StoreView) path. + */ +export function defineKeyedModule( + def: Omit & { kind?: 'keyed' }, + opts: DefineKeyedModuleOptions = {}, +): LintedKeyedModuleDefinition { + const strict = opts.strict ?? true; + if (!def.name || def.name.trim() === '') { + throw new Error('Module definition requires a non-empty name'); + } + // Same reserved-name guard as defineModule/register: `__`-prefixed names + // collide with the snapshot's reserved keys (`__outbox`/`__audit`). + if (def.name.startsWith('__')) { + throw new Error( + `Module name "${def.name}" is reserved: names starting with "__" are reserved for runtime internals`, + ); + } + + const commandNames = Object.keys(def.commands ?? {}); + if (commandNames.length === 0) { + throw new Error(`Module "${def.name}" must define at least one command`); + } + for (const name of commandNames) { + if (name === '') { + throw new Error(`Module "${def.name}" has a command with an empty name`); + } + } + + // Determinism lint (ADR-0019 pillar 2) over every keyed reducer — the same + // static stand-in for the deferred sandbox that whole-state modules get. + const violations: string[] = []; + for (const name of commandNames) { + violations.push(...lintReducer(name, def.commands[name])); + } + + const result: KeyedModuleDefinition = { ...def, kind: 'keyed' }; + if (violations.length > 0) { + if (strict) { + throw new Error( + `Module "${def.name}" failed the determinism lint (ADR-0019 pillar 2):\n` + + violations.map((v) => ` - ${v}`).join('\n') + + `\nReducers must be pure: use ctx.now / ctx.random() / ctx.id() instead of ambient ` + + `globals, or pass { strict: false } to defineKeyedModule for a vetted reducer.`, + ); + } + return Object.assign(result, { __lint: violations }); + } + return result; +} diff --git a/src/runtime/merkleAudit.ts b/src/runtime/merkleAudit.ts new file mode 100644 index 0000000..ab7295f --- /dev/null +++ b/src/runtime/merkleAudit.ts @@ -0,0 +1,256 @@ +import { createHash } from 'crypto'; +import { canonicalJson } from './canonical'; + +/** + * Merkle accumulator audit (ADR-0019 pillar 5). Upgrades the consensus core's + * LINEAR hash chain (`replicatedStateMachine.ts`) to a Merkle tree: instead of + * an O(n) walk to prove an entry, a verifier needs only a compact root plus an + * O(log n) sibling path. The root is a single hash that can be externally + * anchored, and any inclusion proof verifies against it without the full log — + * the property the linear chain could not offer. + * + * DETERMINISM: the audit is replicated state, so every leaf hash and the root + * MUST be a pure function of the applied command stream. We therefore hash a + * CANONICAL serialization (object keys sorted) of each leaf — no `Date`, no + * `Math.random`, no key-order dependence — so all replicas compute byte-identical + * hashes and converge on the same root. + */ + +/** + * One audited fact: which logic version (`codeHash`) produced which result + * (`status`) for which command, and on whose behalf. `seq` is the leaf's index + * in the accumulator (and its position in the tree). `codeHash` is what closes + * ADR-0018's "data but not logic" gap — see {@link moduleCodeHash}. + */ +export interface AuditLeaf { + seq: number; + module: string; + command: string; + actor: string; + requestId: string; + status: number; + codeHash: string; +} + +/** + * An inclusion proof: the leaf's own hash plus the ordered sibling hashes from + * leaf level up to the root. `side` says whether the sibling sits to the LEFT or + * RIGHT of the running hash at that level, so a verifier knows the concatenation + * order (`sha256(0x01 + left + right)`, the internal-node domain). A level where + * this node was promoted unchanged + * (odd-count tail — see below) contributes NO sibling, exactly as the tree built. + */ +export interface MerkleProof { + leafHash: string; + siblings: { hash: string; side: 'left' | 'right' }[]; +} + +/** sha256 hex of the given string. The single hashing primitive used throughout. */ +function sha256(s: string): string { + return createHash('sha256').update(s).digest('hex'); +} + +/** + * RFC-6962-style domain-separation tags. Leaves and internal nodes are hashed in + * DISTINCT domains (`0x00` for leaves, `0x01` for internal nodes) so a hash can + * never be valid as both. Without this, an internal-node hash (a bare 64-hex + * string) could be presented as a `MerkleProof.leafHash` and verify against the + * root — the classic second-preimage ambiguity. The prefix byte closes that gap. + */ +const LEAF_TAG = '\x00'; +const NODE_TAG = '\x01'; + +/** Hash an internal node from its two child hashes, tagged in the node domain. */ +function hashNode(left: string, right: string): string { + return sha256(NODE_TAG + left + right); +} + +/** + * Root of the EMPTY tree: a fixed, documented constant `sha256('')`. Returning a + * stable sentinel (rather than throwing) lets `auditRoot()` be called before any + * command is applied and keeps the root well-defined for the zero-leaf case. + * Deliberately UNTAGGED (neither leaf nor node domain): an empty tree has no + * leaf, so it cannot collide with any real leaf or internal hash. + */ +export const EMPTY_ROOT = sha256(''); + +/** + * Hash a leaf via its canonical serialization, in the LEAF domain (`0x00` + * prefix). The tag keeps leaf hashes disjoint from internal-node hashes so a + * proof's `leafHash` can never be a smuggled internal node. Pure and + * replica-stable. + * + * The serializer (shared `canonicalJson` from `./canonical`) sorts object keys + * recursively so logically-equal leaves serialize to the SAME bytes regardless + * of property insertion order — the determinism linchpin for the leaf hash. We + * use the THROW-on-`undefined` mode because this is a hash PREIMAGE: it must be + * lossless and unambiguous, so any `undefined` (which `JSON.stringify` would + * silently drop or emit as the value `undefined`) is rejected loudly rather than + * corrupting the leaf hash. No current `AuditLeaf` field is ever `undefined`; + * the throw keeps that enforced for any future reuse. + */ +function hashLeaf(leaf: AuditLeaf): string { + return sha256(LEAF_TAG + canonicalJson(leaf, { onUndefined: 'throw' })); +} + +/** + * Append-only Merkle accumulator over audit leaves. + * + * DOMAIN SEPARATION (RFC-6962-style): leaves and internal nodes are hashed in + * disjoint domains via a one-byte prefix — leaves as `sha256(0x00 + canonical)`, + * internal nodes as `sha256(0x01 + left + right)`. Because the two domains can + * never collide, an internal-node hash can never be passed off as a leaf hash + * (or vice versa), which closes the second-preimage ambiguity for proofs. + * + * ODD-NODE SCHEME (documented, and applied identically in build and proof): when + * a tree level has an odd number of nodes, the unpaired LAST node is **promoted + * unchanged** to the next level (it is NOT duplicated and re-hashed with itself). + * Combined with the leaf/internal domain tags above, this avoids the classic + * "duplicate-last" second-preimage ambiguity (where a tree and a tampered variant + * could share a root). A promoted level contributes no proof element, because no + * concatenation happened there. + * + * ATTESTATION SCOPE (read this before trusting the root for more than it offers): + * the audit attests *which command + logic version (`codeHash`) + status* was + * applied, and in what ORDER. It does NOT by itself attest STATE agreement: two + * replicas that processed an identical command stream share the same audit root + * even if a non-determinism bug left their materialized state divergent. State + * convergence is a separate property, verified elsewhere — the audit root is a + * cross-check on the command/logic stream, not a proof of equal state. + */ +export class MerkleAudit { + /** Leaves in append order; `leaf.seq` equals its index here. */ + private readonly storedLeaves: AuditLeaf[] = []; + /** Cached leaf hashes, parallel to `storedLeaves`. */ + private readonly leafHashes: string[] = []; + + /** + * Append a leaf, returning its index (which equals `leaf.seq`). The leaf is + * canonical-hashed once and cached; the tree itself is recomputed on demand + * by `root()`/`proof()`, which keeps append O(1) and the structure trivially + * correct for any leaf count. + */ + append(leaf: AuditLeaf): number { + const index = this.storedLeaves.length; + this.storedLeaves.push({ ...leaf }); + this.leafHashes.push(hashLeaf(leaf)); + return index; + } + + /** + * Build all tree levels bottom-up from the cached leaf hashes. `levels[0]` is + * the leaf level; the last entry is the single-node root level. Returns an + * empty array for an empty tree (callers handle that as {@link EMPTY_ROOT}). + */ + private buildLevels(): string[][] { + if (this.leafHashes.length === 0) { + return []; + } + const levels: string[][] = [this.leafHashes.slice()]; + let current = levels[0]; + while (current.length > 1) { + const next: string[] = []; + for (let i = 0; i < current.length; i += 2) { + if (i + 1 < current.length) { + next.push(hashNode(current[i], current[i + 1])); + } else { + // Odd tail: promote the last node unchanged (no self-pairing). + next.push(current[i]); + } + } + levels.push(next); + current = next; + } + return levels; + } + + /** + * Merkle root over all leaf hashes. Empty tree → {@link EMPTY_ROOT} + * (`sha256('')`). Stable for a given leaf set regardless of how the tree is + * cached or recomputed. + */ + root(): string { + const levels = this.buildLevels(); + if (levels.length === 0) { + return EMPTY_ROOT; + } + return levels[levels.length - 1][0]; + } + + /** + * Inclusion proof for the leaf at `index`. Walks level by level, recording + * the sibling hash and which side it sits on. Where this node is the promoted + * odd tail (no sibling at that level), it records nothing and simply rises — + * the SAME rule `buildLevels` used, so `verify` reconstructs the identical + * root. Throws on an out-of-range index. + */ + proof(index: number): MerkleProof { + if (index < 0 || index >= this.storedLeaves.length) { + throw new Error(`Audit proof index out of range: ${index}`); + } + const levels = this.buildLevels(); + const siblings: { hash: string; side: 'left' | 'right' }[] = []; + let pos = index; + // Walk from the leaf level up to (but not including) the root level. + for (let level = 0; level < levels.length - 1; level += 1) { + const nodes = levels[level]; + const isRightChild = pos % 2 === 1; + const siblingPos = isRightChild ? pos - 1 : pos + 1; + if (siblingPos < nodes.length) { + siblings.push({ + hash: nodes[siblingPos], + // If we are the right child, the sibling is to our left. + side: isRightChild ? 'left' : 'right', + }); + } + // else: we are the promoted odd tail — no sibling, no concatenation. + pos = Math.floor(pos / 2); + } + return { leafHash: this.leafHashes[index], siblings }; + } + + /** + * Recompute the root from a proof and compare to `root`. Folds each sibling + * into the running hash on the recorded side, then checks equality. Returns + * false on any mismatch, so a tampered leaf or wrong-index proof fails. + */ + static verify(root: string, proof: MerkleProof): boolean { + let running = proof.leafHash; + for (const sib of proof.siblings) { + running = sib.side === 'left' ? hashNode(sib.hash, running) : hashNode(running, sib.hash); + } + return running === root; + } + + /** A defensive copy of every leaf in append order. */ + leaves(): AuditLeaf[] { + return this.storedLeaves.map((l) => ({ ...l })); + } + + /** Number of appended leaves. */ + size(): number { + return this.storedLeaves.length; + } + + /** + * Snapshot the accumulator as just its leaves — the tree (and every hash) is + * a pure function of them, so storing the leaves alone is sufficient and + * keeps the snapshot minimal. `restore` rebuilds the hashes deterministically. + */ + snapshot(): { leaves: AuditLeaf[] } { + return { leaves: this.leaves() }; + } + + /** + * Rebuild from a snapshot: drop all state, then re-append each leaf so the + * cached hashes are recomputed canonically. Two hosts restoring the same + * snapshot therefore reproduce the identical root. + */ + restore(snap: { leaves: AuditLeaf[] }): void { + this.storedLeaves.length = 0; + this.leafHashes.length = 0; + for (const leaf of snap.leaves) { + this.append(leaf); + } + } +} diff --git a/src/runtime/moduleHost.ts b/src/runtime/moduleHost.ts new file mode 100644 index 0000000..c75809d --- /dev/null +++ b/src/runtime/moduleHost.ts @@ -0,0 +1,767 @@ +import { createContext } from './context'; +import { moduleCodeHash } from './codeHash'; +import { canonicalBytes } from './determinism'; +import { KeyedModuleDefinition } from './keyedModule'; +import { AuditLeaf, MerkleAudit, MerkleProof } from './merkleAudit'; +import { + BudgetExceededError, + CompiledReducer, + compileReducer, + runReducer, + runReducerWithBudget, +} from './sandbox'; +import { KeyRegistry, SignablePayload, verifyCommand } from './signing'; +import { MemoryStateStore, StateStore, StoreView } from './stateStore'; +import { + EffectIntent, + EffectResultEntry, + ModuleApplyResult, + ModuleCommand, + ModuleDefinition, + ModuleHostOptions, + OutboxEntry, +} from './types'; + +/** Default ceiling on effects emitted by a single command. See `ModuleHostOptions`. */ +const DEFAULT_MAX_EFFECTS = 16; +/** Default ceiling on the canonical byte size of a command's next-state (64 KiB). */ +const DEFAULT_MAX_RESULT_BYTES = 64 * 1024; +/** Default ceiling on the buffered writes a single KEYED command may make. */ +const DEFAULT_MAX_WRITES = 256; +/** Default wall-clock step budget (ms) for the leader-side admission dry-run. */ +const DEFAULT_STEP_BUDGET_MS = 50; + +/** + * A registrable module: either a whole-state `ModuleDefinition` (no `kind`, the + * original model) or a key-oriented `KeyedModuleDefinition` (`kind: 'keyed'`, + * ADR-0019 pillar 4). The host discriminates on `kind`, defaulting absent to + * whole-state for back-compat. + */ +export type AnyModuleDefinition = ModuleDefinition | KeyedModuleDefinition; + +/** Narrow an `AnyModuleDefinition` to the keyed variant. */ +function isKeyed(def: AnyModuleDefinition): def is KeyedModuleDefinition { + return (def as KeyedModuleDefinition).kind === 'keyed'; +} + +/** + * Deep-clone via JSON round-trip. Used so snapshots are decoupled from live + * state (mutating a host after snapshotting must not retroactively change the + * snapshot, and vice versa). Module state is plain serializable data — the same + * assumption the consensus-core snapshots already make — so JSON is sufficient + * and avoids depending on `structuredClone` typings under the ES2017 lib target. + */ +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Registry of modules plus their live state (ADR-0019 pillars 1–2). This is the + * deterministic runtime that a later milestone will drive from the replicated + * `apply()` path. Today it stands alone: register modules, then `apply` commands + * whose seeds were resolved on the leader. Because the host derives every + * context from the command's seed, two hosts fed the same command stream reach + * identical state — the property the convergence test asserts. + */ +export class ModuleHost { + private readonly modules = new Map(); + /** Live per-module WHOLE-STATE blob, keyed by module name. */ + private readonly states = new Map(); + /** + * Per-module record store for KEYED modules (ADR-0019 pillar 4), keyed by + * module name. A keyed module has an entry here and NONE in `states`; a + * whole-state module is the reverse. The default `MemoryStateStore` is the + * in-memory backend; the `StateStore` seam is where a persistent embedded + * KV/LSM store would drop in (snapshots becoming store checkpoints). + */ + private readonly stores = new Map(); + /** + * The deterministic outbox (ADR-0019 pillar 3), keyed by `idempotencyKey`. + * Every host derives it from the same committed command stream, so it is + * itself replicated state — part of `snapshot()`/`restore()`. Keying on the + * idempotency key is what makes enqueue idempotent: a replayed command can + * re-run its reducer but never re-enqueue the same effect. + */ + private readonly outbox = new Map(); + /** + * Per-module code-version hash (ADR-0019 pillar 5), computed at `register()` + * from the module's logic. Every audited leaf stamps the hash of the module + * that produced it, so the history proves WHICH logic version ran. + */ + private readonly codeHashes = new Map(); + /** + * Compiled sandbox reducers for `sandbox: true` whole-state modules (ADR-0019 + * M9), keyed `module/command`. Built ONCE at `register()` by re-evaluating + * each reducer's source inside a fresh `vm` context with a frozen safe-global + * set. Present only for sandboxed modules; absent modules dispatch directly as + * before. Both the apply path (`runReducer`, no timeout) and leader-side + * admission (`runReducerWithBudget`, timeout) drive the SAME compiled handle. + */ + private readonly compiled = new Map(); + /** + * The Merkle audit accumulator (ADR-0019 pillar 5). Replicated state: it is + * derived purely from the applied command stream, so two hosts fed the same + * stream produce the same `auditRoot()`. Folded into `snapshot()`/`restore()`. + */ + private readonly audit = new MerkleAudit(); + /** + * Monotonic audit sequence, advanced once per audited leaf. Deterministic + * (driven only by the command stream), it is each leaf's `seq` / index. + */ + private auditSeq = 0; + + /** + * Deterministic resource bound (ADR-0019 pillar 6): the max effects one + * command may emit and the max canonical byte size of the next-state it may + * produce. Computed identically on every replica, so over-budget commands + * are rejected uniformly — NOT a CPU meter (that is deferred; needs a vm). + */ + private readonly maxEffects: number; + private readonly maxResultBytes: number; + private readonly maxWrites: number; + /** + * Default wall-clock step budget (ms) for the leader-side admission meter + * (ADR-0019 pillar 6 — M9), used when a sandboxed module does not set its own + * `stepBudgetMs`. Only the admission dry-run consults it; the apply path is + * always timeout-free. + */ + private readonly stepBudgetMs: number; + + /** + * Optional actor->public-key registry (ADR-0019 pillar 7). When SET, every + * caller `apply()` must carry a valid actor signature over its logical + * command or it is rejected (401) before its reducer runs. When UNSET + * (default), no verification happens and behavior is exactly as before — + * the back-compat guarantee that keeps all existing tests green. Configured + * per node before start (like modules), identical on every node, so + * verification converges. + */ + private keyRegistry?: KeyRegistry; + + constructor(opts: ModuleHostOptions = {}) { + this.maxEffects = opts.maxEffects ?? DEFAULT_MAX_EFFECTS; + this.maxResultBytes = opts.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; + this.maxWrites = opts.maxWrites ?? DEFAULT_MAX_WRITES; + this.stepBudgetMs = opts.stepBudgetMs ?? DEFAULT_STEP_BUDGET_MS; + } + + /** + * Configure the actor signature registry (ADR-0019 pillar 7). Call before + * start, identically on every node. Once set, caller commands are verified + * on the apply path; until set, verification is skipped (back-compat). + */ + setKeyRegistry(reg: KeyRegistry): void { + this.keyRegistry = reg; + } + + /** Authorize `publicKeyPem` as the signer for `actor`, creating the registry if needed. */ + registerActorKey(actor: string, publicKeyPem: string): void { + if (!this.keyRegistry) this.keyRegistry = new KeyRegistry(); + this.keyRegistry.registerActor(actor, publicKeyPem); + } + + /** + * Register a module and initialize its state. Accepts BOTH a whole-state + * `ModuleDefinition` and a key-oriented `KeyedModuleDefinition` (discriminated + * on `kind`, ADR-0019 pillar 4); a keyed module gets its own `StateStore` + * instead of a whole-state blob. Throws on a duplicate name. + */ + register(def: AnyModuleDefinition): void { + if (!def.name || def.name.trim() === '') { + throw new Error('Module definition requires a non-empty name'); + } + // Reject reserved `__`-prefixed names: the snapshot stores the outbox + // under the reserved `__outbox` key in the same flat module-states object, + // so a `__`-prefixed module would silently collide (its state overwritten + // on snapshot, misread as an outbox map on restore). Fail closed. + if (def.name.startsWith('__')) { + throw new Error( + `Module name "${def.name}" is reserved: names starting with "__" are reserved for runtime internals`, + ); + } + if (this.modules.has(def.name)) { + throw new Error(`Module "${def.name}" is already registered`); + } + // Sandbox is a WHOLE-STATE feature for this milestone (ADR-0019 M9): + // sandboxing keyed StoreView-mutating reducers is out of scope, so reject + // the combination at registration rather than silently ignore the flag. + if (isKeyed(def) && (def as { sandbox?: boolean }).sandbox) { + throw new Error( + `Module "${def.name}": the determinism sandbox (sandbox: true) is supported ` + + `for whole-state modules only in this milestone, not keyed modules`, + ); + } + + this.modules.set(def.name, def); + if (isKeyed(def)) { + // Keyed module: give it an empty record store. The default in-memory + // backend; the StateStore interface is the persistent-KV seam. + this.stores.set(def.name, new MemoryStateStore()); + } else { + this.states.set(def.name, def.initialState()); + // If the module opted into the sandbox, compile each reducer ONCE now + // into its own frozen vm context (ADR-0019 M9). A non-self-contained + // reducer source (e.g. method-shorthand) fails loudly here, at + // registration, rather than at first dispatch. + if (def.sandbox) { + for (const [name, reducer] of Object.entries(def.commands)) { + this.compiled.set(`${def.name}/${name}`, compileReducer(reducer.toString())); + } + } + } + // Stamp the module's logic version now; every leaf it later produces + // records this hash (ADR-0019 pillar 5). + this.codeHashes.set(def.name, moduleCodeHash(def)); + } + + /** Register several modules (whole-state and/or keyed) in order. */ + registerModules(defs: AnyModuleDefinition[]): void { + for (const def of defs) this.register(def); + } + + /** + * Dispatch a command: look up the module + reducer, build a deterministic + * context from the command's seed, run the (pure) reducer against current + * state, and adopt the returned state. Failures are returned as status codes + * rather than thrown, mirroring the state machine's HTTP-style results, so a + * single bad command never crashes the apply loop. + */ + apply(cmd: ModuleCommand, meta: { actor: string; requestId: string }): ModuleApplyResult { + // Actor signature verification (ADR-0019 pillar 7), if a registry is + // configured. This runs FIRST so a forged/tampered/unsigned command never + // reaches its reducer and never touches state or the outbox. Verification + // is a pure, deterministic function of the committed command + meta + // (canonical JSON + ed25519 verify, both deterministic), so every replica + // rejects identically — the cluster cannot diverge on a bad signature. + const denied = this.verifySignature(cmd, meta); + if (denied) { + // Audit the rejection at 401 for uniformity with other failure + // statuses (a forged command IS part of the history), but do NOT run + // the reducer and do NOT mutate state/outbox. + this.recordAudit(cmd, meta, denied.status); + return denied; + } + + const result = this.dispatch(cmd, meta); + if (result.status === 200) { + // Record each emitted effect into the outbox as `pending`, but only if + // its key is unknown — a replayed command must re-run deterministically + // without re-enqueuing the same effect (the exactly-once dedup point). + for (const intent of result.effects) { + if (!this.outbox.has(intent.idempotencyKey)) { + this.outbox.set(intent.idempotencyKey, { intent, status: 'pending' }); + } + } + } + return result; + } + + /** + * Leader-side admission / step meter (ADR-0019 pillar 6 "gas" — M9). A + * pre-log DRY RUN: the LEADER calls this BEFORE submitting a command to the + * log; if it returns `ok: false`, the command is rejected and never enters the + * log. Followers do NOT re-admit — under the CFT trust model they trust that a + * committed entry was already admitted by the leader, which is exactly why the + * apply path stays timeout-free and deterministic. + * + * For a SANDBOXED module it runs the reducer inside the vm with a wall-clock + * `stepBudgetMs` timeout against a CLONE of current state — NO mutation, NO + * commit, NO effects enqueued, NO audit. A runaway reducer (e.g. an infinite + * loop) trips the vm timeout and is reported as `503` (`BudgetExceededError`); + * any other reducer throw is reported as `500` — the SAME status the apply + * path (`dispatch`) gives that deterministic throw, so admission and apply + * stay in status parity. Because the dry run touches a clone and discards its + * result, the timeout's non-determinism never reaches replicated state. + * + * For a NON-SANDBOXED module admission is a NO-OP that returns ok: the step + * meter is a sandbox feature (a direct-execution reducer cannot be safely + * interrupted by the vm), and those modules already gate fan-out/size on the + * deterministic apply path. Unknown module/command also admit ok and surface + * their 404 on apply, keeping admission strictly about the CPU/step budget. + */ + admit( + cmd: ModuleCommand, + meta: { actor: string; requestId: string }, + ): { ok: true } | { ok: false; status: number; message: string } { + const compiled = this.compiled.get(`${cmd.module}/${cmd.command}`); + if (!compiled) { + // Not a sandboxed command (non-sandboxed module, keyed module, or + // unknown module/command): nothing to meter, admit and let apply run. + return { ok: true }; + } + + const def = this.modules.get(cmd.module) as ModuleDefinition; + const budgetMs = def.stepBudgetMs ?? this.stepBudgetMs; + const ctx = createContext(cmd.seed, meta); + // Clone current state so the dry run cannot mutate committed state even if + // the reducer mutates its argument in place before looping/throwing. + const working = deepClone(this.states.get(cmd.module)); + + try { + runReducerWithBudget(compiled, working, cmd.input, ctx, budgetMs); + return { ok: true }; + } catch (err) { + if (err instanceof BudgetExceededError) { + return { + ok: false, + status: 503, + message: + `Command "${cmd.command}" on module "${cmd.module}" exceeded the ` + + `${budgetMs}ms step budget and was rejected before entering the log`, + }; + } + // Any other reducer error during admission: reject too, so a command + // that would deterministically 500 on apply is caught at the edge. + // Report 500 (NOT 400) for admit/apply STATUS PARITY: the apply path + // (`dispatch`) maps the same deterministic reducer throw to 500, so the + // pre-log gate must surface the identical status it would get on apply. + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: 500, + message: `Command "${cmd.command}" on module "${cmd.module}" failed admission: ${message}`, + }; + } + } + + /** + * Verify a caller command's actor signature (ADR-0019 pillar 7). Returns a + * deterministic 401 `ModuleApplyResult` to REJECT, or `undefined` to ALLOW. + * + * Back-compat: with no registry configured, always allows (returns + * `undefined`) — verification is opt-in, so existing unsigned flows are + * untouched. With a registry configured, a command is allowed ONLY if it + * carries a `sig` AND the actor has a registered key AND the signature + * verifies over the canonical LOGICAL payload (excluding `seed`). A missing + * signature, an unknown actor, or an invalid signature all reject — this is + * what stops a leader forging `actor`. + */ + private verifySignature( + cmd: ModuleCommand, + meta: { actor: string; requestId: string }, + ): ModuleApplyResult | undefined { + if (!this.keyRegistry) return undefined; // back-compat: no verification + + if (!cmd.sig) { + return { status: 401, effects: [], message: 'Missing actor signature' }; + } + const publicKey = this.keyRegistry.get(meta.actor); + if (!publicKey) { + return { status: 401, effects: [], message: `No registered key for actor "${meta.actor}"` }; + } + // Reconstruct the EXACT logical payload the actor signed — the seed is + // deliberately excluded (the leader adds it after signing). + const payload: SignablePayload = { + module: cmd.module, + command: cmd.command, + input: cmd.input, + actor: meta.actor, + requestId: meta.requestId, + }; + if (!verifyCommand(publicKey, payload, cmd.sig)) { + return { status: 401, effects: [], message: 'Invalid actor signature' }; + } + return undefined; // verified + } + + /** + * The reducer dispatch path, shared by `apply` (caller commands) and + * `applyEffectResult` (the committed `onResult` follow-up). It runs a pure + * reducer against a clone of current state and adopts the result atomically; + * it does NOT touch the outbox so callers can layer their own bookkeeping. + */ + private dispatch(cmd: ModuleCommand, meta: { actor: string; requestId: string }): ModuleApplyResult { + const def = this.modules.get(cmd.module); + if (!def) { + return { status: 404, effects: [], message: `Unknown module: ${cmd.module}` }; + } + + if (!def.commands[cmd.command]) { + return { + status: 404, + effects: [], + message: `Unknown command "${cmd.command}" on module "${cmd.module}"`, + }; + } + + // Keyed modules take a separate, transactional (StoreView) path. + if (isKeyed(def)) { + return this.dispatchKeyed(def, cmd, meta); + } + + const reducer = def.commands[cmd.command]; + const ctx = createContext(cmd.seed, meta); + // Hand the reducer a deep clone, never the live reference: a reducer that + // mutates `state` in place and then throws must not corrupt committed + // host state. Live state is replaced atomically only on a clean return. + const working = deepClone(this.states.get(cmd.module)); + + // The reducer is about to run, so this command WILL be audited regardless + // of its outcome (success, business-failure status, or thrown→500). The + // earlier unknown-module/unknown-command 404s returned before this point + // and are intentionally NOT audited — no logic ran for them. + // For a sandboxed module, run the reducer inside its frozen vm context on + // the APPLY path with NO timeout (ADR-0019 M9): the call must be a pure, + // deterministic function of (state, input, ctx) so every replica computes + // the same next-state. A banned global (Date, Math.random, …) is absent in + // the sandbox and throws here, caught below as a 500 — structurally, not + // by the lint. Non-sandboxed modules run the reducer directly, as before. + const sandboxed = this.compiled.get(`${cmd.module}/${cmd.command}`); + + let outcome: ModuleApplyResult; + try { + const result = sandboxed + ? runReducer(sandboxed, working, cmd.input, ctx) + : reducer(working, cmd.input, ctx); + const effects = result.effects ?? []; + + // Deterministic resource bound (ADR-0019 pillar 6). Computed AFTER the + // reducer returns, from its output alone — every replica derives the + // same effect count and the same canonical byte size, so an + // over-budget command is rejected identically everywhere (no + // divergence). On rejection we DO NOT adopt the next-state and DO NOT + // surface the effects: it is treated as a failed apply, audited with a + // 413 status for uniformity (consistent with a business-failure + // status), leaving committed state and the outbox untouched. + if (effects.length > this.maxEffects) { + outcome = { + status: 413, + effects: [], + message: + `Command "${cmd.command}" on module "${cmd.module}" emitted ${effects.length} ` + + `effects, exceeding the limit of ${this.maxEffects}`, + }; + } else { + const stateBytes = canonicalBytes(result.state); + if (stateBytes > this.maxResultBytes) { + outcome = { + status: 413, + effects: [], + message: + `Command "${cmd.command}" on module "${cmd.module}" produced a ${stateBytes}-byte ` + + `state, exceeding the limit of ${this.maxResultBytes} bytes`, + }; + } else { + // Under budget: adopt the next-state and surface the reducer's + // explicit `result` value (not the whole ReducerResult). + this.states.set(cmd.module, result.state); + outcome = { status: 200, result: result.result, effects }; + } + } + } catch (err) { + // A throwing reducer must not corrupt state or halt the host; report it. + const message = err instanceof Error ? err.message : String(err); + outcome = { status: 500, effects: [], message }; + } + + this.recordAudit(cmd, meta, outcome.status); + return outcome; + } + + /** + * The KEYED reducer dispatch path (ADR-0019 pillar 4). Mirrors `dispatch`'s + * atomicity and resource-bound envelope, but the reducer mutates a + * transactional {@link StoreView} instead of returning a next-state blob: + * + * - Build a copy-on-write `StoreView` over the module's `StateStore`. The + * reducer reads through it (clones, reads-its-own-writes) and buffers any + * `put`/`delete` — nothing reaches the store yet. + * - On a CLEAN, in-budget return, `commit()` the view atomically. On a thrown + * reducer OR a budget rejection, DISCARD the view (drop it) so the store is + * untouched — the same all-or-nothing guarantee whole-state modules get. + * - Resource bounds (deterministic, computed from the buffer after return): + * `maxEffects` (fan-out), `maxWrites` (records touched), and `maxResultBytes` + * over the canonical bytes of the buffered puts (write amplification). Over + * budget ⇒ 413, view discarded. + * - Audit exactly as the whole-state path (one envelope leaf per run). + */ + private dispatchKeyed( + def: KeyedModuleDefinition, + cmd: ModuleCommand, + meta: { actor: string; requestId: string }, + ): ModuleApplyResult { + const reducer = def.commands[cmd.command]; + const ctx = createContext(cmd.seed, meta); + const store = this.stores.get(cmd.module)!; + // The view buffers all writes; the live store is touched only on commit(). + const view = new StoreView(store); + + let outcome: ModuleApplyResult; + try { + const result = reducer(view, cmd.input, ctx); + const effects = result.effects ?? []; + + if (effects.length > this.maxEffects) { + // Over fan-out budget: discard the view (no commit), 413. + outcome = { + status: 413, + effects: [], + message: + `Command "${cmd.command}" on module "${cmd.module}" emitted ${effects.length} ` + + `effects, exceeding the limit of ${this.maxEffects}`, + }; + } else if (view.pendingWriteCount() > this.maxWrites) { + // Over write-count budget: discard the view, 413. + outcome = { + status: 413, + effects: [], + message: + `Command "${cmd.command}" on module "${cmd.module}" buffered ` + + `${view.pendingWriteCount()} writes, exceeding the limit of ${this.maxWrites}`, + }; + } else { + const writeBytes = canonicalBytes(view.pendingPuts()); + if (writeBytes > this.maxResultBytes) { + // Over write-size budget: discard the view, 413. + outcome = { + status: 413, + effects: [], + message: + `Command "${cmd.command}" on module "${cmd.module}" buffered ${writeBytes} bytes ` + + `of writes, exceeding the limit of ${this.maxResultBytes} bytes`, + }; + } else { + // Clean + in budget: commit the buffer atomically and surface + // the reducer's explicit result value. + view.commit(); + outcome = { status: 200, result: result.result, effects }; + } + } + } catch (err) { + // A throwing keyed reducer commits NOTHING: the view is dropped here + // with its buffer unapplied, so the store is exactly as before. + const message = err instanceof Error ? err.message : String(err); + outcome = { status: 500, effects: [], message }; + } + + this.recordAudit(cmd, meta, outcome.status); + return outcome; + } + + /** + * Append one audit leaf for a command whose reducer ran. The leaf is a pure + * function of the command + meta + outcome status + the producing module's + * code hash, so every replica appends an identical leaf and the Merkle root + * stays convergent. `seq` is the monotonic, deterministic audit index. + */ + private recordAudit(cmd: ModuleCommand, meta: { actor: string; requestId: string }, status: number): void { + const leaf: AuditLeaf = { + seq: this.auditSeq, + module: cmd.module, + command: cmd.command, + actor: meta.actor, + requestId: meta.requestId, + status, + codeHash: this.codeHashes.get(cmd.module) ?? '', + }; + this.audit.append(leaf); + this.auditSeq += 1; + } + + /** All outbox entries still awaiting execution at the edge. */ + pendingEffects(): EffectIntent[] { + const out: EffectIntent[] = []; + for (const name of [...this.outbox.keys()].sort()) { + const entry = this.outbox.get(name)!; + if (entry.status === 'pending') { + // Deep-clone (like getOutbox) so a handler that mutates the intent + // it receives cannot mutate the committed outbox state behind it. + out.push(deepClone(entry.intent)); + } + } + return out; + } + + /** + * Apply a committed `EffectResultEntry` — the follow-up entry the executor + * fed back after performing the effect at the edge. This runs on the + * deterministic apply path on EVERY replica, so it must be a pure function of + * the entry + current state, and it MUST be idempotent: a redelivered or + * replayed result must not re-dispatch the `onResult` reducer. + * + * Idempotency rule: if the key is unknown or already `done`, no-op (200, no + * dispatch). Otherwise mark the entry `done`, store the edge-resolved + * `result`, and — if the intent named an `onResult` command — dispatch it + * through the normal reducer path with the entry's leader-resolved `seed`, so + * the consuming reducer stays deterministic. + */ + applyEffectResult(entry: EffectResultEntry, meta: { actor: string; requestId: string }): ModuleApplyResult { + // First-applied wins. A handler retry that fires before the first result + // is applied can commit multiple `EffectResultEntry` values for one key + // (each drain that completes submits one). That is fine: the FIRST entry + // applied flips the key to `done`; every later entry for that key hits the + // `done` guard below and no-ops. Convergence holds because the committed + // log order is identical on every replica, so all replicas apply the same + // "first" entry and discard the same rest. + const slot = this.outbox.get(entry.idempotencyKey); + if (!slot || slot.status === 'done') { + // Unknown or already-applied: exactly-once at the state level means + // this is a harmless no-op, never a second `onResult` dispatch. + return { status: 200, effects: [] }; + } + + slot.status = 'done'; + slot.result = entry.result; + + const onResult = slot.intent.onResult; + if (!onResult) { + return { status: 200, effects: [] }; + } + + // Feed the edge-resolved result to the consuming reducer. We go through + // `apply` (not bare `dispatch`) so any effects the onResult reducer emits + // are themselves enqueued into the outbox. + return this.apply( + { + module: onResult.module, + command: onResult.command, + input: { idempotencyKey: entry.idempotencyKey, result: entry.result }, + seed: entry.seed, + }, + meta, + ); + } + + /** + * The outbox as a list in deterministic (sorted-by-key) order. For tests and + * inspection; the canonical store is the keyed map. + */ + getOutbox(): OutboxEntry[] { + return [...this.outbox.keys()].sort().map((k) => deepClone(this.outbox.get(k)!)); + } + + /** Run a read query against current state. Never mutates. */ + query(module: string, name: string, args?: unknown): unknown { + const def = this.modules.get(module); + if (!def) { + throw new Error(`Unknown module: ${module}`); + } + const q = def.queries?.[name]; + if (!q) { + throw new Error(`Unknown query "${name}" on module "${module}"`); + } + if (isKeyed(def)) { + // A keyed query reads the module's StateStore (reads return clones). + return q(this.stores.get(module)!, args); + } + return (q as (state: unknown, args: unknown) => unknown)(this.states.get(module), args); + } + + /** Current live state of a module (for tests/snapshots). */ + getState(module: string): unknown { + return this.states.get(module); + } + + /** + * The `StateStore` backing a keyed module (for tests/inspection). Returns the + * LIVE store, not a copy — callers must NOT mutate it in production code + * (writes must go through `apply()` so they are audited and replicated); + * direct mutation here would diverge a replica. + */ + getStore(module: string): StateStore | undefined { + return this.stores.get(module); + } + + /** + * Serializable map of module name -> deep-cloned state. Keys are emitted in + * sorted order so a `JSON.stringify` over the snapshot is stable regardless + * of module registration order (helps the later audit/hash-chain milestone). + */ + snapshot(): Record { + const states: Record = {}; + for (const name of [...this.states.keys()].sort()) { + states[name] = deepClone(this.states.get(name)); + } + // KEYED modules contribute their store dump (SORTED entries) under the + // module name, alongside whole-state blobs. `store.snapshot()` already + // returns sorted, cloned entries — deterministic across replicas. + for (const name of [...this.stores.keys()].sort()) { + states[name] = this.stores.get(name)!.snapshot(); + } + // The outbox is replicated state too: emit it under a reserved key, also + // in sorted-key order so `JSON.stringify` over the snapshot is stable. + const outbox: Record = {}; + for (const key of [...this.outbox.keys()].sort()) { + outbox[key] = deepClone(this.outbox.get(key)!); + } + // The Merkle audit + its seq are replicated state too: emit them under the + // reserved `__audit` key (collision-safe — `__`-prefixed module names are + // rejected at registration). Storing the leaves alone is sufficient; the + // tree and root are a pure function of them, rebuilt on restore. + const auditSnap = { seq: this.auditSeq, ...this.audit.snapshot() }; + return { ...states, __outbox: outbox, __audit: auditSnap }; + } + + /** + * Replace the state of registered modules from a snapshot. Only modules that + * are both registered AND present in the snapshot are restored; unknown keys + * in the snapshot are ignored (a module may have been removed), and modules + * absent from the snapshot keep their initialized state. + */ + restore(snap: Record): void { + for (const [name, def] of this.modules) { + if (!Object.prototype.hasOwnProperty.call(snap, name)) continue; + if (isKeyed(def)) { + // Route by registered kind: a keyed module restores its store from + // the dumped [key, value][] entries (cloned by the store on restore). + const entries = (snap[name] as [string, unknown][]) ?? []; + this.stores.get(name)!.restore(entries); + } else { + this.states.set(name, deepClone(snap[name])); + } + } + // Rebuild the outbox from its reserved key. Replacing wholesale (not + // merging) keeps restore a faithful point-in-time reconstruction. + this.outbox.clear(); + const saved = snap.__outbox; + if (saved && typeof saved === 'object') { + for (const [key, entry] of Object.entries(saved as Record)) { + this.outbox.set(key, deepClone(entry)); + } + } + // Rebuild the Merkle audit from its reserved key. The accumulator + // recomputes all leaf hashes (and thus the root) deterministically, so a + // restored host reports the identical `auditRoot()`. + const auditSaved = snap.__audit as { seq?: number; leaves?: AuditLeaf[] } | undefined; + this.audit.restore({ leaves: auditSaved?.leaves ?? [] }); + this.auditSeq = auditSaved?.seq ?? this.audit.size(); + } + + // ---- audit access (ADR-0019 pillar 5) ---- + + /** + * The compact Merkle root over all audited commands. A single hash that + * summarizes the entire history and can be externally anchored; identical on + * every replica that applied the same command stream. + */ + auditRoot(): string { + return this.audit.root(); + } + + /** + * An O(log n) inclusion proof that the leaf at `seq` is part of the audited + * history. Verifies against `auditRoot()` via `MerkleAudit.verify`. + */ + auditProof(seq: number): MerkleProof { + return this.audit.proof(seq); + } + + /** Every audit leaf in append order (for inspection/tests). */ + auditEntries(): AuditLeaf[] { + return this.audit.leaves(); + } + + /** Number of audited commands. */ + auditSize(): number { + return this.audit.size(); + } + + /** + * The code-version hash recorded for a module (or `undefined` if the module + * is not registered). The same value every audited leaf from that module + * carries — letting a verifier confirm which logic version produced a result. + */ + moduleCodeHash(module: string): string | undefined { + return this.codeHashes.get(module); + } +} diff --git a/src/runtime/moduleStateMachine.ts b/src/runtime/moduleStateMachine.ts new file mode 100644 index 0000000..4c10335 --- /dev/null +++ b/src/runtime/moduleStateMachine.ts @@ -0,0 +1,59 @@ +import { StateMachine } from '../consensus/stateMachine'; +import { ApplyResult } from '../consensus/types'; +import { ModuleHost } from './moduleHost'; +import { ModuleAppCommand } from './types'; + +/** + * Adapts a {@link ModuleHost} to the framework's {@link StateMachine} contract + * (ADR-0017) so the whole module runtime — pure reducers, effects/outbox, the + * Merkle audit, the keyed store, signing — plugs into a `RaftNode` as an ordinary + * pluggable application: + * + * const sm = new ModuleStateMachine(); + * sm.host.registerModules([counter, notes]); + * const node = new RaftNode({ id, peers, stateMachine: sm }, transport); + * + * This is the seam ADR-0021 calls the real pluggability point — the + * commit-ordered-log contract above the log. The module runtime no longer needs + * a bespoke `MODULE` command baked into the consensus core: a `ModuleAppCommand` + * is just an application command `C`, and this adapter is the application. + * + * The only impedance to bridge is that `StateMachine.apply(command)` receives no + * separate `CommandMeta` (audit/idempotency are the substrate's concern), whereas + * `ModuleHost.apply(cmd, meta)` needs `actor`/`requestId` (for the audit leaf and + * signature verification). We therefore carry those two fields ON the command + * (see {@link ModuleAppCommand}) and split them back out here. `ModuleHost` + * itself is untouched. + */ +export class ModuleStateMachine implements StateMachine { + /** The wrapped runtime — use it to register modules/keys and to read state. */ + readonly host: ModuleHost; + + constructor(host: ModuleHost = new ModuleHost()) { + this.host = host; + } + + apply(command: ModuleAppCommand): ApplyResult { + const result = this.host.apply( + { + module: command.module, + command: command.command, + input: command.input, + seed: command.seed, + sig: command.sig, + }, + { actor: command.actor, requestId: command.requestId }, + ); + // The runtime's `effects` stay in the host's outbox (drained post-commit + // by the EffectExecutor); the substrate only needs status/data/message. + return { status: result.status, data: result.result, message: result.message }; + } + + snapshot(): unknown { + return this.host.snapshot(); + } + + restore(data: unknown): void { + this.host.restore(data as Record); + } +} diff --git a/src/runtime/modules/accounts.ts b/src/runtime/modules/accounts.ts new file mode 100644 index 0000000..507717e --- /dev/null +++ b/src/runtime/modules/accounts.ts @@ -0,0 +1,128 @@ +import { defineKeyedModule } from '../keyedModule'; + +/** + * A demo KEYED module (ADR-0019 pillar 4, "state larger than RAM"): a ledger of + * accounts where state is ONE RECORD PER ACCOUNT, addressed by account id. Every + * command touches only the keys it needs — `deposit` reads/writes a single + * account, `transfer` exactly two — so the whole ledger is NEVER loaded as a + * blob. That is the per-key access the in-memory `Map` model could not express; + * it is the property a persistent embedded KV/LSM backend exploits to scale state + * past RAM behind the same `StateStore` seam. + * + * Determinism: `openedAt` comes from `ctx.now` (the leader-resolved seed), never + * an ambient `Date` — the same purity contract every reducer obeys. + */ + +/** One account record, stored at key = its id. */ +interface Account { + id: string; + balance: number; + openedAt: string; +} + +export const accounts = defineKeyedModule({ + name: 'accounts', + version: '1', + commands: { + /** + * Open an account at key = id. Idempotent-ish: rejects 400 if the key is + * already present rather than silently overwriting an existing balance. + */ + open: (store, input, ctx) => { + const { id } = (input ?? {}) as { id: string }; + if (!id) { + throw new Error('open requires an id'); + } + if (store.has(id)) { + throw new Error(`account "${id}" already exists`); + } + const account: Account = { id, balance: 0, openedAt: ctx.now }; + store.put(id, account); + return { result: account }; + }, + + /** Read the single account by key, add `amount`, write it back. */ + deposit: (store, input) => { + const { id, amount } = (input ?? {}) as { id: string; amount: number }; + if (!(amount > 0)) { + throw new Error('deposit amount must be positive'); + } + const account = store.get(id) as Account | undefined; + if (!account) { + throw new Error(`account "${id}" not found`); + } + account.balance += amount; + store.put(id, account); + return { result: account }; + }, + + /** + * Read the single account by key, subtract `amount`, write it back. The + * single-key debit half of a CROSS-SHARD transfer (ADR-0020): a cross-shard + * transfer cannot be one `transfer` command (the two accounts live in + * different Raft groups), so the saga composes `withdraw` on the source + * shard with `deposit` on the target shard. Rejects (non-200) on a missing + * account or insufficient balance so the saga can compensate; because the + * host commits the StoreView only on a clean return, a rejection writes + * nothing — the balance is unchanged, never partially debited. + */ + withdraw: (store, input) => { + const { id, amount } = (input ?? {}) as { id: string; amount: number }; + if (!(amount > 0)) { + throw new Error('withdraw amount must be positive'); + } + const account = store.get(id) as Account | undefined; + if (!account) { + throw new Error(`account "${id}" not found`); + } + if (account.balance < amount) { + throw new Error(`insufficient balance in "${id}"`); + } + account.balance -= amount; + store.put(id, account); + return { result: account }; + }, + + /** + * Move funds between two accounts, touching ONLY the two involved keys. + * Rejects on a missing account or insufficient balance; because the host + * commits the StoreView atomically, a rejection (thrown) writes neither + * side — no partial transfer. + */ + transfer: (store, input) => { + const { from, to, amount } = (input ?? {}) as { from: string; to: string; amount: number }; + if (!(amount > 0)) { + throw new Error('transfer amount must be positive'); + } + if (from === to) { + throw new Error('cannot transfer to the same account'); + } + const src = store.get(from) as Account | undefined; + if (!src) { + throw new Error(`account "${from}" not found`); + } + const dst = store.get(to) as Account | undefined; + if (!dst) { + throw new Error(`account "${to}" not found`); + } + if (src.balance < amount) { + throw new Error(`insufficient balance in "${from}"`); + } + src.balance -= amount; + dst.balance += amount; + store.put(from, src); + store.put(to, dst); + return { result: { from: src, to: dst } }; + }, + }, + queries: { + /** Balance of one account, or `undefined` if it does not exist. */ + balance: (store, args) => { + const { id } = (args ?? {}) as { id: string }; + const account = store.get(id) as Account | undefined; + return account?.balance; + }, + /** Number of accounts in the ledger. */ + count: (store) => store.size(), + }, +}); diff --git a/src/runtime/modules/compute.ts b/src/runtime/modules/compute.ts new file mode 100644 index 0000000..f7cf962 --- /dev/null +++ b/src/runtime/modules/compute.ts @@ -0,0 +1,68 @@ +import { defineModule } from '../defineModule'; + +/** + * A demo SANDBOXED whole-state module (ADR-0019 pillars 2, 6 — M9). Defined with + * `{ sandbox: true }`, so the host re-compiles each reducer into a frozen `vm` + * context at registration: the reducers below run with NO ambient `Date`, + * `Math.random`, `crypto`, timers, etc. — determinism is enforced STRUCTURALLY, + * not merely by the static lint. + * + * Reducers MUST be self-contained arrow/`function` expressions that reference + * only their parameters, `ctx`, and the curated safe globals (`Object`, `Array`, + * `Math` without `random`, `JSON`, …). They derive any time/randomness/ids from + * `ctx`, exactly like the non-sandboxed demos — the sandbox merely makes a + * regression (reaching for `Date.now()`) impossible to apply silently. + */ +interface ComputeState { + /** Last computed value (deterministic). */ + last: number; + /** A deterministic id stamped on the last computation, from `ctx.id()`. */ + lastId: string; +} + +/** Input to `sumTo`: compute the bounded sum 1..n. */ +interface SumToInput { + n: number; +} + +export const compute = defineModule( + { + name: 'compute', + initialState: () => ({ last: 0, lastId: '' }), + commands: { + // A terminating arithmetic reducer: the triangular number 1..n. Bounded + // by a hard cap so it always halts well within any step budget. Uses + // ctx.id() (deterministic) — never crypto/Date — to stamp the result. + sumTo: (state, input, ctx) => { + const n = Math.max(0, Math.min(((input ?? {}) as SumToInput).n | 0, 100000)); + let total = 0; + for (let i = 1; i <= n; i++) { + total += i; + } + return { + state: { last: total, lastId: ctx.id() }, + result: { sum: total }, + }; + }, + // TEST FIXTURE ONLY — do not call on a real apply path. `spin` loops + // forever; it exists solely to prove the leader-side admission step + // meter (`ModuleHost.admit`) interrupts a runaway reducer via the vm + // timeout and rejects it BEFORE it could enter the log. On the apply + // path (no timeout) this would hang, which is precisely why admission + // must reject it first. + spin: (state) => { + // eslint-disable-next-line no-constant-condition + while (true) { + // busy-loop; the vm wall-clock budget interrupts this during + // the leader's admission dry-run. + } + // Unreachable, but keeps the reducer a well-formed expression. + return { state }; + }, + }, + queries: { + last: (state) => state.last, + }, + }, + { sandbox: true }, +); diff --git a/src/runtime/modules/counter.ts b/src/runtime/modules/counter.ts new file mode 100644 index 0000000..fa8b30f --- /dev/null +++ b/src/runtime/modules/counter.ts @@ -0,0 +1,26 @@ +import { defineModule } from '../defineModule'; + +/** A trivial demo module: a single integer with increment/reset commands. */ +interface CounterState { + value: number; +} + +/** Input to `increment`: an optional step (defaults to 1). */ +interface IncrementInput { + by?: number; +} + +export const counter = defineModule({ + name: 'counter', + initialState: () => ({ value: 0 }), + commands: { + increment: (state, input) => { + const { by = 1 } = (input ?? {}) as IncrementInput; + return { state: { value: state.value + by } }; + }, + reset: () => ({ state: { value: 0 } }), + }, + queries: { + value: (state) => state.value, + }, +}); diff --git a/src/runtime/modules/notes.ts b/src/runtime/modules/notes.ts new file mode 100644 index 0000000..b5f921e --- /dev/null +++ b/src/runtime/modules/notes.ts @@ -0,0 +1,45 @@ +import { defineModule } from '../defineModule'; + +/** A single note. Its `id` and `createdAt` come from the deterministic ctx. */ +interface Note { + id: string; + text: string; + createdAt: string; +} + +interface NotesState { + notes: Note[]; +} + +/** Input to `create`. */ +interface CreateInput { + text: string; +} + +/** + * A demo module that mints ids and timestamps. It exists to prove the central + * runtime contract: the id and createdAt below are NOT pulled from `crypto` or + * `Date` inside the reducer — they flow from `ctx`, which is rebuilt identically + * on every replica from the leader-resolved seed. Replace ctx with ambient + * randomness here and the convergence test would fail. + */ +export const notes = defineModule({ + name: 'notes', + initialState: () => ({ notes: [] }), + commands: { + create: (state, input, ctx) => { + const { text } = (input ?? {}) as CreateInput; + const note: Note = { + id: ctx.id(), + text, + createdAt: ctx.now, + }; + // Return the created note as the explicit `result` so callers get it + // directly at `apply().result`, decoupled from the next-state object. + return { state: { notes: [...state.notes, note] }, result: note }; + }, + }, + queries: { + list: (state) => state.notes, + }, +}); diff --git a/src/runtime/modules/payments.ts b/src/runtime/modules/payments.ts new file mode 100644 index 0000000..2a1bb53 --- /dev/null +++ b/src/runtime/modules/payments.ts @@ -0,0 +1,78 @@ +import { defineModule } from '../defineModule'; + +/** An order tracked through the charge lifecycle. */ +interface Order { + id: string; + amount: number; + status: 'pending' | 'paid' | 'failed'; +} + +interface PaymentsState { + orders: Record; +} + +/** Input to `charge`. */ +interface ChargeInput { + orderId: string; + amount: number; +} + +/** + * Input to `settle`, the `onResult` target. The host feeds it the effect's key + * plus the edge-resolved `result`; here `result` carries the gateway outcome. + */ +interface SettleInput { + idempotencyKey: string; + result: { orderId: string; ok: boolean }; +} + +/** + * A demo module for the committed-intent effect model (ADR-0019 pillar 3). + * + * `charge` is a pure reducer: it records the order as `pending` and EMITS an + * effect intent rather than calling a payment gateway itself. The intent commits + * to the log via the outbox; the edge executor performs the real charge and feeds + * the outcome back, which `settle` folds into state. Both reducers are pure — the + * only side effect lives in the executor's handler, off the deterministic path. + */ +export const payments = defineModule({ + name: 'payments', + initialState: () => ({ orders: {} }), + commands: { + charge: (state, input, ctx) => { + const { orderId, amount } = (input ?? {}) as ChargeInput; + const order: Order = { id: orderId, amount, status: 'pending' }; + return { + state: { orders: { ...state.orders, [orderId]: order } }, + result: order, + // `ctx.id()` is deterministic, so the idempotency key is identical + // on every replica — the outbox dedups consistently across hosts. + effects: [ + { + kind: 'http', + idempotencyKey: ctx.id(), + payload: { orderId, amount }, + onResult: { module: 'payments', command: 'settle' }, + }, + ], + }; + }, + settle: (state, input) => { + const { result } = (input ?? {}) as SettleInput; + const existing = state.orders[result.orderId]; + if (!existing) { + // Nothing to settle; return state unchanged. Pure no-op. + return { state }; + } + const settled: Order = { ...existing, status: result.ok ? 'paid' : 'failed' }; + return { + state: { orders: { ...state.orders, [result.orderId]: settled } }, + result: settled, + }; + }, + }, + queries: { + order: (state, args) => state.orders[(args as { orderId: string }).orderId], + list: (state) => Object.values(state.orders), + }, +}); diff --git a/src/runtime/projection.ts b/src/runtime/projection.ts new file mode 100644 index 0000000..29b07ae --- /dev/null +++ b/src/runtime/projection.ts @@ -0,0 +1,98 @@ +/** + * CQRS read-projection layer (ADR-0019 pillar 4: "Reads / rich queries / big + * data"). + * + * The authoritative state is the replicated log applied by `ModuleHost` — that + * is the source of truth and the only thing consensus commits. This layer is the + * OTHER half of CQRS: a DERIVED, indexed read model built by folding the stream + * of committed module commands. It exists to answer rich queries the raw module + * state does not index (e.g. "all note ids by a given actor") without bloating + * the deterministic apply path or the snapshots the consensus core replicates. + * + * WHY SEPARATE: a projection is a CACHE, not a database. It is never consulted + * to decide whether a command is valid, never feeds back into the log, and can + * be thrown away and rebuilt from the committed command stream at any time + * (`ProjectionHost.rebuild`). Because the fold is PURE and DETERMINISTIC, every + * node that replays the same committed stream reconstructs an identical read + * model — read-scaling that preserves ADR-0002 (the log stays source of truth). + */ + +/** + * A committed module command as seen by the READ side. This is the projection + * layer's input event: the same information the write path produced when it + * applied the command, captured AFTER commit so the read model only ever folds + * facts that consensus already agreed on. + * + * It is derived from a `ModuleHost.apply` outcome plus the command's meta — see + * the projection tests for how a host apply result maps onto one of these. The + * read side intentionally does not depend on `ModuleHost` internals; it consumes + * this flat, serializable event so a projection could equally be fed from a log + * reader, a replication stream, or a test harness. + */ +export interface ProjectionEvent { + /** Monotonic position of the command in the committed stream (its log order). */ + seq: number; + /** Target module name. */ + module: string; + /** Command name dispatched on the module. */ + command: string; + /** The command input that was committed. */ + input: unknown; + /** The explicit result the reducer surfaced (e.g. the created entity). */ + result: unknown; + /** Who issued the command (from `CommandMeta`). */ + actor: string; + /** Request correlation id (from `CommandMeta`). */ + requestId: string; + /** Leader-resolved commit timestamp, if the caller chose to carry it. */ + timestamp?: string; +} + +/** + * A declarative read projection over the committed command stream. + * + * `on` is a PURE fold: given the current view and the next committed event, it + * returns the NEXT view. It is responsible for its own filtering — it inspects + * `event.module` / `event.command` and ignores events it does not care about by + * returning the view unchanged. Keeping the fold pure (no `Date`, no + * `Math.random`, no I/O) is what makes the read model rebuildable and convergent + * across nodes: the same committed stream always folds to the same view. + * + * `queries` are read-only views over the folded state — the rich, indexed answers + * the projection exists to provide. They never mutate the view. + */ +export interface ProjectionDefinition { + name: string; + /** Builds the empty initial view. A factory so each host gets its own copy. */ + init: () => V; + /** Pure fold: next view given the current view and the next committed event. */ + on: (view: V, event: ProjectionEvent) => V; + /** Named read-only queries over the folded view. */ + queries: Record unknown>; +} + +/** + * Validate and return a projection definition. Like `defineModule`, this is an + * identity with light validation so a malformed projection fails loud at + * definition time rather than as a confusing fold/query error later. + * + * Requirements: a non-empty name (projections are registered by name and + * duplicates are rejected) and at least one query (a projection with no queries + * is dead weight — it folds a view nothing can read). + */ +export function defineProjection(def: ProjectionDefinition): ProjectionDefinition { + if (!def.name || def.name.trim() === '') { + throw new Error('Projection definition requires a non-empty name'); + } + if (typeof def.init !== 'function') { + throw new Error(`Projection "${def.name}" requires an init() factory`); + } + if (typeof def.on !== 'function') { + throw new Error(`Projection "${def.name}" requires an on() fold`); + } + const queryNames = Object.keys(def.queries ?? {}); + if (queryNames.length === 0) { + throw new Error(`Projection "${def.name}" must define at least one query`); + } + return def; +} diff --git a/src/runtime/projectionHost.ts b/src/runtime/projectionHost.ts new file mode 100644 index 0000000..d6ce48c --- /dev/null +++ b/src/runtime/projectionHost.ts @@ -0,0 +1,132 @@ +import { canonicalJson } from './canonical'; +import { ProjectionDefinition, ProjectionEvent } from './projection'; + +/** + * The READ side of CQRS (ADR-0019 pillar 4). A registry of projections plus their + * live, folded views, fed by the stream of committed module commands. + * + * It is architecturally SEPARATE from the authoritative state: + * - It never participates in the consensus apply path — `ModuleHost` and the + * replicated log remain the single source of truth. + * - It is a DERIVED cache: every view here can be discarded and reconstructed + * from the committed command stream via `rebuild`. That is the proof the read + * model is not authoritative — if the cache and a from-scratch replay disagree, + * the replay (the log) wins, by construction. + * - Folds are pure and deterministic (`ProjectionDefinition.on`), so two hosts + * fed the same committed stream converge to deep-equal views — the same + * discipline the deterministic core uses, applied to the read model so read + * replicas stay consistent. + */ +export class ProjectionHost { + private readonly defs = new Map>(); + /** Live per-projection view, keyed by projection name. */ + private readonly views = new Map(); + + /** Register a projection and initialize its view. Throws on a duplicate name. */ + register(def: ProjectionDefinition): void { + if (!def.name || def.name.trim() === '') { + throw new Error('Projection definition requires a non-empty name'); + } + if (this.defs.has(def.name)) { + throw new Error(`Projection "${def.name}" is already registered`); + } + this.defs.set(def.name, def as ProjectionDefinition); + this.views.set(def.name, def.init()); + } + + /** + * Fold one committed event into every registered projection's view. Each + * projection's `on` decides whether the event is relevant (it filters on + * `event.module` / `event.command` internally) and returns the next view, + * which we adopt. Because `on` is pure, applying the same event on every + * replica produces the same next view. + */ + applyEvent(event: ProjectionEvent): void { + for (const [name, def] of this.defs) { + const next = def.on(this.views.get(name), event); + this.views.set(name, next); + } + } + + /** Run a named query against a projection's current view. Never mutates. */ + query(projection: string, name: string, args?: unknown): unknown { + const def = this.defs.get(projection); + if (!def) { + throw new Error(`Unknown projection: ${projection}`); + } + const q = def.queries[name]; + if (!q) { + throw new Error(`Unknown query "${name}" on projection "${projection}"`); + } + return q(this.views.get(projection), args); + } + + /** + * Discard ALL views and rebuild them from scratch by replaying the committed + * event stream in order. This is the heart of CQRS: it proves the read model + * is DERIVED, not authoritative. A node can drop its cache (or join fresh) and + * reconstruct byte-identical views purely from the log, because each `on` fold + * is pure and deterministic. + * + * Every registered projection is reset to its `init()` view first, so the + * rebuilt state depends ONLY on the supplied events, never on whatever the + * cache happened to hold before. + */ + rebuild(events: ProjectionEvent[]): void { + for (const [name, def] of this.defs) { + this.views.set(name, def.init()); + } + for (const event of events) { + this.applyEvent(event); + } + } + + /** + * A serializable, deep-cloned map of projection name -> current view, with + * keys emitted in sorted order so a `JSON.stringify` over the snapshot is + * stable regardless of registration order (mirrors `ModuleHost.snapshot`). + * + * NOTE: the snapshot is a CACHE CONVENIENCE — a way to warm-start a read + * replica without replaying the whole stream. It is NOT a source of truth. + * Correctness of the read model comes from `rebuild` (replay from the log); + * a snapshot is only ever as trustworthy as the stream that produced it. + */ + snapshot(): Record { + const out: Record = {}; + for (const name of [...this.views.keys()].sort()) { + out[name] = clone(this.views.get(name)); + } + return out; + } + + /** + * Replace the views of registered projections from a snapshot. Only + * projections that are both registered AND present in the snapshot are + * restored; unknown snapshot keys are ignored and absent projections keep + * their `init()` view. As with `snapshot`, this is a cache warm-start — the + * authoritative path remains `rebuild` from the committed stream. + */ + restore(snap: Record): void { + for (const name of this.defs.keys()) { + if (Object.prototype.hasOwnProperty.call(snap, name)) { + this.views.set(name, clone(snap[name])); + } + } + } + + /** Current live view of a projection (for tests/inspection). */ + getView(projection: string): unknown { + return this.views.get(projection); + } +} + +/** + * Deep-clone a view through the shared canonical serializer. Routing the clone + * through `canonicalJson` (rather than a second `JSON.stringify`) keeps this + * layer on the ONE sanctioned serializer — the same reason `canonical.ts` exists + * — and yields a sorted-key, drift-free copy decoupled from the live view, so a + * caller that mutates a returned snapshot cannot reach back into host state. + */ +function clone(value: T): T { + return JSON.parse(canonicalJson(value, { onUndefined: 'drop' })) as T; +} diff --git a/src/runtime/projections/noteIndex.ts b/src/runtime/projections/noteIndex.ts new file mode 100644 index 0000000..c69a5cf --- /dev/null +++ b/src/runtime/projections/noteIndex.ts @@ -0,0 +1,68 @@ +import { defineProjection, ProjectionEvent } from '../projection'; + +/** + * The note created by the `notes` module's `create` reducer — the shape that + * arrives on `event.result` for a `notes.create` command. We only need the id + * here; the rest of the note lives in the authoritative module state. + */ +interface CreatedNote { + id: string; +} + +/** + * The folded read model: note ids grouped by the actor who created them, plus a + * running total. This is exactly the index the authoritative `notes` module does + * NOT keep — its state is a flat `notes` array, so answering "which notes did + * actor X create?" against the module would be an O(n) scan on every call. The + * projection maintains the grouping incrementally so the query is O(1) lookup. + */ +interface NoteIndexView { + byActor: Record; + total: number; +} + +/** + * A demo CQRS projection over the `notes` module (ADR-0019 pillar 4). + * + * It proves the value of the read side: a derived, indexed view that answers + * rich queries the raw module state cannot, built purely by folding the committed + * command stream. The fold is pure and deterministic — no `Date`, no + * `Math.random` — so the index is rebuildable from the log and convergent across + * nodes. + */ +export const noteIndex = defineProjection({ + name: 'noteIndex', + init: () => ({ byActor: {}, total: 0 }), + on: (view, event: ProjectionEvent) => { + // Filter inside the fold: ignore everything but a successful note creation. + if (event.module !== 'notes' || event.command !== 'create') { + return view; + } + const note = event.result as CreatedNote | undefined; + // Defensive: a create whose result lacks an id (e.g. a rejected apply that + // still reached the read side) contributes nothing rather than corrupting + // the index. The fold stays total and pure. + if (!note || typeof note.id !== 'string') { + return view; + } + + // Build the NEXT view immutably — never mutate the input view in place, so + // the fold is a clean pure function and replay is reproducible. + const existing = view.byActor[event.actor] ?? []; + return { + byActor: { ...view.byActor, [event.actor]: [...existing, note.id] }, + total: view.total + 1, + }; + }, + queries: { + /** Note ids created by `actor`, in creation order (empty if none). */ + byActor: (view, args): string[] => { + const actor = args as string; + return view.byActor[actor] ?? []; + }, + /** Total number of notes created across all actors. */ + total: (view): number => view.total, + /** The set of actors that have created at least one note, sorted. */ + actors: (view): string[] => Object.keys(view.byActor).sort(), + }, +}); diff --git a/src/runtime/saga.ts b/src/runtime/saga.ts new file mode 100644 index 0000000..7d08af2 --- /dev/null +++ b/src/runtime/saga.ts @@ -0,0 +1,104 @@ +/** + * Saga coordinator for cross-shard transactions (ADR-0020, M10). + * + * A multi-Raft cluster has NO single log spanning shards, so an operation that + * touches two shards (e.g. debit an account on shard A, credit one on shard B) + * cannot be one atomic append. ADR-0020 chooses a SAGA over two-phase commit: + * run the per-shard steps forward, and if one fails, run the COMPENSATING action + * for each step that already succeeded, in reverse order. + * + * This yields **atomicity by eventual compensation, NOT isolation**: intermediate + * states are visible (the debit lands before the credit; on failure the credit + * never happens and the debit is undone). That exposure is the accepted trade-off + * for keeping each shard independently available (ADR-0020 "Why saga, not 2PC"). + * + * Idempotency requirement: each `invoke` and each `compensate` is an ordinary, + * single-shard MODULE command carrying a `requestId`, so the runtime's exactly- + * once dedup makes steps safe to RETRY and compensations safe to REPLAY. Authors + * MUST give distinct requestIds to the forward and compensating legs (they are + * different commands) but keep each leg's requestId stable across retries. + */ + +/** + * One step of a saga. `invoke` performs the forward action; `compensate` undoes + * it. `compensate` runs ONLY for steps whose `invoke` already resolved, and only + * when a LATER step fails — so it must semantically reverse a committed `invoke` + * (e.g. `invoke` = withdraw, `compensate` = deposit the same amount back). + */ +export interface SagaStep { + name: string; + invoke: () => Promise; + compensate: () => Promise; +} + +/** Outcome of {@link runSaga}: a discriminated union on `ok`. */ +export type SagaResult = + | { ok: true; results: unknown[] } + | { + ok: false; + failedAt: string; + error: Error; + /** Steps whose compensation RAN SUCCESSFULLY (in compensation order). */ + compensated: string[]; + /** + * Steps whose compensation itself THREW — their forward effect was NOT + * undone, so this is the real "funds may be lost" signal a conservation- + * critical coordinator must not hide. Empty on a clean compensation. + */ + compensationFailures: { step: string; error: Error }[]; + }; + +/** + * Run `steps` forward in order. On the FIRST failing `invoke`, stop and run + * `compensate` for every step that already SUCCEEDED, in REVERSE order, then + * report which steps were compensated. + * + * - Success: `{ ok: true, results }` with one entry per step (in step order). + * - Failure: `{ ok: false, failedAt, error, compensated, compensationFailures }`. + * `failedAt` is the step whose `invoke` threw; `compensated` lists the steps + * whose compensation RAN SUCCESSFULLY (in the order their compensations ran — + * reverse of success). The failing step itself is NOT compensated (its `invoke` + * did not commit). + * + * A compensation that itself throws does NOT block the remaining compensations + * (best-effort unwind), but it is NOT silently swallowed: the step is recorded in + * `compensationFailures` and kept OUT of `compensated`, so a conservation-critical + * caller can detect that a forward effect was never undone (real fund loss) and + * park it for retry. A non-empty `compensationFailures` means the saga did not + * fully unwind. + */ +export async function runSaga(steps: SagaStep[]): Promise { + const results: unknown[] = []; + // Steps whose invoke committed, in execution order; compensated in reverse. + const succeeded: SagaStep[] = []; + + for (const step of steps) { + try { + const result = await step.invoke(); + results.push(result); + succeeded.push(step); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const compensated: string[] = []; + const compensationFailures: { step: string; error: Error }[] = []; + // Undo committed steps in REVERSE order so dependencies unwind safely. + for (let i = succeeded.length - 1; i >= 0; i--) { + const done = succeeded[i]; + try { + await done.compensate(); + compensated.push(done.name); + } catch (cerr) { + // A failed compensation must not block the others, but it means + // this step's effect was NOT undone — surface it, don't hide it. + compensationFailures.push({ + step: done.name, + error: cerr instanceof Error ? cerr : new Error(String(cerr)), + }); + } + } + return { ok: false, failedAt: step.name, error, compensated, compensationFailures }; + } + } + + return { ok: true, results }; +} diff --git a/src/runtime/sandbox.ts b/src/runtime/sandbox.ts new file mode 100644 index 0000000..0289344 --- /dev/null +++ b/src/runtime/sandbox.ts @@ -0,0 +1,378 @@ +/** + * `vm`-based determinism sandbox + leader-side step/CPU meter (ADR-0019 + * pillars 2 and 6). This is the M9 hardening the prototype-status note flagged + * as missing: M5 enforced determinism with a STATIC lint (bypassable by + * obfuscation) and a deterministic size/write bound; this module adds the + * STRUCTURAL enforcement the lint stands in for, plus the preemptive CPU/step + * gas meter the deterministic size bound could not provide. + * + * Two distinct guarantees, two execution entry points: + * + * 1. STRUCTURAL DETERMINISM (apply path). `compileReducer` re-evaluates a + * self-contained reducer source inside a fresh `vm` context whose globals are + * a curated, frozen SAFE set — `Date`, `Intl`, `Math.random`, `process`, + * `require`, timers, `crypto`, `console`, `performance` are ABSENT, and the + * locale-sensitive `toLocale*` / `localeCompare` prototype methods on the kept + * `Number`/`String`/`Array`/`Object` intrinsics are replaced by throwers. A + * reducer that reaches for `Date.now()` or `(1).toLocaleString()` therefore + * throws at runtime, even if it slipped past the static lint. This list is the + * set of footguns the sandbox ENFORCES, not a proof of total purity (the file + * header below is explicit that this is a determinism aid, not a security + * boundary). `runReducer` executes this on the + * apply path with NO timeout: the call must be a pure, deterministic function + * of (state, input, ctx) so every replica computes the identical result. A + * wall-clock timeout there would be non-deterministic (it could trip on one + * replica and not another) and is therefore forbidden on the apply path. + * + * 2. CPU/STEP GAS (admission, leader-only). `runReducerWithBudget` executes the + * SAME compiled reducer but inside the vm with `{ timeout: budgetMs }`, so a + * runaway synchronous reducer (an infinite loop) is interrupted and rejected + * as `BudgetExceededError`. The leader runs this as a pre-log DRY RUN against + * a clone of current state; an over-budget command is rejected BEFORE it ever + * enters the log. Followers never re-run the meter — under the CFT trust + * model they trust that a committed entry was already admitted by the leader, + * so the apply path stays timeout-free and deterministic. + * + * HONEST SCOPING — NOT A SECURITY BOUNDARY. Node's own docs are explicit that + * the `vm` module is "not a security mechanism. Do not use it to run untrusted + * code." A vm context still exposes `Function`/`eval` (and a sufficiently + * determined script can break out), so this sandbox does NOT defend against + * MALICIOUS reducers. Its goal is to enforce determinism against HONEST-BUT- + * CARELESS reducers — the author who reflexively reaches for `Date.now()` — by + * making the non-deterministic globals structurally unavailable. This mirrors + * the honest caveats already stated for the M3 code-hash and the M5 static lint. + */ + +import * as vm from 'vm'; +import { ReducerContext, ReducerResult } from './types'; + +/** + * Thrown when the admission step meter (`runReducerWithBudget`) interrupts a + * reducer that ran past its wall-clock budget. Distinct error type so the host's + * `admit()` can map it to a dedicated rejection status (503) rather than lumping + * it in with an ordinary reducer throw. + */ +export class BudgetExceededError extends Error { + constructor(message: string) { + super(message); + this.name = 'BudgetExceededError'; + } +} + +/** + * A compiled, sandboxed reducer handle. Holds the vm context (with its frozen + * safe globals) and the reducer function evaluated INSIDE that context, so every + * free identifier the reducer references resolves against the sandbox globals — + * a banned global is simply absent and throws `ReferenceError` when touched. + */ +export interface CompiledReducer { + /** Run on the apply path: no timeout, deterministic. */ + run(state: unknown, input: unknown, ctx: ReducerContext): ReducerResult; + /** Run on the admission/dry-run path: vm-enforced wall-clock budget. */ + runWithBudget( + state: unknown, + input: unknown, + ctx: ReducerContext, + budgetMs: number, + ): ReducerResult; +} + +/** + * The curated safe-global posture, enforced by REMOVING non-deterministic + * intrinsics from a fresh `vm` context rather than by an allowlist. + * + * IMPORTANT IMPLEMENTATION NOTE: `vm.createContext` produces a fresh global + * object that V8 STILL populates with the standard built-in intrinsics + * (`Date`, `Math`, `Object`, …) — passing only an allowlist object does NOT + * unset them. So determinism is enforced by explicitly `delete`-ing the banned + * globals from the context's `globalThis` and neutralizing `Math.random`, run + * once per context as the setup script below. + * + * KEPT (deterministic builtins a reducer legitimately needs): `Object, Array, + * String, Number, Boolean, JSON, Math` (random removed), `isNaN, isFinite, + * parseInt, parseFloat` — these are left in place as standard intrinsics. Their + * locale-sensitive methods are neutralized in place (see NEUTRALIZED below) so + * the deterministic methods stay usable while the non-deterministic ones throw. + * + * REMOVED (so referencing them throws `ReferenceError`): + * - `Date` — wall-clock; use `ctx.now`. + * - `Intl` — locale/ICU formatting & collation are non-deterministic (vary by + * host locale and ICU build); the global is deleted entirely. + * - `Math.random` — randomness (the property is replaced by a thrower; the rest + * of `Math` stays); use `ctx.random()`. + * - `crypto` — entropy; use `ctx.id()` / `ctx.random()`. + * - `process`, `require`, `module` — ambient environment / dynamic load. + * - `setTimeout`, `setInterval`, `setImmediate`, `clearTimeout`, + * `clearInterval`, `queueMicrotask` — timers / async scheduling have no place + * on the synchronous deterministic path. + * - `console` — I/O; reducers must be pure. + * - `Reflect`, `Proxy` — common indirection paths to reach removed globals. + * - `performance` — high-resolution wall clock. + * + * NEUTRALIZED (kept intrinsics whose locale-sensitive methods are replaced by a + * thrower, since deleting `Intl` alone does not close the gap — these methods + * ride on the kept `Number`/`String`/`Array`/`Object` prototypes): + * - `Number.prototype.toLocaleString`, `Array.prototype.toLocaleString`, + * `Object.prototype.toLocaleString` (and `BigInt.prototype.toLocaleString` + * when present) — locale-dependent formatting; format deterministically. + * - `String.prototype.localeCompare` — locale-dependent collation; compare with + * deterministic ordering (`<`/`>`) instead. + * - `String.prototype.toLocaleLowerCase`, `String.prototype.toLocaleUpperCase` + * — locale-dependent case mapping; use `toLowerCase`/`toUpperCase`. + * The non-locale methods on these prototypes are untouched and remain usable. + * + * This is the documented footgun set the sandbox enforces, NOT a proof of total + * determinism — see the file header's HONEST SCOPING note. + * + * `globalThis` itself, `Function`, and `eval` CANNOT be meaningfully removed + * (they are intrinsic to any vm context and a script can re-derive them), which + * is exactly why this is a DETERMINISM aid and NOT a security boundary — see the + * file header. + */ + +/** Globals deleted from each sandbox context's `globalThis`. */ +const BANNED_GLOBALS = [ + 'Date', + 'Intl', + 'crypto', + 'process', + 'require', + 'module', + 'setTimeout', + 'setInterval', + 'setImmediate', + 'clearTimeout', + 'clearInterval', + 'queueMicrotask', + 'console', + 'Reflect', + 'Proxy', + 'performance', +]; + +/** + * Setup script run ONCE per context: delete every banned intrinsic from + * `globalThis`, replace `Math.random` with a thrower (leaving the rest of `Math` + * — all pure functions — intact), and neutralize the locale-sensitive `toLocale*` + * / `localeCompare` prototype methods that ride on the KEPT `Number`/`String`/ + * `Array`/`Object` intrinsics. After this runs, a reducer body that references a + * banned global resolves it as a free identifier against the (now-missing) global + * and throws `ReferenceError`; `Math.random()` throws too; and any call to a + * locale-formatting method throws a clear `Error`. + * + * WHY the locale methods: removing the `Intl` global is not enough — methods like + * `Number.prototype.toLocaleString`, `String.prototype.localeCompare`, and + * `Array.prototype.toLocaleString` perform locale/ICU-dependent formatting and + * comparison that VARY across Node builds and host locales, so two replicas could + * compute different strings/orderings from the same input. That is exactly the + * non-determinism this sandbox must block, so each is replaced by a thrower while + * the deterministic methods on those prototypes stay intact. + */ +const SANDBOX_SETUP = ` +(() => { + const banned = ${JSON.stringify(BANNED_GLOBALS)}; + for (const name of banned) { + try { delete globalThis[name]; } catch (_e) { /* non-configurable: ignore */ } + } + // Replace Math.random with a loud thrower (do NOT touch the shared real Math + // object outside this context — this mutates only this context's Math). + try { + Object.defineProperty(Math, 'random', { + configurable: true, + get() { + throw new ReferenceError( + 'Math.random is not available in the determinism sandbox (use ctx.random())', + ); + }, + }); + } catch (_e) { /* ignore */ } + // Neutralize locale-sensitive prototype methods: replace each with a thrower + // so a reducer cannot reach locale/ICU-dependent formatting through the kept + // Number/String/Array/Object intrinsics. The deterministic methods on those + // prototypes are untouched. + const localeThrower = (label) => function () { + throw new Error( + label + ' is not available in the determinism sandbox: locale-aware ' + + 'formatting/comparison is non-deterministic (it varies by host locale ' + + 'and ICU build). Format via deterministic logic instead.', + ); + }; + const neutralize = (proto, method, label) => { + try { + Object.defineProperty(proto, method, { + configurable: true, + writable: true, + enumerable: false, + value: localeThrower(label), + }); + } catch (_e) { /* non-configurable: ignore */ } + }; + neutralize(Number.prototype, 'toLocaleString', 'Number.prototype.toLocaleString'); + neutralize(String.prototype, 'localeCompare', 'String.prototype.localeCompare'); + neutralize(String.prototype, 'toLocaleLowerCase', 'String.prototype.toLocaleLowerCase'); + neutralize(String.prototype, 'toLocaleUpperCase', 'String.prototype.toLocaleUpperCase'); + neutralize(Array.prototype, 'toLocaleString', 'Array.prototype.toLocaleString'); + neutralize(Object.prototype, 'toLocaleString', 'Object.prototype.toLocaleString'); + // BigInt may be absent depending on context shape; guard it. Its + // toLocaleString is equally locale-dependent. + try { + if (typeof BigInt !== 'undefined' && BigInt.prototype) { + neutralize(BigInt.prototype, 'toLocaleString', 'BigInt.prototype.toLocaleString'); + } + } catch (_e) { /* ignore */ } +})(); +`; + +/** + * Detect whether a stringified reducer is a STANDALONE expression we can wrap in + * parentheses and evaluate — i.e. an arrow function (`(s, i) => ...`) or a + * `function` expression (`function (s) { ... }`, named or anonymous). Method + * shorthand from an object literal stringifies as `name(args) { ... }`, which is + * NOT a valid standalone expression; we detect that and reject with a clear + * message rather than producing a confusing `SyntaxError` deep in the vm. + */ +function isStandaloneFunctionSource(source: string): boolean { + const trimmed = source.trim(); + // Arrow function: contains `=>` before any `{` body and starts with `(` or an + // identifier/`async`. A function expression starts with `function`/`async`. + if (/^async\s+function\b/.test(trimmed)) return true; + if (/^function\b/.test(trimmed)) return true; + // Arrow: `(...) =>` or `ident =>` or `async (...) =>`. + if (/^async\s*\(/.test(trimmed) || /^\(/.test(trimmed) || /^[A-Za-z_$][\w$]*\s*=>/.test(trimmed)) { + // Must actually be an arrow (has `=>`), not a parenthesized non-function. + if (trimmed.includes('=>')) return true; + } + return false; +} + +/** + * Compile a SELF-CONTAINED reducer source into the determinism sandbox. + * + * `source` is typically `fn.toString()` of an arrow or `function` expression + * that references ONLY its parameters, the safe globals, and `ctx` — it must not + * close over helpers defined outside itself (the vm re-evaluates the text in a + * fresh context, so any free identifier that is not a safe global is undefined / + * throws). The demo `compute` module uses arrow expressions, satisfying this. + * + * The returned handle exposes both execution modes; the host picks `run` for the + * apply path and `runWithBudget` for leader-side admission. + */ +export function compileReducer(source: string): CompiledReducer { + if (!isStandaloneFunctionSource(source)) { + throw new Error( + 'Sandboxed reducer source is not a standalone function expression. ' + + 'Sandboxed reducers must be written as arrow functions or `function` ' + + 'expressions (not object method-shorthand), and must be self-contained ' + + '(reference only their parameters, ctx, and the safe globals).', + ); + } + + // Fresh context, then STRIP the banned intrinsics (createContext leaves the + // standard globals in place, so removal — not an allowlist — is what makes + // them absent). After setup, a banned global referenced in the reducer body + // throws `ReferenceError`. + const context = vm.createContext({}); + vm.runInContext(SANDBOX_SETUP, context); + + // Evaluate the reducer expression and stash it on the context as `__reducer`. + // Parenthesize the source so an arrow/function expression is an expression, + // not a statement (a bare `function foo(){}` would be a declaration). + vm.runInContext(`globalThis.__reducer = (${source});`, context); + + /** + * Build the per-call invocation. We pass `state`/`input`/`ctx` INTO the + * context as globals and call `__reducer` there, so the reducer body runs + * with the sandbox's resolution rules. `ctx` carries closures (`random`, + * `id`) created OUTSIDE the context; calling them is fine — they execute in + * their own (deterministic) closure, the sandbox only governs the free + * identifiers in the reducer's own body. + */ + const invoke = ( + state: unknown, + input: unknown, + ctx: ReducerContext, + timeoutMs?: number, + ): ReducerResult => { + // Publish the call arguments as context globals. They are plain data / + // the ctx capability object; assigning them does not widen the sandbox's + // ambient surface for the reducer's free identifiers. + (context as Record).__state = state; + (context as Record).__input = input; + (context as Record).__ctx = ctx; + + const options: vm.RunningScriptOptions = {}; + // Only the admission path passes a timeout: a positive budget arms the + // vm's wall-clock interrupt. The apply path passes none, so the call runs + // to completion deterministically. + if (timeoutMs !== undefined) { + options.timeout = timeoutMs; + } + + try { + return vm.runInContext( + '__reducer(__state, __input, __ctx)', + context, + options, + ) as ReducerResult; + } catch (err) { + // The vm throws a timeout error whose `code` is + // `ERR_SCRIPT_EXECUTION_TIMEOUT` and whose message includes "timed + // out" when the budget trips. We deliberately DO NOT use + // `instanceof Error` to detect it: the vm fabricates that error in a + // DIFFERENT V8 realm, so `instanceof Error` is FALSE for it in this + // realm (cross-realm prototype identity). We sniff `code`/`message` + // duck-typed instead. Normalize that single case to + // `BudgetExceededError`; every other throw (including a reducer's + // `ReferenceError` for a banned global) propagates as-is so the apply + // path can audit it as an ordinary 500. + const e = err as { code?: string; message?: string }; + if ( + timeoutMs !== undefined && + (e?.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT' || + (typeof e?.message === 'string' && /timed out/i.test(e.message))) + ) { + throw new BudgetExceededError( + `Reducer exceeded the ${timeoutMs}ms step budget`, + ); + } + throw err; + } + }; + + return { + run: (state, input, ctx) => invoke(state, input, ctx), + runWithBudget: (state, input, ctx, budgetMs) => invoke(state, input, ctx, budgetMs), + }; +} + +/** + * Execute a compiled reducer on the APPLY path: NO timeout, fully deterministic. + * Every replica runs this and must reach the identical result, so a wall-clock + * interrupt is deliberately not used here. + */ +export function runReducer( + compiled: CompiledReducer, + state: unknown, + input: unknown, + ctx: ReducerContext, +): ReducerResult { + return compiled.run(state, input, ctx); +} + +/** + * Execute a compiled reducer on the ADMISSION/dry-run path with a wall-clock + * `budgetMs`. A runaway synchronous reducer is interrupted by the vm and surfaced + * as `BudgetExceededError`. LEADER-ONLY: this is the pre-log gas check; followers + * never call it (CFT trust). Because it runs against a CLONE of state and its + * result is discarded, the non-determinism of the timeout never touches + * replicated state. + */ +export function runReducerWithBudget( + compiled: CompiledReducer, + state: unknown, + input: unknown, + ctx: ReducerContext, + budgetMs: number, +): ReducerResult { + return compiled.runWithBudget(state, input, ctx, budgetMs); +} diff --git a/src/runtime/shardRouter.ts b/src/runtime/shardRouter.ts new file mode 100644 index 0000000..5c5cf27 --- /dev/null +++ b/src/runtime/shardRouter.ts @@ -0,0 +1,113 @@ +import { createHash } from 'crypto'; +import { AppCommand, ApplyResult, CommandMeta } from '../consensus/types'; + +/** + * The minimal node surface the router needs: ask who leads, and submit to it. + * Structural so any `RaftNode` (e.g. a {@link ModuleNode}) fits without the + * router depending on the concrete node/state-machine types. + */ +export interface ShardNode { + isLeader(): boolean; + submit(command: C, meta?: CommandMeta): Promise>; +} + +/** + * Multi-Raft shard router (ADR-0020, M10). + * + * Each shard is an INDEPENDENT single-group Raft cluster — its own nodes, its own + * leader election, its own replicated log + ModuleHost. The router is a thin, + * STATE-FREE front door: it maps a command's partition key to exactly one shard + * and submits to that shard's current leader. It holds no data of its own — it is + * fully derivable from (a) the static shard map and (b) each group's live + * leadership — so it can be reconstructed at any time and every participant that + * shares the same shard count computes the same routing. + * + * Routing model (ADR-0020 "Sharding key"): the shard is a PURE, deterministic + * function of the partition key — `sha256(key) mod N`. No `Date`/`Math.random`, + * so the same key always lands on the same shard on every node and on every + * process restart. Writes to different shards proceed in parallel (different + * leaders), which is the write-scaling the ADR is demonstrating. + */ + +/** + * A handle to one shard's cluster. The router stays generic over the submit + * mechanism: it is given the shard's set of nodes and asks them which one is the + * current leader, rather than baking in transport/forwarding details. This is + * what lets tests wire in `buildCluster(3)` per shard and `buildModuleCommand` + * for the payload without the router knowing about either. + */ +export interface ShardHandle { + /** Every node in this shard's Raft group (used to locate the current leader). */ + nodes: ShardNode[]; +} + +/** Raised when a shard currently has no known leader; the caller should retry. */ +export class NoShardLeaderError extends Error { + constructor(public readonly shard: number) { + super(`shard ${shard} has no known leader`); + this.name = 'NoShardLeaderError'; + } +} + +export class ShardRouter { + private readonly shards: ShardHandle[]; + + /** + * @param shards one handle per shard, index `i` == shard number `i`. The shard + * COUNT (`shards.length`) is the modulus of the routing function, so it must + * be identical on every participant for routing to agree. + */ + constructor(shards: ShardHandle[]) { + if (shards.length === 0) { + throw new Error('ShardRouter requires at least one shard'); + } + this.shards = shards; + } + + /** Number of shards (the modulus of {@link shardFor}). */ + get shardCount(): number { + return this.shards.length; + } + + /** + * Deterministic shard mapping: `sha256(partitionKey) mod N`. Pure — depends + * ONLY on the key and the shard count, never on wall-clock or randomness — so + * every node derives the same shard for the same key (ADR-0020). The first 6 + * hex digits (24 bits) of the digest are ample entropy for a small N and keep + * the arithmetic inside a safe integer. + */ + shardFor(partitionKey: string): number { + const digest = createHash('sha256').update(partitionKey).digest('hex'); + const bucket = parseInt(digest.slice(0, 6), 16); + return bucket % this.shards.length; + } + + /** + * The current leader of `shard`, or `null` if none is presently known (e.g. + * mid-election). Derived live from the group — the router caches nothing. + */ + leaderOf(shard: number): ShardNode | null { + const handle = this.shards[shard]; + if (!handle) { + throw new RangeError(`no such shard: ${shard}`); + } + return handle.nodes.find((n) => n.isLeader()) ?? null; + } + + /** + * Route a command to the leader of the shard that owns `partitionKey` and await + * its `submit`. The leader replicates + applies as any single-group write, so + * per-shard correctness (determinism, idempotency, audit) is unchanged. If the + * owning shard has no known leader right now, reject with + * {@link NoShardLeaderError} so the caller can retry across a leader change — + * the router does no buffering of its own (ADR-0020 "Routing/membership"). + */ + submit(partitionKey: string, command: C, meta?: CommandMeta): Promise> { + const shard = this.shardFor(partitionKey); + const leader = this.leaderOf(shard); + if (!leader) { + return Promise.reject(new NoShardLeaderError(shard)); + } + return leader.submit(command, meta); + } +} diff --git a/src/runtime/signing.ts b/src/runtime/signing.ts new file mode 100644 index 0000000..9fb6318 --- /dev/null +++ b/src/runtime/signing.ts @@ -0,0 +1,117 @@ +/** + * Actor-signed module commands (ADR-0019 pillar 7: "Sign commands with the + * originating actor's key so the leader cannot forge `actor`"). + * + * This adds ACCOUNTABILITY without paying for full BFT. The originating actor + * signs the LOGICAL command with its private key; every replica verifies the + * signature on the deterministic apply path against an actor->public-key + * registry. A command whose `actor` was forged by a malicious leader (or whose + * input/requestId was tampered with) fails verification IDENTICALLY on every + * node and is rejected (401) before its reducer runs — so the leader cannot put + * words in an actor's mouth. + * + * WHY THE SIGNATURE EXCLUDES `seed`: the leader resolves the deterministic + * `seed` (clock + nonce) AFTER the client signs, so the seed cannot be part of + * the signed payload — the actor never sees it. The actor signs only the + * LOGICAL command (`module`/`command`/`input`/`actor`/`requestId`). The seed is + * a NON-SECURITY, convergence-only value the leader bakes in afterward; it does + * not need to be authenticated. Verification recomputes the exact same logical + * payload from the committed command + meta and checks the signature. + * + * WHY THIS IS CONVERGENCE-SAFE: ed25519 sign and verify are deterministic, and + * the verification input (canonical JSON of the logical payload) is a pure + * function of replicated state (the committed command fields + meta). Every + * replica computes the same boolean, so an invalid signature is rejected on all + * nodes or none — never some — and the cluster never diverges. + * + * Stdlib only: Node `crypto` ed25519 (ADR-0013, minimal dependencies). + */ + +import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'crypto'; +import { canonicalJson } from './canonical'; + +/** + * The LOGICAL command an actor signs. Deliberately EXCLUDES the leader-resolved + * `seed`: the actor signs before the leader exists in the flow, so the seed is + * not available and (being non-security) does not need to be authenticated. + */ +export interface SignablePayload { + module: string; + command: string; + input: unknown; + actor: string; + requestId: string; +} + +/** + * Generate an ed25519 keypair for an actor, returned as PEM strings. The public + * half is registered (per actor) in a {@link KeyRegistry} on every node; the + * private half stays with the actor and signs its commands. + */ +export function generateActorKeypair(): { publicKey: string; privateKey: string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + return { + publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + privateKey: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + }; +} + +/** + * Sign the canonical serialization of the logical payload with `privateKeyPem`. + * Canonical (sorted-key) JSON guarantees the signer and every verifier hash the + * SAME bytes regardless of property insertion order. ed25519 uses `null` as the + * algorithm argument (the digest is built in). Returns a base64 signature. + */ +export function signCommand(privateKeyPem: string, payload: SignablePayload): string { + const key = createPrivateKey(privateKeyPem); + const data = Buffer.from(canonicalJson(payload)); + return sign(null, data, key).toString('base64'); +} + +/** + * Verify `signatureB64` over the canonical logical payload against + * `publicKeyPem`. Pure and deterministic, so it is safe to run on the apply + * path: every replica computes the same boolean from the same committed inputs. + * Returns `false` (never throws) on a malformed key/signature so a bad command + * is a deterministic rejection, not a host crash. + */ +export function verifyCommand(publicKeyPem: string, payload: SignablePayload, signatureB64: string): boolean { + try { + const key = createPublicKey(publicKeyPem); + const data = Buffer.from(canonicalJson(payload)); + return verify(null, data, key, Buffer.from(signatureB64, 'base64')); + } catch { + return false; + } +} + +/** + * The prototype's PKI / allowlist: a map from `actor` -> the public key + * AUTHORIZED to sign on that actor's behalf. Binding actor->authorized-key here + * is the whole point of pillar 7 — it is what makes a forged-`actor` command + * detectable: a command claiming `actor: 'alice'` only verifies if it was signed + * by the key the registry holds for `alice`. A leader that fabricates an + * `actor` it has no key for cannot produce a matching signature. + * + * The registry is configured per node BEFORE start (like module registration) + * and is identical on every node, so verification converges. When NO registry is + * configured on a host, verification is skipped entirely (back-compat). + */ +export class KeyRegistry { + private readonly keys = new Map(); + + /** Authorize `publicKeyPem` as the sole signer for `actor`. */ + registerActor(actor: string, publicKeyPem: string): void { + this.keys.set(actor, publicKeyPem); + } + + /** The public key authorized for `actor`, or `undefined` if none is registered. */ + get(actor: string): string | undefined { + return this.keys.get(actor); + } + + /** Whether `actor` has an authorized key on file. */ + has(actor: string): boolean { + return this.keys.has(actor); + } +} diff --git a/src/runtime/stateStore.ts b/src/runtime/stateStore.ts new file mode 100644 index 0000000..bfc2dda --- /dev/null +++ b/src/runtime/stateStore.ts @@ -0,0 +1,245 @@ +/** + * Pluggable, key-oriented state store (ADR-0019 pillar 4, the "state larger than + * RAM" half). + * + * The existing module model (`defineModule`) treats a module's state as ONE + * whole-state blob: every command loads, transforms, and replaces the entire + * value. That is simple but caps a module's state at what fits in RAM and what is + * cheap to clone per command. This module adds the alternative: a module's state + * is a COLLECTION OF RECORDS addressed by key, and a reducer touches only the + * keys it needs (read one account, write it back) — never the whole dataset. + * + * `StateStore` is the seam. The default `MemoryStateStore` keeps records in a + * `Map`, but the interface is deliberately the shape a persistent embedded KV/LSM + * store (LevelDB, RocksDB, …) would implement: there snapshots become store + * checkpoints and the working set need not fit in memory. Swapping the backend + * changes nothing above this seam. + * + * DETERMINISM (the non-negotiable, same rule as the consensus state machine): + * every observable store operation a reducer can see MUST be identical on every + * replica. Two consequences enforced here: + * - ITERATION ORDER is sorted by key, always. A `Map` iterates in insertion + * order, which differs between replicas that applied the same logical writes + * in a different incidental order — so `keys()/entries()/snapshot()` sort. + * - READS RETURN DEEP CLONES. A reducer must never receive a shared mutable + * reference into stored state: mutating it in place would (a) bypass the + * transactional commit/discard guarantee and (b) leak insertion-time identity. + * Every read clones, so the store is the sole owner of its records. + */ + +import { canonicalJson } from './canonical'; + +/** + * Deep-clone a serializable value. Reuses the shared `canonicalJson` round-trip + * (DROP-`undefined` mode, matching `JSON.stringify` semantics) rather than + * `structuredClone` so the clone is consistent with the byte-sizing / hashing + * paths and does not depend on `structuredClone` typings under the lib target — + * the same assumption `moduleHost.ts`'s `deepClone` makes. Records are plain + * serializable data, so a JSON round-trip is sufficient. + * + * Using `canonicalJson` (sorted keys) additionally NORMALIZES key order in the + * clone, so a stored record can never leak its insertion-time key order to a + * reader — one more determinism guard for free. + */ +function cloneValue(value: T): T { + return JSON.parse(canonicalJson(value, { onUndefined: 'drop' })) as T; +} + +/** + * Per-module record store: a key→value map with deterministic iteration and + * clone-on-read. This is the seam a persistent embedded KV/LSM store drops into + * (snapshots become store checkpoints there); the default below is in-memory. + * + * VALUE SEMANTICS: values round-trip through canonical JSON (the clone path), so + * store plain JSON-safe records — a property explicitly set to `undefined` is + * DROPPED (matching `JSON.stringify`, identical across replicas, so no + * divergence) and `Date`/`Map`/`Set`/class instances are NOT preserved (a `Date` + * becomes its ISO string, a `Map` becomes `{}`). + */ +export interface StateStore { + /** The value at `key`, deep-cloned, or `undefined` if absent. */ + get(key: string): unknown | undefined; + /** Store (a deep clone of) `value` at `key`, replacing any existing record. */ + put(key: string, value: unknown): void; + /** Remove the record at `key` (no-op if absent). */ + delete(key: string): void; + /** Whether a record exists at `key`. */ + has(key: string): boolean; + /** All keys, SORTED ascending (deterministic iteration). */ + keys(): string[]; + /** All [key, value] pairs, SORTED by key, values deep-cloned. */ + entries(): [string, unknown][]; + /** Number of records. */ + size(): number; + /** Full SORTED dump for snapshotting; values deep-cloned. */ + snapshot(): [string, unknown][]; + /** Replace all records with `entries` (deep-cloned). */ + restore(entries: [string, unknown][]): void; +} + +/** + * Default in-memory `StateStore` over a `Map`. + * + * This is the default backend; the `StateStore` interface is the seam a + * persistent embedded KV/LSM store would implement instead (where `snapshot()` + * becomes a store checkpoint and the dataset need not fit in RAM). Determinism is + * guaranteed here by SORTING keys on every iteration (a `Map` is insertion- + * ordered, which is not replica-stable) and DEEP-CLONING values on read/dump so a + * caller can never mutate a stored record in place. + * + * VALUE SEMANTICS (same as the interface): records round-trip through canonical + * JSON, so a property set to `undefined` is dropped and `Date`/`Map`/etc. are + * not preserved — store plain JSON-safe records. + */ +export class MemoryStateStore implements StateStore { + private readonly map = new Map(); + + get(key: string): unknown | undefined { + if (!this.map.has(key)) return undefined; + // Clone on read: the caller must never get a mutable reference into the + // store, or it could mutate committed state out of band and diverge. + return cloneValue(this.map.get(key)); + } + + put(key: string, value: unknown): void { + // Clone on write too: the caller keeps no shared handle to what it stored, + // so a later mutation of its local object cannot reach into the store. + this.map.set(key, cloneValue(value)); + } + + delete(key: string): void { + this.map.delete(key); + } + + has(key: string): boolean { + return this.map.has(key); + } + + keys(): string[] { + // SORTED: a Map iterates in insertion order, which is not replica-stable. + return [...this.map.keys()].sort(); + } + + entries(): [string, unknown][] { + return this.keys().map((k) => [k, cloneValue(this.map.get(k))]); + } + + size(): number { + return this.map.size; + } + + snapshot(): [string, unknown][] { + // Same as entries(): sorted + cloned. Named distinctly so a persistent + // backend can implement it as a checkpoint without overloading entries(). + return this.entries(); + } + + restore(entries: [string, unknown][]): void { + this.map.clear(); + for (const [k, v] of entries) { + this.map.set(k, cloneValue(v)); + } + } +} + +/** + * A transactional COPY-ON-WRITE view over a `StateStore`, handed to a keyed + * reducer (ADR-0019 pillar 4). The atomicity story mirrors what whole-state + * modules get from the host's clone-and-swap: nothing the reducer writes touches + * the underlying store until the host explicitly `commit()`s, and a reducer that + * throws (or is rejected by a budget/lint check) is simply discarded — the view + * is dropped and the store is untouched. + * + * READ SEMANTICS (reads-your-writes): a `get`/`has`/`keys`/`entries` reflects + * this view's own buffered `put`/`delete` FIRST, falling through to the + * underlying store only for keys the view hasn't touched. Fall-through reads + * return clones (from the underlying store), so the reducer still cannot mutate + * committed records in place. Iteration stays SORTED and reflects buffered + * puts/deletes — deterministic regardless of how the reducer interleaved writes. + */ +export class StoreView { + /** Buffered puts, keyed; values are already cloned on entry. */ + private readonly writes = new Map(); + /** Keys buffered for deletion. A delete shadows the underlying store. */ + private readonly deletes = new Set(); + + constructor(private readonly store: StateStore) {} + + get(key: string): unknown | undefined { + if (this.deletes.has(key)) return undefined; + if (this.writes.has(key)) { + // Clone the buffered value so the reducer cannot mutate the buffer in + // place behind its own write (consistent with the store's read clone). + return cloneValue(this.writes.get(key)); + } + return this.store.get(key); // already cloned by the underlying store + } + + put(key: string, value: unknown): void { + // Buffer only — nothing reaches the underlying store until commit(). + // Clone on entry so the reducer's later mutation of its local object + // cannot retroactively change what the view will commit. + this.writes.set(key, cloneValue(value)); + this.deletes.delete(key); // a put cancels a prior buffered delete + } + + delete(key: string): void { + this.writes.delete(key); + this.deletes.add(key); + } + + has(key: string): boolean { + if (this.deletes.has(key)) return false; + if (this.writes.has(key)) return true; + return this.store.has(key); + } + + /** + * SORTED keys reflecting buffered changes: underlying keys, minus buffered + * deletes, plus buffered puts (which may be new keys). Computed fresh each + * call so it always mirrors the current buffer. + */ + keys(): string[] { + const set = new Set(); + for (const k of this.store.keys()) { + if (!this.deletes.has(k)) set.add(k); + } + for (const k of this.writes.keys()) set.add(k); + return [...set].sort(); + } + + entries(): [string, unknown][] { + return this.keys().map((k) => [k, this.get(k)]); + } + + size(): number { + return this.keys().length; + } + + /** Number of buffered writes (puts + deletes) — the host's `maxWrites` axis. */ + pendingWriteCount(): number { + return this.writes.size + this.deletes.size; + } + + /** The buffered puts, sorted by key — what the host bytes-sizes for `maxResultBytes`. */ + pendingPuts(): [string, unknown][] { + return [...this.writes.keys()].sort().map((k) => [k, this.writes.get(k)] as [string, unknown]); + } + + /** + * Apply the buffer to the underlying store atomically: all buffered puts and + * deletes land together. The host calls this ONLY on a successful, in-budget + * reducer; on any failure the view is simply dropped and nothing here runs. + */ + commit(): void { + // Deterministic apply order (sorted) — the result is order-independent + // since each key appears at most once, but a stable order keeps any + // backend's write log reproducible. + for (const k of [...this.deletes].sort()) { + this.store.delete(k); + } + for (const k of [...this.writes.keys()].sort()) { + this.store.put(k, this.writes.get(k)); + } + } +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts new file mode 100644 index 0000000..4563c2f --- /dev/null +++ b/src/runtime/types.ts @@ -0,0 +1,267 @@ +/** + * Core type definitions for the module runtime (ADR-0019, pillars 1–2). + * + * This layer sits ON TOP of the consensus core. Where the book demo wires + * non-determinism out by hand in `models/book.ts`, the runtime generalizes that + * discipline: developers write **pure reducers** and the framework injects a + * deterministic `ReducerContext`. Every value a reducer could be tempted to pull + * from the ambient environment (clock, randomness, ids) instead arrives through + * `ctx`, and `ctx` is derived entirely from a leader-resolved `Seed`. That is + * what lets two fresh hosts replay the same commands to byte-identical state. + */ + +/** + * A declarative request for a side effect (ADR-0019 pillar 3). Reducers never + * perform I/O; they return intents that the host records into a deterministic + * outbox and a post-commit `EffectExecutor` runs at the edge. The replicated log + * thus IS a transactional outbox: the intent commits as part of the reducer's + * result, and the effect's outcome re-enters the log as a committed follow-up. + */ +export interface EffectIntent { + kind: string; + /** + * Stable key that makes execution exactly-once: the outbox dedups on it (a + * replayed command never re-enqueues) and `applyEffectResult` is idempotent + * on it (a redelivered result never double-dispatches). + */ + idempotencyKey: string; + payload: unknown; + /** + * Optional module command that consumes the effect's result. After the edge + * resolves the effect, `applyEffectResult` dispatches this command so the + * reducer can fold the outcome back into module state deterministically. + */ + onResult?: { module: string; command: string }; +} + +/** Lifecycle of an outbox entry: enqueued (`pending`) or executed (`done`). */ +export type OutboxStatus = 'pending' | 'done'; + +/** + * One slot in the deterministic outbox: the emitted intent, its execution + * status, and (once executed) the result the edge produced. Keyed in the outbox + * by `intent.idempotencyKey`. + */ +export interface OutboxEntry { + intent: EffectIntent; + status: OutboxStatus; + result?: unknown; +} + +/** + * A committed log entry carrying an effect's outcome back into the deterministic + * core. The `result` was resolved ONCE on the effectful edge (the executor) and + * is now replicated verbatim; likewise the `seed` is resolved once on the edge by + * the executor, then committed verbatim so every replica applies the same value + * (convergence comes from committing the value, not from where it was generated). + * That deterministic `seed` gives any `onResult` reducer that consumes the result + * a deterministic `ctx`. Applying this entry on every replica yields identical + * state. + */ +export interface EffectResultEntry { + idempotencyKey: string; + result: unknown; + /** + * Resolved once on the edge by the executor, then committed verbatim so every + * replica applies the same value. It seeds the `ctx` of the `onResult` + * reducer, keeping that follow-up dispatch deterministic on every replica. + */ + seed: Seed; +} + +/** + * Performs the real side effect at the edge (ADR-0019 pillar 3). This is the ONE + * place non-determinism (network, clock, randomness) is allowed: the executor + * runs it post-commit, off the deterministic apply path, and bakes the returned + * value into an `EffectResultEntry` so replicas never re-run it. + */ +export type EffectHandler = (intent: EffectIntent) => Promise; + +/** + * The deterministic capabilities handed to a reducer. These are the ONLY + * sanctioned source of non-determinism: a reducer that reads `Date.now()`, + * `Math.random()`, or `crypto` directly breaks the determinism contract. + * + * All values are reproducible from the command's `Seed` alone, so a replica + * applying the same command rebuilds the identical context. + */ +export interface ReducerContext { + /** Leader-resolved wall-clock as an ISO string (the analog of `seed.timestamp`). */ + now: string; + /** Deterministic PRNG: a float in [0, 1). Successive calls advance the stream. */ + random(): number; + /** Deterministic unique id. Successive calls return distinct values. */ + id(): string; + /** Who issued the command (carried from `CommandMeta`, off the deterministic path). */ + actor: string; + /** Request correlation id (carried from `CommandMeta`). */ + requestId: string; +} + +/** + * What a reducer returns: the next state, an optional explicit result value to + * surface to the caller, and any effect intents it wants run. `result` is kept + * distinct from `state` so the host can hand callers a purpose-built value (e.g. + * the created entity) without exposing the entire next-state object. + */ +export interface ReducerResult { + state: S; + result?: unknown; + effects?: EffectIntent[]; +} + +/** + * A pure state transition. Given the current state, a command input, and the + * deterministic context, it returns the next state. MUST NOT mutate `state` + * in place in a way that depends on anything outside its arguments, and MUST + * NOT read ambient clocks/randomness — use `ctx`. + */ +export type Reducer = (state: S, input: unknown, ctx: ReducerContext) => ReducerResult; + +/** A read-only projection over state. Never mutates; not part of the log. */ +export type Query = (state: S, args: unknown) => unknown; + +/** A self-contained unit of application logic: its state, commands, and queries. */ +export interface ModuleDefinition { + name: string; + /** + * Semantic version of the module's LOGIC (ADR-0019 pillar 5). Optional; + * defaults to `'0'` when unset. It participates in the module's code hash, so + * bumping it produces a distinct `codeHash` even if the reducer source is + * byte-identical — letting an operator force a recorded version boundary. + */ + version?: string; + /** Builds the empty initial state. A factory (not a value) so each host gets its own. */ + initialState: () => S; + commands: Record>; + queries?: Record>; + /** + * Opt into the `vm` determinism sandbox (ADR-0019 pillars 2, 6 — M9). OFF by + * default: existing whole-state modules keep their current DIRECT reducer + * execution. When `true`, the host re-compiles each command reducer into a + * fresh `vm` context with a frozen safe-global set (no `Date`/`Math.random`/ + * `crypto`/timers/…) at registration, so determinism is enforced + * STRUCTURALLY at runtime rather than only by the static lint — a reducer + * that touches a banned global throws even if the lint were bypassed. + * + * SCOPE (this milestone): WHOLE-STATE modules only. A keyed module passed + * with `sandbox: true` is rejected at registration — sandboxing keyed + * `StoreView`-mutating reducers is out of scope. Sandboxed reducers must be + * SELF-CONTAINED arrow/`function` expressions (see {@link compileReducer}). + * + * HONEST CAVEAT: `vm` is a determinism aid, NOT a security boundary against + * malicious code (Node's docs say so). See `sandbox.ts`. + */ + sandbox?: boolean; + /** + * Wall-clock budget (ms) for the LEADER-SIDE admission step meter (ADR-0019 + * pillar 6 "gas"). Only consulted for a `sandbox` module. The leader's + * pre-log `admit()` dry-run runs the reducer inside the vm with this timeout + * and rejects a runaway (e.g. an infinite loop) BEFORE it enters the log. The + * apply path NEVER uses this — followers trust committed entries are bounded + * (CFT), keeping apply deterministic and timeout-free. Default: 50. + */ + stepBudgetMs?: number; +} + +/** + * The leader-resolved deterministic seed for a single command. Resolved once on + * the leader (real clock + real randomness) and replicated verbatim, so every + * replica derives the same context from it. The consensus-core analog is the + * leader baking ids/timestamps into a `Command` before it enters the log. + */ +export interface Seed { + /** ISO timestamp captured on the leader. */ + timestamp: string; + /** Hex-encoded random nonce captured on the leader; seeds the PRNG and id stream. */ + nonce: string; +} + +/** A command targeted at a module's reducer, carrying its leader-resolved seed. */ +export interface ModuleCommand { + module: string; + command: string; + input: unknown; + seed: Seed; + /** + * Optional actor signature (base64) over the LOGICAL command, threaded + * through so {@link ModuleHost.apply} can verify it against a configured + * `KeyRegistry` (ADR-0019 pillar 7). Excludes `seed` — the actor signs + * `{ module, command, input, actor, requestId }` before the leader resolves + * the seed. Ignored when no registry is configured (back-compat). + */ + sig?: string; +} + +/** + * The application command the module runtime puts on the replicated log + * (ADR-0019 M4). It satisfies the framework's `AppCommand` contract (a string + * `type` discriminator — `'MODULE'`, distinct from the reserved `NOOP`/`CONFIG`) + * and additionally carries the `actor`/`requestId` so the `StateMachine` adapter, + * whose `apply(command)` receives no separate meta, can reconstruct the + * `(cmd, meta)` call the `ModuleHost` expects and verify the signature. These two + * fields MUST match the `CommandMeta` the command is submitted with. + */ +export interface ModuleAppCommand extends ModuleCommand { + type: 'MODULE'; + actor: string; + requestId: string; +} + +/** The outcome of applying a `ModuleCommand` to a `ModuleHost`. */ +export interface ModuleApplyResult { + status: number; + result?: unknown; + effects: EffectIntent[]; + message?: string; +} + +/** + * Construction options for `ModuleHost` (ADR-0019 pillar 6 "resource safety"). + * + * These bound the AMPLIFICATION a single command may cause: how many effects it + * may enqueue, and how large the next-state it may produce. They are a + * DETERMINISTIC resource bound, NOT a CPU/step meter — every replica computes + * the same effect count and the same canonical byte size from the same reducer + * output, so an over-budget command is rejected IDENTICALLY on every node and + * the bound never causes divergence. A preemptive CPU/instruction meter (which + * would need a vm/worker to interrupt a runaway reducer mid-execution) is + * explicitly DEFERRED per ADR-0019; this guards the cheap, deterministic axes + * (fan-out and state size) that can be measured purely after the reducer returns. + */ +export interface ModuleHostOptions { + /** + * Max number of effect intents one command may emit. Caps outbox fan-out so + * a single command cannot flood the post-commit executor. Default: 16. + */ + maxEffects?: number; + /** + * Max canonical-JSON byte size of the next-state a reducer may produce. Caps + * state amplification so a single command cannot bloat replicated state (and + * thus every snapshot) without bound. Default: 64 KiB. + * + * For a KEYED module (ADR-0019 pillar 4) this bounds the canonical byte size + * of the command's BUFFERED WRITES (the records it `put`s), the keyed analog + * of next-state amplification. + */ + maxResultBytes?: number; + /** + * Max number of buffered writes (puts + deletes) one KEYED command may make + * before commit. Caps how many records a single command can touch, the keyed + * analog of `maxEffects` for fan-out. Deterministic (counted from the view's + * buffer after the reducer returns), so over-budget commands are rejected + * identically on every replica. Default: 256. + */ + maxWrites?: number; + /** + * Default wall-clock step budget (ms) for the LEADER-SIDE admission meter + * (ADR-0019 pillar 6 "gas" — M9), used for a `sandbox` module that does not + * set its own `stepBudgetMs`. ONLY the leader's pre-log `admit()` dry-run uses + * this timeout, to reject a runaway reducer before it enters the log; the + * apply path NEVER uses a timeout (it must stay deterministic — followers + * trust committed entries are already bounded under the CFT model). Unlike the + * other bounds here, this is NOT deterministic state — it is an edge-side + * admission gate whose result is discarded. Default: 50. + */ + stepBudgetMs?: number; +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 44cbfcd..98c219b 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -2,6 +2,10 @@ import { RaftNode } from '../src/consensus/raftNode'; import { LocalTransport, RpcHandler } from '../src/consensus/transport'; import { PeerInfo } from '../src/consensus/types'; import { BookNode, BookStateMachine } from '../src/models/bookStateMachine'; +import { ModuleStateMachine } from '../src/runtime/moduleStateMachine'; +import { ModuleAppCommand } from '../src/runtime/types'; +import { AnyModuleDefinition } from '../src/runtime/moduleHost'; +import { KeyRegistry } from '../src/runtime/signing'; // Fast timers keep the test suite snappy and deterministic. const TEST_TIMERS = { @@ -25,6 +29,36 @@ export function buildCluster(size: number): BookNode[] { return nodes; } +/** A node whose application is the {@link ModuleStateMachine} runtime (ADR-0019). */ +export type ModuleNode = RaftNode; + +/** + * Build an in-process cluster of `size` nodes running the module runtime. Each + * node gets its OWN {@link ModuleStateMachine} (independent replica), registered + * with the same `modules` and the same optional `keyRegistry`, so MODULE commands + * apply identically and converge. + */ +export function buildModuleCluster( + size: number, + modules: AnyModuleDefinition[], + opts: { keyRegistry?: KeyRegistry } = {}, +): ModuleNode[] { + const registry = new Map(); + const transport = new LocalTransport(registry, 1); + const ids = Array.from({ length: size }, (_, i) => `node${i + 1}`); + + const nodes = ids.map((id) => { + const peers: PeerInfo[] = ids.filter((p) => p !== id).map((p) => ({ id: p, url: `local://${p}` })); + const sm = new ModuleStateMachine(); + sm.host.registerModules(modules); + if (opts.keyRegistry) sm.host.setKeyRegistry(opts.keyRegistry); + return new RaftNode({ id, peers, stateMachine: sm, ...TEST_TIMERS }, transport); + }); + + nodes.forEach((n) => registry.set(n.id, n)); + return nodes; +} + /** Poll until `predicate` is true or `timeoutMs` elapses. */ export async function waitFor(predicate: () => boolean, timeoutMs = 2000, stepMs = 10): Promise { const start = Date.now(); @@ -35,6 +69,7 @@ export async function waitFor(predicate: () => boolean, timeoutMs = 2000, stepMs throw new Error('waitFor: condition not met within timeout'); } -export function leaders(nodes: BookNode[]): BookNode[] { +/** The current leaders among `nodes` (generic over the node's command type). */ +export function leaders(nodes: N[]): N[] { return nodes.filter((n) => n.isLeader()); } diff --git a/tests/membership.test.ts b/tests/membership.test.ts index 4776673..271e910 100644 --- a/tests/membership.test.ts +++ b/tests/membership.test.ts @@ -7,6 +7,15 @@ import { waitFor } from './helpers'; const TIMERS = { electionMinMs: 50, electionMaxMs: 100, heartbeatMs: 20 }; +// Several tests here run multiple membership changes + elections back-to-back, +// each gated by its own `waitFor` (up to 2–3s apiece). Those internal budgets are +// the real guards — they throw a clear "condition not met" if anything genuinely +// stalls. Their SUM, however, can exceed Jest's 5s default test timeout when +// workers run in parallel under CPU contention (the suite passes in isolation), +// which is the only reason this test flaked. Give the suite ample headroom so a +// slow-but-correct run is never mistaken for a hang. +jest.setTimeout(15000); + /** * A cluster whose registry + node set the test controls, so nodes can be added * to or removed from the configuration at runtime. diff --git a/tests/runtime/audit.test.ts b/tests/runtime/audit.test.ts new file mode 100644 index 0000000..f4e2e73 --- /dev/null +++ b/tests/runtime/audit.test.ts @@ -0,0 +1,186 @@ +import { MerkleAudit } from '../../src/runtime/merkleAudit'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { defineModule } from '../../src/runtime/defineModule'; +import { counter } from '../../src/runtime/modules/counter'; +import { notes } from '../../src/runtime/modules/notes'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +function freshHost(): ModuleHost { + const host = new ModuleHost(); + host.register(counter); + host.register(notes); + return host; +} + +describe('ModuleHost Merkle audit', () => { + it('grows the audit and stamps each leaf with module/command/status/codeHash', () => { + const host = freshHost(); + expect(host.auditSize()).toBe(0); + expect(host.auditRoot()).toBe(new MerkleAudit().root()); // empty-tree constant + + host.apply(cmd('counter', 'increment', { by: 2 }, seed('a1')), META); + host.apply(cmd('notes', 'create', { text: 'hi' }, seed('a2')), META); + + expect(host.auditSize()).toBe(2); + const entries = host.auditEntries(); + + expect(entries[0]).toMatchObject({ + seq: 0, + module: 'counter', + command: 'increment', + actor: 'tester', + requestId: 'req-1', + status: 200, + codeHash: host.moduleCodeHash('counter'), + }); + expect(entries[1]).toMatchObject({ + seq: 1, + module: 'notes', + command: 'create', + status: 200, + codeHash: host.moduleCodeHash('notes'), + }); + // Different modules -> different code hashes. + expect(entries[0].codeHash).not.toBe(entries[1].codeHash); + }); + + it('auditProof(seq) verifies against auditRoot()', () => { + const host = freshHost(); + for (let i = 0; i < 5; i += 1) { + host.apply(cmd('counter', 'increment', { by: 1 }, seed(`s${i}`)), META); + } + const root = host.auditRoot(); + for (let seq = 0; seq < host.auditSize(); seq += 1) { + expect(MerkleAudit.verify(root, host.auditProof(seq))).toBe(true); + } + }); + + it('two independent hosts applying the same stream converge on the same auditRoot', () => { + const stream: ModuleCommand[] = [ + cmd('counter', 'increment', { by: 3 }, seed('s1')), + cmd('notes', 'create', { text: 'first' }, seed('s2', '2026-01-01T00:00:00.000Z')), + cmd('counter', 'reset', undefined, seed('s3')), + cmd('notes', 'create', { text: 'second' }, seed('s4', '2026-02-02T00:00:00.000Z')), + cmd('counter', 'increment', undefined, seed('s5')), + ]; + + const h1 = freshHost(); + const h2 = freshHost(); + for (const c of stream) { + h1.apply(c, META); + h2.apply(c, META); + } + + expect(h1.auditRoot()).toBe(h2.auditRoot()); + expect(h1.auditEntries()).toEqual(h2.auditEntries()); + }); + + it('captures which logic version ran: a changed version/body yields a new codeHash on its leaves', () => { + // Two modules, same shape, different reducer BODY -> different codeHash. + const v1 = defineModule<{ n: number }>({ + name: 'widget', + initialState: () => ({ n: 0 }), + commands: { bump: (s) => ({ state: { n: s.n + 1 } }) }, + }); + const v2 = defineModule<{ n: number }>({ + name: 'widget', + initialState: () => ({ n: 0 }), + commands: { bump: (s) => ({ state: { n: s.n + 2 } }) }, // changed logic + }); + + const hostV1 = new ModuleHost(); + hostV1.register(v1); + hostV1.apply(cmd('widget', 'bump', undefined, seed('w1')), META); + + const hostV2 = new ModuleHost(); + hostV2.register(v2); + hostV2.apply(cmd('widget', 'bump', undefined, seed('w1')), META); + + const hash1 = hostV1.moduleCodeHash('widget')!; + const hash2 = hostV2.moduleCodeHash('widget')!; + expect(hash1).not.toBe(hash2); + + // Each version's leaf records its own code hash -> the audit proves which + // logic version produced the result, and the two audits diverge. + expect(hostV1.auditEntries()[0].codeHash).toBe(hash1); + expect(hostV2.auditEntries()[0].codeHash).toBe(hash2); + expect(hostV1.auditRoot()).not.toBe(hostV2.auditRoot()); + + // A bumped `version` alone (identical body) also shifts the hash. + const sameBodyV3 = defineModule<{ n: number }>({ + name: 'widget', + version: '2.0.0', + initialState: () => ({ n: 0 }), + commands: { bump: (s) => ({ state: { n: s.n + 1 } }) }, + }); + const hostV3 = new ModuleHost(); + hostV3.register(sameBodyV3); + expect(hostV3.moduleCodeHash('widget')).not.toBe(hash1); + }); + + it('audit survives snapshot/restore: root and proofs stay stable', () => { + const host = freshHost(); + for (let i = 0; i < 6; i += 1) { + host.apply(cmd('counter', 'increment', { by: 1 }, seed(`r${i}`)), META); + } + const rootBefore = host.auditRoot(); + const snap = host.snapshot(); + + const restored = freshHost(); + restored.restore(snap); + + expect(restored.auditSize()).toBe(host.auditSize()); + expect(restored.auditRoot()).toBe(rootBefore); + for (let seq = 0; seq < restored.auditSize(); seq += 1) { + expect(MerkleAudit.verify(restored.auditRoot(), restored.auditProof(seq))).toBe(true); + } + + // Audit continues seamlessly after restore (seq keeps climbing). + restored.apply(cmd('counter', 'increment', { by: 1 }, seed('after')), META); + expect(restored.auditSize()).toBe(host.auditSize() + 1); + expect(restored.auditEntries()[host.auditSize()].seq).toBe(host.auditSize()); + }); + + it('audits business failures and thrown reducers; does NOT audit unknown commands', () => { + // A module whose reducer throws to signal a business failure (-> status 500). + const flaky = defineModule<{ ok: boolean }>({ + name: 'flaky', + initialState: () => ({ ok: true }), + commands: { + fail: () => { + throw new Error('business rule violated'); + }, + ok: (s) => ({ state: s }), + }, + }); + + const host = new ModuleHost(); + host.register(flaky); + + // Thrown reducer -> 500, still audited with that status. + const failed = host.apply(cmd('flaky', 'fail', undefined, seed('f1')), META); + expect(failed.status).toBe(500); + expect(host.auditSize()).toBe(1); + expect(host.auditEntries()[0]).toMatchObject({ command: 'fail', status: 500 }); + + // A successful command is audited too. + host.apply(cmd('flaky', 'ok', undefined, seed('f2')), META); + expect(host.auditSize()).toBe(2); + + // Unknown module / unknown command: no logic ran -> NOT audited. + expect(host.apply(cmd('ghost', 'whatever', {}, seed('f3')), META).status).toBe(404); + expect(host.apply(cmd('flaky', 'nope', {}, seed('f4')), META).status).toBe(404); + expect(host.auditSize()).toBe(2); + }); +}); diff --git a/tests/runtime/consensusIntegration.test.ts b/tests/runtime/consensusIntegration.test.ts new file mode 100644 index 0000000..df251be --- /dev/null +++ b/tests/runtime/consensusIntegration.test.ts @@ -0,0 +1,122 @@ +import { CommandMeta } from '../../src/consensus/types'; +import { buildModuleCommand } from '../../src/runtime/command'; +import { counter } from '../../src/runtime/modules/counter'; +import { notes } from '../../src/runtime/modules/notes'; +import { buildModuleCluster, leaders, ModuleNode, waitFor } from '../helpers'; + +/** + * M4: the module runtime rides REAL Raft as a pluggable application state machine + * (ADR-0017 seam). The leader resolves the seed up front (via `buildModuleCommand`), + * the command replicates as an ordinary application log entry, and every node + * applies it to its own `ModuleStateMachine`/`ModuleHost` — converging on identical + * module state AND identical Merkle audit roots, with idempotency (substrate dedup) + * and leader-resolved seeds carried through the log. + */ +describe('Module commands over Raft consensus', () => { + let nodes: ModuleNode[]; + + beforeEach(() => { + // Each node gets its own ModuleStateMachine registered with the same modules. + nodes = buildModuleCluster(3, [counter, notes]); + nodes.forEach((n) => n.start()); + }); + + afterEach(() => { + nodes.forEach((n) => n.stop()); + }); + + it('replicates module commands and converges state + audit root on every node', async () => { + await waitFor(() => leaders(nodes).length === 1); + const leader = leaders(nodes)[0]; + + const incMeta: CommandMeta = { + requestId: 'req-inc-1', + actor: 'alice', + timestamp: new Date().toISOString(), + }; + const incResult = await leader.submit(buildModuleCommand('counter', 'increment', { by: 5 }, incMeta), incMeta); + expect(incResult.status).toBe(200); + + const noteMeta: CommandMeta = { + requestId: 'req-note-1', + actor: 'bob', + timestamp: new Date().toISOString(), + }; + const noteResult = await leader.submit(buildModuleCommand('notes', 'create', { text: 'hello' }, noteMeta), noteMeta); + expect(noteResult.status).toBe(200); + + // Wait for replication of BOTH commands: gate on the LAST submitted entry + // (the note) as well as the counter, so we never read mid-replication while + // the later entry is still in flight on a follower. + await waitFor(() => + nodes.every( + (n) => + n.app.host.query('counter', 'value') === 5 && + (n.app.host.query('notes', 'list') as unknown[]).length === 1, + ), + ); + + // All three nodes converge on identical module state AND audit root. + const counterValues = new Set(nodes.map((n) => n.app.host.query('counter', 'value'))); + expect(counterValues).toEqual(new Set([5])); + + const noteLists = nodes.map((n) => JSON.stringify(n.app.host.getState('notes'))); + expect(new Set(noteLists).size).toBe(1); + + const auditRoots = new Set(nodes.map((n) => n.app.host.auditRoot())); + expect(auditRoots.size).toBe(1); + }); + + it('does not double-apply a module command whose requestId is replayed (idempotency)', async () => { + await waitFor(() => leaders(nodes).length === 1); + const leader = leaders(nodes)[0]; + + const meta: CommandMeta = { + requestId: 'req-dup', + actor: 'alice', + timestamp: new Date().toISOString(), + }; + + await leader.submit(buildModuleCommand('counter', 'increment', { by: 3 }, meta), meta); + await waitFor(() => nodes.every((n) => n.app.host.query('counter', 'value') === 3)); + + // Re-submit with the SAME requestId: the substrate dedup cache returns the + // cached result without re-running the reducer, so the counter must NOT advance. + await leader.submit(buildModuleCommand('counter', 'increment', { by: 3 }, meta), meta); + + // Give the second command a chance to commit/apply, then assert no change. + await waitFor(() => leader.status().lastApplied >= 0); // settle + await new Promise((r) => setTimeout(r, 50)); + const values = new Set(nodes.map((n) => n.app.host.query('counter', 'value'))); + expect(values).toEqual(new Set([3])); + }); + + it('carries the leader-resolved seed through the log (same id/timestamp on every node)', async () => { + await waitFor(() => leaders(nodes).length === 1); + const leader = leaders(nodes)[0]; + + const meta: CommandMeta = { + requestId: 'req-seed', + actor: 'carol', + timestamp: new Date().toISOString(), + }; + await leader.submit(buildModuleCommand('notes', 'create', { text: 'seeded' }, meta), meta); + + await waitFor(() => + nodes.every((n) => { + const list = n.app.host.query('notes', 'list') as Array<{ id: string }>; + return list.length === 1; + }), + ); + + // The note's id and createdAt come from ctx, derived from the seed baked + // into the log on the leader. If each node resolved its own seed, these + // would differ — identical values prove the seed flowed through the log. + const noteJsons = nodes.map((n) => JSON.stringify(n.app.host.query('notes', 'list'))); + expect(new Set(noteJsons).size).toBe(1); + + const firstList = nodes[0].app.host.query('notes', 'list') as Array<{ id: string; createdAt: string }>; + expect(firstList[0].id).toBeTruthy(); + expect(firstList[0].createdAt).toBeTruthy(); + }); +}); diff --git a/tests/runtime/determinism.test.ts b/tests/runtime/determinism.test.ts new file mode 100644 index 0000000..dcc49a6 --- /dev/null +++ b/tests/runtime/determinism.test.ts @@ -0,0 +1,197 @@ +import { defineModule } from '../../src/runtime/defineModule'; +import { canonicalBytes, lintReducer } from '../../src/runtime/determinism'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { counter } from '../../src/runtime/modules/counter'; +import { notes } from '../../src/runtime/modules/notes'; +import { payments } from '../../src/runtime/modules/payments'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +describe('determinism lint at registration', () => { + it('rejects a reducer that calls Math.random() (strict default)', () => { + expect(() => + defineModule<{ v: number }>({ + name: 'bad-random', + initialState: () => ({ v: 0 }), + commands: { + go: (state) => ({ state: { v: Math.random() } }), + }, + }), + ).toThrow(/Math\.random/); + }); + + it('rejects a reducer that calls Date.now() (strict default)', () => { + expect(() => + defineModule<{ v: number }>({ + name: 'bad-date', + initialState: () => ({ v: 0 }), + commands: { + go: (state) => ({ state: { v: Date.now() } }), + }, + }), + ).toThrow(/Date\.now/); + }); + + it('allows a non-deterministic reducer when strict: false, recording the violations', () => { + const mod = defineModule<{ v: number }>( + { + name: 'vetted', + initialState: () => ({ v: 0 }), + commands: { + go: (state) => ({ state: { v: Math.random() } }), + }, + }, + { strict: false }, + ); + expect(mod.name).toBe('vetted'); + expect(mod.__lint).toBeDefined(); + expect(mod.__lint!.some((v) => /Math\.random/.test(v))).toBe(true); + }); + + it('lintReducer returns empty for the clean demo reducers (no false positives)', () => { + // The demo modules use ctx.now / ctx.id() / ctx.random() — through ctx, + // never ambient globals — so they MUST pass the lint cleanly. + for (const mod of [counter, notes, payments]) { + for (const [name, fn] of Object.entries(mod.commands)) { + expect(lintReducer(name, fn)).toEqual([]); + } + } + }); + + it('does not flag a state field merely named "random"', () => { + // Guard against a false positive: a field named `random` is not access to + // the Math.random global, so the module must define cleanly. + const mod = defineModule<{ random: number }>({ + name: 'has-random-field', + initialState: () => ({ random: 0 }), + commands: { + set: (state, input) => ({ state: { random: (input as { v: number }).v } }), + }, + }); + expect(mod.name).toBe('has-random-field'); + }); +}); + +describe('canonicalBytes', () => { + it('is independent of key insertion order', () => { + expect(canonicalBytes({ a: 1, b: 2 })).toBe(canonicalBytes({ b: 2, a: 1 })); + }); + + it('grows with content size', () => { + const small = canonicalBytes({ items: ['x'] }); + const large = canonicalBytes({ items: Array.from({ length: 100 }, () => 'x') }); + expect(large).toBeGreaterThan(small); + }); +}); + +describe('deterministic resource bound: maxEffects', () => { + /** A module whose `spam` command emits `input.n` effect intents. */ + const spammer = defineModule<{ count: number }>({ + name: 'spammer', + initialState: () => ({ count: 0 }), + commands: { + spam: (state, input) => { + const n = (input as { n: number }).n; + return { + state: { count: state.count + 1 }, + effects: Array.from({ length: n }, (_v, i) => ({ + kind: 'noop', + idempotencyKey: `k-${i}`, + payload: {}, + })), + }; + }, + }, + queries: { count: (state) => state.count }, + }); + + function host(): ModuleHost { + const h = new ModuleHost({ maxEffects: 3 }); + h.register(spammer); + return h; + } + + it('rejects a command emitting more than maxEffects with 413, leaving state + outbox unchanged', () => { + const h1 = host(); + const h2 = host(); + + const res1 = h1.apply(cmd('spammer', 'spam', { n: 4 }, seed('s1')), META); + const res2 = h2.apply(cmd('spammer', 'spam', { n: 4 }, seed('s1')), META); + + expect(res1.status).toBe(413); + expect(res1.message).toMatch(/exceeding the limit of 3/); + // State not adopted, no effects enqueued — identical on both hosts. + expect(h1.query('spammer', 'count')).toBe(0); + expect(h1.getOutbox()).toEqual([]); + expect(res1).toEqual(res2); + expect(h1.snapshot()).toEqual(h2.snapshot()); + }); + + it('applies a command at the maxEffects boundary normally', () => { + const h = host(); + const res = h.apply(cmd('spammer', 'spam', { n: 3 }, seed('s2')), META); + expect(res.status).toBe(200); + expect(res.effects).toHaveLength(3); + expect(h.query('spammer', 'count')).toBe(1); + expect(h.getOutbox()).toHaveLength(3); + }); +}); + +describe('deterministic resource bound: maxResultBytes', () => { + /** A module whose `grow` command produces a state of roughly `input.n` bytes. */ + const grower = defineModule<{ blob: string }>({ + name: 'grower', + initialState: () => ({ blob: '' }), + commands: { + grow: (state, input) => ({ state: { blob: 'x'.repeat((input as { n: number }).n) } }), + }, + queries: { size: (state) => state.blob.length }, + }); + + function host(): ModuleHost { + const h = new ModuleHost({ maxResultBytes: 256 }); + h.register(grower); + return h; + } + + it('rejects an over-budget next-state with 413, leaving state unchanged (deterministic across hosts)', () => { + const h1 = host(); + const h2 = host(); + + const res1 = h1.apply(cmd('grower', 'grow', { n: 1000 }, seed('g1')), META); + const res2 = h2.apply(cmd('grower', 'grow', { n: 1000 }, seed('g1')), META); + + expect(res1.status).toBe(413); + expect(res1.message).toMatch(/exceeding the limit of 256 bytes/); + expect(h1.query('grower', 'size')).toBe(0); // state not adopted + expect(res1).toEqual(res2); + expect(h1.snapshot()).toEqual(h2.snapshot()); + }); + + it('applies an under-budget next-state normally', () => { + const h = host(); + const res = h.apply(cmd('grower', 'grow', { n: 50 }, seed('g2')), META); + expect(res.status).toBe(200); + expect(h.query('grower', 'size')).toBe(50); + }); +}); + +describe('clean module under both budgets', () => { + it('applies normally with default budgets', () => { + const h = new ModuleHost(); + h.register(counter); + const res = h.apply(cmd('counter', 'increment', { by: 5 }, seed('c1')), META); + expect(res.status).toBe(200); + expect(h.query('counter', 'value')).toBe(5); + }); +}); diff --git a/tests/runtime/effects.test.ts b/tests/runtime/effects.test.ts new file mode 100644 index 0000000..0d94aeb --- /dev/null +++ b/tests/runtime/effects.test.ts @@ -0,0 +1,319 @@ +import { EffectExecutor } from '../../src/runtime/effectExecutor'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { defineModule } from '../../src/runtime/defineModule'; +import { payments } from '../../src/runtime/modules/payments'; +import { EffectHandler, EffectResultEntry, ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +/** A fixed seed makes the deterministic id() (and thus idempotencyKey) reproducible. */ +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +function freshHost(): ModuleHost { + const host = new ModuleHost(); + host.register(payments); + return host; +} + +/** A handler that succeeds, counts its invocations, and reports the order paid. */ +function okHandler(): EffectHandler & { calls: number } { + const fn = (async (intent) => { + fn.calls += 1; + const { orderId } = intent.payload as { orderId: string }; + return { orderId, ok: true }; + }) as EffectHandler & { calls: number }; + fn.calls = 0; + return fn; +} + +describe('committed-intent effects: enqueue', () => { + it('charge enqueues exactly one pending effect and marks the order pending', () => { + const host = freshHost(); + + const res = host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + expect(res.status).toBe(200); + expect((res.result as { status: string }).status).toBe('pending'); + + const pending = host.pendingEffects(); + expect(pending).toHaveLength(1); + expect(pending[0].kind).toBe('http'); + expect(pending[0].onResult).toEqual({ module: 'payments', command: 'settle' }); + + const outbox = host.getOutbox(); + expect(outbox).toHaveLength(1); + expect(outbox[0].status).toBe('pending'); + expect(outbox[0].result).toBeUndefined(); + }); + + it('a replayed charge does not re-enqueue the same effect (outbox dedup)', () => { + const host = freshHost(); + const c = cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')); + + host.apply(c, META); + host.apply(c, META); // identical seed -> identical idempotencyKey + + expect(host.getOutbox()).toHaveLength(1); + expect(host.pendingEffects()).toHaveLength(1); + }); +}); + +describe('committed-intent effects: drain + apply result', () => { + it('drains once, submits a result entry, and settle flips the order to paid', async () => { + const host = freshHost(); + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + + const handler = okHandler(); + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (entry) => { + submitted.push(entry); + }); + + await exec.drain(host.pendingEffects()); + + expect(handler.calls).toBe(1); + expect(submitted).toHaveLength(1); + expect((submitted[0].result as { ok: boolean }).ok).toBe(true); + + const applyRes = host.applyEffectResult(submitted[0], META); + expect(applyRes.status).toBe(200); + + // Outbox marked done, result recorded. + const outbox = host.getOutbox(); + expect(outbox[0].status).toBe('done'); + expect(outbox[0].result).toEqual({ orderId: 'o1', ok: true }); + + // settle reducer flipped the order. + const order = host.query('payments', 'order', { orderId: 'o1' }) as { status: string }; + expect(order.status).toBe('paid'); + + // Nothing left pending. + expect(host.pendingEffects()).toHaveLength(0); + }); +}); + +describe('committed-intent effects: exactly-once at state level', () => { + it('a drain with nothing pending is a no-op, and re-applying a result does not double-dispatch', async () => { + const host = freshHost(); + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + + const handler = okHandler(); + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (entry) => { + submitted.push(entry); + }); + + await exec.drain(host.pendingEffects()); + host.applyEffectResult(submitted[0], META); + + // Draining again: nothing pending -> handler not called again. + await exec.drain(host.pendingEffects()); + expect(handler.calls).toBe(1); + + // Re-applying the SAME result entry: idempotent no-op, no second settle. + // We prove no re-dispatch by checking settle didn't run again: corrupt the + // order's status first, then confirm a redelivered result leaves it alone. + const corrupted = host.applyEffectResult(submitted[0], META); + expect(corrupted.status).toBe(200); + // The outbox entry stays done with the original result; still exactly one. + expect(host.getOutbox()).toHaveLength(1); + expect(host.getOutbox()[0].status).toBe('done'); + }); + + it('applyEffectResult for an unknown key is a harmless no-op', () => { + const host = freshHost(); + const res = host.applyEffectResult( + { idempotencyKey: 'never-seen', result: { orderId: 'x', ok: true }, seed: seed('z') }, + META, + ); + expect(res.status).toBe(200); + expect(host.getOutbox()).toHaveLength(0); + }); + + it('redelivered result does not re-run settle (no double state transition)', async () => { + // Build a host whose settle would be observable if dispatched twice by + // counting settle invocations via a spy module wrapping payments' settle. + const host = freshHost(); + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + + const handler = okHandler(); + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (e) => { + submitted.push(e); + }); + await exec.drain(host.pendingEffects()); + + const first = host.applyEffectResult(submitted[0], META); + expect(first.status).toBe(200); + const stateAfterFirst = host.snapshot(); + + const second = host.applyEffectResult(submitted[0], META); + expect(second.status).toBe(200); + // State is byte-identical: the second apply dispatched nothing. + expect(host.snapshot()).toEqual(stateAfterFirst); + }); +}); + +describe('committed-intent effects: concurrency guard', () => { + it('two concurrent drains run the handler only once (in-flight guard)', async () => { + const host = freshHost(); + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + + let calls = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + // A slow handler: holds open until released, so both drains overlap. + const handler: EffectHandler = async (intent) => { + calls += 1; + await gate; + const { orderId } = intent.payload as { orderId: string }; + return { orderId, ok: true }; + }; + + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (e) => { + submitted.push(e); + }); + + const pending = host.pendingEffects(); + // Kick off both drains WITHOUT awaiting between them so the second sees + // the first's key already in flight. + const d1 = exec.drain(pending); + const d2 = exec.drain(pending); + release(); + await Promise.all([d1, d2]); + + expect(calls).toBe(1); + expect(submitted).toHaveLength(1); + }); +}); + +describe('committed-intent effects: failure + retry', () => { + it('a failing handler leaves the effect pending; a later drain completes it', async () => { + const host = freshHost(); + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + + let attempts = 0; + const submitted: EffectResultEntry[] = []; + // First handler always rejects. + const failing = new EffectExecutor( + { + http: async () => { + attempts += 1; + throw new Error('gateway down'); + }, + }, + (e) => submitted.push(e), + ); + + await failing.drain(host.pendingEffects()); + expect(attempts).toBe(1); + expect(submitted).toHaveLength(0); // nothing submitted on failure + expect(host.pendingEffects()).toHaveLength(1); // still pending + expect(host.getOutbox()[0].status).toBe('pending'); + + // Retry with a now-succeeding handler. + const ok = okHandler(); + const recovering = new EffectExecutor({ http: ok }, (e) => submitted.push(e)); + await recovering.drain(host.pendingEffects()); + + expect(ok.calls).toBe(1); + expect(submitted).toHaveLength(1); + + host.applyEffectResult(submitted[0], META); + const order = host.query('payments', 'order', { orderId: 'o1' }) as { status: string }; + expect(order.status).toBe('paid'); + }); +}); + +describe('committed-intent effects: snapshot / restore', () => { + it('the outbox survives snapshot/restore for both pending and done entries', async () => { + const host = freshHost(); + // One effect we leave pending, one we complete. + host.apply(cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')), META); + host.apply(cmd('payments', 'charge', { orderId: 'o2', amount: 200 }, seed('k2')), META); + + const handler = okHandler(); + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (e) => submitted.push(e)); + // Drain both, but only apply the first result so one stays pending... in + // fact both become done once applied. To keep one pending, only complete o1. + const pending = host.pendingEffects(); + // Run handler for just the first intent. + await exec.drain([pending[0]]); + host.applyEffectResult(submitted[0], META); + + const before = host.snapshot(); + const outboxBefore = host.getOutbox(); + const statuses = outboxBefore.map((e) => e.status).sort(); + expect(statuses).toEqual(['done', 'pending']); + + const restored = freshHost(); + restored.restore(before); + + expect(restored.snapshot()).toEqual(before); + expect(restored.getOutbox()).toEqual(outboxBefore); + // A restored pending effect is still drainable. + expect(restored.pendingEffects()).toHaveLength(1); + }); +}); + +describe('committed-intent effects: reserved module names', () => { + // `__`-prefixed names are reserved for runtime internals (e.g. the snapshot's + // `__outbox` key); registering one would silently collide, so it must fail. + const reserved = () => + defineModule({ + name: '__outbox', + initialState: () => ({}), + commands: { noop: (state) => ({ state }) }, + }); + + it('defineModule rejects a `__`-prefixed module name', () => { + expect(reserved).toThrow(/reserved/); + }); + + it('ModuleHost.register rejects a `__`-prefixed module name', () => { + const host = new ModuleHost(); + // Bypass defineModule's guard to prove register fails closed independently. + const sneaky = { + name: '__outbox', + initialState: () => ({}), + commands: { noop: (state: unknown) => ({ state }) }, + }; + expect(() => host.register(sneaky as never)).toThrow(/reserved/); + }); +}); + +describe('committed-intent effects: convergence', () => { + it('two hosts applying the same charge + result entry reach identical snapshots', async () => { + const host1 = freshHost(); + const host2 = freshHost(); + + const charge = cmd('payments', 'charge', { orderId: 'o1', amount: 100 }, seed('k1')); + host1.apply(charge, META); + host2.apply(charge, META); + + // The executor runs ONCE (on the edge) and produces one result entry; both + // replicas apply that same committed entry. + const handler = okHandler(); + const submitted: EffectResultEntry[] = []; + const exec = new EffectExecutor({ http: handler }, (e) => submitted.push(e)); + await exec.drain(host1.pendingEffects()); + expect(submitted).toHaveLength(1); + + host1.applyEffectResult(submitted[0], META); + host2.applyEffectResult(submitted[0], META); + + // Outbox + module state byte-identical across the two independent hosts. + expect(host1.snapshot()).toEqual(host2.snapshot()); + expect(host1.getOutbox()).toEqual(host2.getOutbox()); + }); +}); diff --git a/tests/runtime/keyedStore.test.ts b/tests/runtime/keyedStore.test.ts new file mode 100644 index 0000000..8adca91 --- /dev/null +++ b/tests/runtime/keyedStore.test.ts @@ -0,0 +1,425 @@ +import { defineKeyedModule } from '../../src/runtime/keyedModule'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { accounts } from '../../src/runtime/modules/accounts'; +import { counter } from '../../src/runtime/modules/counter'; +import { MemoryStateStore, StoreView } from '../../src/runtime/stateStore'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +describe('MemoryStateStore', () => { + it('get/put/delete/has round-trip', () => { + const store = new MemoryStateStore(); + expect(store.has('a')).toBe(false); + expect(store.get('a')).toBeUndefined(); + + store.put('a', { v: 1 }); + expect(store.has('a')).toBe(true); + expect(store.get('a')).toEqual({ v: 1 }); + expect(store.size()).toBe(1); + + store.delete('a'); + expect(store.has('a')).toBe(false); + expect(store.size()).toBe(0); + store.delete('a'); // delete of absent key is a no-op + }); + + it('keys/entries are SORTED regardless of insertion order', () => { + const store = new MemoryStateStore(); + store.put('c', 3); + store.put('a', 1); + store.put('b', 2); + + expect(store.keys()).toEqual(['a', 'b', 'c']); + expect(store.entries()).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + expect(store.snapshot()).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + }); + + it('deep-clones on read: mutating a returned record does not change the store', () => { + const store = new MemoryStateStore(); + store.put('rec', { nested: { count: 0 } }); + + const got = store.get('rec') as { nested: { count: number } }; + got.nested.count = 999; + expect((store.get('rec') as { nested: { count: number } }).nested.count).toBe(0); + + // entries() and snapshot() also return clones. + const entry = store.entries()[0][1] as { nested: { count: number } }; + entry.nested.count = 7; + expect((store.get('rec') as { nested: { count: number } }).nested.count).toBe(0); + }); + + it('clones on write: mutating the stored object afterward does not change the store', () => { + const store = new MemoryStateStore(); + const obj = { v: 1 }; + store.put('k', obj); + obj.v = 2; + expect(store.get('k')).toEqual({ v: 1 }); + }); + + it('snapshot/restore round-trips', () => { + const store = new MemoryStateStore(); + store.put('b', { x: 2 }); + store.put('a', { x: 1 }); + const dump = store.snapshot(); + + const restored = new MemoryStateStore(); + restored.restore(dump); + expect(restored.snapshot()).toEqual(dump); + expect(restored.keys()).toEqual(['a', 'b']); + + // Restore is a wholesale replace, not a merge. + restored.put('c', { x: 3 }); + restored.restore(dump); + expect(restored.keys()).toEqual(['a', 'b']); + }); +}); + +describe('StoreView (transactional copy-on-write)', () => { + it('reads-your-writes: buffered puts/deletes are visible before commit', () => { + const store = new MemoryStateStore(); + store.put('a', 1); + const view = new StoreView(store); + + expect(view.get('a')).toBe(1); // falls through to underlying store + view.put('a', 10); + expect(view.get('a')).toBe(10); // sees its own buffered write + view.put('b', 2); + expect(view.has('b')).toBe(true); + + view.delete('a'); + expect(view.get('a')).toBeUndefined(); + expect(view.has('a')).toBe(false); + + // Underlying store is still untouched (nothing committed). + expect(store.get('a')).toBe(1); + expect(store.has('b')).toBe(false); + }); + + it('keys/entries stay SORTED and reflect buffered puts and deletes', () => { + const store = new MemoryStateStore(); + store.put('a', 1); + store.put('c', 3); + const view = new StoreView(store); + + view.put('b', 2); // new key + view.delete('a'); // remove an underlying key + + expect(view.keys()).toEqual(['b', 'c']); + expect(view.entries()).toEqual([ + ['b', 2], + ['c', 3], + ]); + expect(view.size()).toBe(2); + }); + + it('a put cancels a prior buffered delete', () => { + const store = new MemoryStateStore(); + store.put('a', 1); + const view = new StoreView(store); + view.delete('a'); + expect(view.has('a')).toBe(false); + view.put('a', 5); + expect(view.get('a')).toBe(5); + expect(view.has('a')).toBe(true); + }); + + it('commit applies the buffer to the underlying store atomically', () => { + const store = new MemoryStateStore(); + store.put('a', 1); + const view = new StoreView(store); + view.put('a', 10); + view.put('b', 2); + view.delete('c'); // no-op delete + + view.commit(); + expect(store.get('a')).toBe(10); + expect(store.get('b')).toBe(2); + expect(store.keys()).toEqual(['a', 'b']); + }); + + it('abandoning the view (no commit) writes nothing', () => { + const store = new MemoryStateStore(); + store.put('a', 1); + const view = new StoreView(store); + view.put('a', 99); + view.put('z', 100); + // Never call commit(): the view is dropped. + expect(store.get('a')).toBe(1); + expect(store.has('z')).toBe(false); + }); + + it('clones on read so a reducer cannot mutate committed state via a fall-through read', () => { + const store = new MemoryStateStore(); + store.put('rec', { n: 1 }); + const view = new StoreView(store); + const got = view.get('rec') as { n: number }; + got.n = 42; + expect((store.get('rec') as { n: number }).n).toBe(1); + expect((view.get('rec') as { n: number }).n).toBe(1); + }); +}); + +describe('keyed module via ModuleHost: accounts', () => { + function host(): ModuleHost { + const h = new ModuleHost(); + h.register(accounts); + return h; + } + + it('open creates an account; deposit and transfer move funds across keys', () => { + const h = host(); + + const opened = h.apply(cmd('accounts', 'open', { id: 'alice' }, seed('o1')), META); + expect(opened.status).toBe(200); + expect(opened.result).toEqual({ id: 'alice', balance: 0, openedAt: '2026-06-21T00:00:00.000Z' }); + + h.apply(cmd('accounts', 'open', { id: 'bob' }, seed('o2')), META); + expect(h.query('accounts', 'count')).toBe(2); + + expect(h.apply(cmd('accounts', 'deposit', { id: 'alice', amount: 100 }, seed('d1')), META).status).toBe(200); + expect(h.query('accounts', 'balance', { id: 'alice' })).toBe(100); + + expect(h.apply(cmd('accounts', 'transfer', { from: 'alice', to: 'bob', amount: 30 }, seed('t1')), META).status).toBe(200); + expect(h.query('accounts', 'balance', { id: 'alice' })).toBe(70); + expect(h.query('accounts', 'balance', { id: 'bob' })).toBe(30); + }); + + it('rejects opening an existing account, depositing/transferring on missing accounts, and insufficient funds', () => { + const h = host(); + h.apply(cmd('accounts', 'open', { id: 'alice' }, seed('o1')), META); + + expect(h.apply(cmd('accounts', 'open', { id: 'alice' }, seed('o1b')), META).status).toBe(500); // already exists + expect(h.apply(cmd('accounts', 'deposit', { id: 'ghost', amount: 5 }, seed('d2')), META).status).toBe(500); + + h.apply(cmd('accounts', 'open', { id: 'bob' }, seed('o2')), META); + h.apply(cmd('accounts', 'deposit', { id: 'alice', amount: 10 }, seed('d3')), META); + + // Insufficient funds: transfer rejected and NEITHER side changed. + const res = h.apply(cmd('accounts', 'transfer', { from: 'alice', to: 'bob', amount: 50 }, seed('t2')), META); + expect(res.status).toBe(500); + expect(res.message).toMatch(/insufficient/); + expect(h.query('accounts', 'balance', { id: 'alice' })).toBe(10); + expect(h.query('accounts', 'balance', { id: 'bob' })).toBe(0); + }); + + it('a throwing keyed reducer commits NOTHING (store unchanged)', () => { + const thrower = defineKeyedModule({ + name: 'thrower', + commands: { + wreck: (store) => { + store.put('a', 1); + store.put('b', 2); + throw new Error('boom after buffered writes'); + }, + }, + queries: { count: (store) => store.size() }, + }); + const h = new ModuleHost(); + h.register(thrower); + + const res = h.apply(cmd('thrower', 'wreck', undefined, seed('w1')), META); + expect(res.status).toBe(500); + // The buffered puts never reached the store. + expect(h.query('thrower', 'count')).toBe(0); + expect(h.getStore('thrower')!.keys()).toEqual([]); + }); + + it('determinism/convergence: two hosts running the same keyed command stream reach deep-equal snapshots', () => { + const build = (): ModuleHost => { + const h = new ModuleHost(); + h.register(accounts); + return h; + }; + const h1 = build(); + const h2 = build(); + + const stream: ModuleCommand[] = [ + cmd('accounts', 'open', { id: 'alice' }, seed('s1', '2026-01-01T00:00:00.000Z')), + cmd('accounts', 'open', { id: 'bob' }, seed('s2', '2026-02-02T00:00:00.000Z')), + cmd('accounts', 'deposit', { id: 'alice', amount: 100 }, seed('s3')), + cmd('accounts', 'transfer', { from: 'alice', to: 'bob', amount: 40 }, seed('s4')), + cmd('accounts', 'deposit', { id: 'bob', amount: 5 }, seed('s5')), + ]; + // Apply in a DIFFERENT incidental order would still converge, but here we + // simply assert identical stream -> identical sorted snapshot. + for (const c of stream) { + h1.apply(c, META); + h2.apply(c, META); + } + expect(h1.snapshot()).toEqual(h2.snapshot()); + expect(h1.auditRoot()).toBe(h2.auditRoot()); + }); + + it('cross-order convergence: independent keyed commands in DIFFERENT incidental orders reach the same store state', () => { + // The keyed store's sorted-iteration + clone-on-read design exists so that + // the INCIDENTAL apply order of INDEPENDENT commands does not affect the + // committed record state. This is the stronger claim the same-stream test + // above does not exercise: here the two hosts see the same SET of commands + // but in DIFFERENT orders. + // + // The commands are mutually commutative: two `open`s for distinct ids and + // two `deposit`s into distinct accounts never touch a shared key, so no + // ordering creates a read-write conflict. (We deliberately avoid `transfer` + // or a second deposit to the same id — those are order-SENSITIVE on the + // business level, not just incidentally.) Each command carries the same + // leader-resolved seed in both orders, so any timestamp/id is identical. + const cOpenA = cmd('accounts', 'open', { id: 'alice' }, seed('s-a', '2026-01-01T00:00:00.000Z')); + const cOpenB = cmd('accounts', 'open', { id: 'bob' }, seed('s-b', '2026-02-02T00:00:00.000Z')); + const cDepA = cmd('accounts', 'deposit', { id: 'alice', amount: 100 }, seed('s-da')); + const cDepB = cmd('accounts', 'deposit', { id: 'bob', amount: 250 }, seed('s-db')); + + // Host 1: opens first, then deposits. + const h1 = new ModuleHost(); + h1.register(accounts); + for (const c of [cOpenA, cOpenB, cDepA, cDepB]) expect(h1.apply(c, META).status).toBe(200); + + // Host 2: a DIFFERENT incidental order — interleave open/deposit per id, and + // process bob before alice. Every command still succeeds because each id's + // open precedes its own deposit; the cross-id order is free to vary. + const h2 = new ModuleHost(); + h2.register(accounts); + for (const c of [cOpenB, cDepB, cOpenA, cDepA]) expect(h2.apply(c, META).status).toBe(200); + + // The STATE STORE converges: the accounts module's record store is + // deep-equal across the two hosts despite the different apply order. The + // store sorts keys and clones values, so insertion order leaves no trace. + expect(h1.getStore('accounts')!.snapshot()).toEqual(h2.getStore('accounts')!.snapshot()); + // Both hosts agree on the per-account balances, too. + expect(h1.query('accounts', 'balance', { id: 'alice' })).toBe(100); + expect(h2.query('accounts', 'balance', { id: 'alice' })).toBe(100); + expect(h1.query('accounts', 'balance', { id: 'bob' })).toBe(250); + expect(h2.query('accounts', 'balance', { id: 'bob' })).toBe(250); + + // The AUDIT root legitimately DIFFERS: the audit is an ordered hash-chain + // (each leaf records the command in APPLY order), so feeding the same + // commands in a different order produces a different sequence of leaves and + // thus a different root. That is by design — the audit is order-SENSITIVE + // (it records "what ran, in what order"), whereas the keyed STORE state is + // order-INDEPENDENT for commuting commands. Asserting equality on the full + // host `snapshot()` would fail on the `__audit`/`__outbox`-free state only + // if order mattered; we scope to the store precisely because the audit must + // NOT be expected to match here. + expect(h1.auditRoot()).not.toBe(h2.auditRoot()); + }); + + it('snapshot/restore of a keyed module round-trips', () => { + const h = host(); + h.apply(cmd('accounts', 'open', { id: 'alice' }, seed('o1')), META); + h.apply(cmd('accounts', 'deposit', { id: 'alice', amount: 50 }, seed('d1')), META); + + const snap = h.snapshot(); + // The keyed module's dump is the SORTED [key, value][] entries. + expect(snap.accounts).toEqual([['alice', { id: 'alice', balance: 50, openedAt: '2026-06-21T00:00:00.000Z' }]]); + + const restored = host(); + restored.restore(snap); + expect(restored.snapshot()).toEqual(snap); + expect(restored.query('accounts', 'balance', { id: 'alice' })).toBe(50); + expect(restored.query('accounts', 'count')).toBe(1); + }); + + it('the determinism lint rejects a keyed reducer that calls Date.now()', () => { + expect(() => + defineKeyedModule({ + name: 'bad-keyed', + commands: { + go: (store) => { + store.put('t', Date.now()); + return {}; + }, + }, + }), + ).toThrow(/Date\.now/); + }); + + it('the write-count budget rejects an over-maxWrites reducer with 413 and no writes', () => { + const bulk = defineKeyedModule({ + name: 'bulk', + commands: { + fill: (store, input) => { + const n = (input as { n: number }).n; + for (let i = 0; i < n; i += 1) store.put(`k-${i}`, i); + return {}; + }, + }, + queries: { count: (store) => store.size() }, + }); + const h1 = new ModuleHost({ maxWrites: 3 }); + const h2 = new ModuleHost({ maxWrites: 3 }); + h1.register(bulk); + h2.register(bulk); + + const res1 = h1.apply(cmd('bulk', 'fill', { n: 4 }, seed('b1')), META); + const res2 = h2.apply(cmd('bulk', 'fill', { n: 4 }, seed('b1')), META); + expect(res1.status).toBe(413); + expect(res1.message).toMatch(/exceeding the limit of 3/); + expect(h1.query('bulk', 'count')).toBe(0); // nothing committed + expect(res1).toEqual(res2); + expect(h1.snapshot()).toEqual(h2.snapshot()); + + // At the boundary it applies normally. + const ok = h1.apply(cmd('bulk', 'fill', { n: 3 }, seed('b2')), META); + expect(ok.status).toBe(200); + expect(h1.query('bulk', 'count')).toBe(3); + }); + + it('the write-size budget rejects an over-maxResultBytes keyed reducer with 413 and no writes', () => { + const grower = defineKeyedModule({ + name: 'keyed-grower', + commands: { + grow: (store, input) => { + store.put('blob', 'x'.repeat((input as { n: number }).n)); + return {}; + }, + }, + queries: { has: (store) => store.has('blob') }, + }); + const h = new ModuleHost({ maxResultBytes: 256 }); + h.register(grower); + + const res = h.apply(cmd('keyed-grower', 'grow', { n: 1000 }, seed('g1')), META); + expect(res.status).toBe(413); + expect(res.message).toMatch(/bytes/); + expect(h.query('keyed-grower', 'has')).toBe(false); + }); +}); + +describe('back-compat: whole-state and keyed modules coexist', () => { + it('a host with both counter (whole-state) and accounts (keyed) snapshots/restores both', () => { + const h = new ModuleHost(); + h.registerModules([counter, accounts]); + + h.apply(cmd('counter', 'increment', { by: 7 }, seed('c1')), META); + h.apply(cmd('accounts', 'open', { id: 'alice' }, seed('a1')), META); + h.apply(cmd('accounts', 'deposit', { id: 'alice', amount: 12 }, seed('a2')), META); + + const snap = h.snapshot(); + + const restored = new ModuleHost(); + restored.registerModules([counter, accounts]); + restored.restore(snap); + + expect(restored.snapshot()).toEqual(snap); + expect(restored.query('counter', 'value')).toBe(7); + expect(restored.query('accounts', 'balance', { id: 'alice' })).toBe(12); + expect(restored.auditRoot()).toBe(h.auditRoot()); + }); +}); diff --git a/tests/runtime/merkleAudit.test.ts b/tests/runtime/merkleAudit.test.ts new file mode 100644 index 0000000..545a853 --- /dev/null +++ b/tests/runtime/merkleAudit.test.ts @@ -0,0 +1,98 @@ +import { AuditLeaf, EMPTY_ROOT, MerkleAudit, MerkleProof } from '../../src/runtime/merkleAudit'; + +/** Build a leaf with a varying `seq`/`command` so leaf hashes differ. */ +const leaf = (seq: number): AuditLeaf => ({ + seq, + module: 'm', + command: `cmd-${seq}`, + actor: 'tester', + requestId: `req-${seq}`, + status: 200, + codeHash: 'abc', +}); + +/** An accumulator pre-filled with `n` distinct leaves. */ +function filled(n: number): MerkleAudit { + const audit = new MerkleAudit(); + for (let i = 0; i < n; i += 1) audit.append(leaf(i)); + return audit; +} + +describe('MerkleAudit tree', () => { + it('empty tree returns the documented sha256("") root', () => { + const audit = new MerkleAudit(); + expect(audit.size()).toBe(0); + expect(audit.root()).toBe(EMPTY_ROOT); + }); + + // Cover 1, 2 (even), 3 and 5 (ODD — exercise the promote-unchanged tail), and + // a larger set, since proof/build differ across these shapes. + for (const n of [1, 2, 3, 5, 16, 17]) { + it(`inclusion proofs verify against the root for ${n} leaves`, () => { + const audit = filled(n); + const root = audit.root(); + for (let i = 0; i < n; i += 1) { + const proof = audit.proof(i); + expect(MerkleAudit.verify(root, proof)).toBe(true); + } + }); + } + + it('a tampered leaf changes the root and invalidates the old proof', () => { + const audit = filled(5); + const rootBefore = audit.root(); + const proofBefore = audit.proof(2); + expect(MerkleAudit.verify(rootBefore, proofBefore)).toBe(true); + + // Rebuild a tampered tree: same leaves, but leaf 2 mutated. + const tampered = new MerkleAudit(); + for (let i = 0; i < 5; i += 1) { + tampered.append(i === 2 ? { ...leaf(i), status: 500 } : leaf(i)); + } + const rootAfter = tampered.root(); + + expect(rootAfter).not.toBe(rootBefore); + // The pre-tamper proof no longer verifies against the new root. + expect(MerkleAudit.verify(rootAfter, proofBefore)).toBe(false); + // And the genuine new proof for index 2 has a different leaf hash. + expect(tampered.proof(2).leafHash).not.toBe(proofBefore.leafHash); + }); + + it('a proof for one index does not verify a different leaf', () => { + const audit = filled(5); + const root = audit.root(); + const proofFor1 = audit.proof(1); + + // Splice leaf 3's hash into index 1's sibling path — verification must fail. + const forged: MerkleProof = { leafHash: audit.proof(3).leafHash, siblings: proofFor1.siblings }; + expect(MerkleAudit.verify(root, forged)).toBe(false); + }); + + it('snapshot -> restore reproduces the identical root and proofs', () => { + const audit = filled(7); + const snap = audit.snapshot(); + + const restored = new MerkleAudit(); + restored.restore(snap); + + expect(restored.size()).toBe(7); + expect(restored.root()).toBe(audit.root()); + for (let i = 0; i < 7; i += 1) { + expect(restored.proof(i)).toEqual(audit.proof(i)); + expect(MerkleAudit.verify(restored.root(), restored.proof(i))).toBe(true); + } + }); + + it('append returns the leaf index (== seq)', () => { + const audit = new MerkleAudit(); + expect(audit.append(leaf(0))).toBe(0); + expect(audit.append(leaf(1))).toBe(1); + expect(audit.append(leaf(2))).toBe(2); + }); + + it('proof throws on an out-of-range index', () => { + const audit = filled(3); + expect(() => audit.proof(3)).toThrow(/out of range/); + expect(() => audit.proof(-1)).toThrow(/out of range/); + }); +}); diff --git a/tests/runtime/moduleHost.test.ts b/tests/runtime/moduleHost.test.ts new file mode 100644 index 0000000..4122161 --- /dev/null +++ b/tests/runtime/moduleHost.test.ts @@ -0,0 +1,228 @@ +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { counter } from '../../src/runtime/modules/counter'; +import { notes } from '../../src/runtime/modules/notes'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +/** A fixed seed makes assertions on ids/timestamps reproducible. */ +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +/** Helper to build a module command tersely. */ +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +function freshHost(): ModuleHost { + const host = new ModuleHost(); + host.register(counter); + host.register(notes); + return host; +} + +describe('ModuleHost dispatch', () => { + it('dispatches counter increment/reset and answers the value query', () => { + const host = freshHost(); + + expect(host.apply(cmd('counter', 'increment', { by: 5 }, seed('a1')), META).status).toBe(200); + expect(host.apply(cmd('counter', 'increment', undefined, seed('a2')), META).status).toBe(200); // +1 + expect(host.query('counter', 'value')).toBe(6); + + expect(host.apply(cmd('counter', 'reset', undefined, seed('a3')), META).status).toBe(200); + expect(host.query('counter', 'value')).toBe(0); + }); + + it('notes create assigns a deterministic id + createdAt from the seed', () => { + const host = freshHost(); + const ts = '2026-06-21T12:34:56.000Z'; + + const res = host.apply(cmd('notes', 'create', { text: 'hello' }, seed('beef', ts)), META); + expect(res.status).toBe(200); + + // The host surfaces the created note directly as the explicit result. + const created = res.result as { id: string; text: string; createdAt: string }; + expect(created.text).toBe('hello'); + expect(created.createdAt).toBe(ts); // came from ctx.now == seed.timestamp + expect(created.id).toMatch(/^[0-9a-f]{32}$/); // deterministic 32-char hex id + + const list = host.query('notes', 'list') as Array<{ id: string; text: string; createdAt: string }>; + expect(list).toHaveLength(1); + expect(list[0]).toEqual(created); + + // The id is reproducible: a fresh host with the same seed yields the same id. + const host2 = freshHost(); + host2.apply(cmd('notes', 'create', { text: 'hello' }, seed('beef', ts)), META); + const list2 = host2.query('notes', 'list') as Array<{ id: string }>; + expect(list2[0].id).toBe(list[0].id); + }); + + it('returns 404 for an unknown module or command without throwing', () => { + const host = freshHost(); + + const noModule = host.apply(cmd('ghost', 'increment', {}, seed('z1')), META); + expect(noModule.status).toBe(404); + expect(noModule.effects).toEqual([]); + expect(noModule.message).toMatch(/Unknown module/); + + const noCommand = host.apply(cmd('counter', 'nope', {}, seed('z2')), META); + expect(noCommand.status).toBe(404); + expect(noCommand.message).toMatch(/Unknown command/); + + // State is untouched by the failed dispatches. + expect(host.query('counter', 'value')).toBe(0); + }); + + it('a reducer that mutates the passed-in state then throws leaves committed state intact', () => { + // The reducer mutates `state` in place AND throws. Because the host hands + // the reducer a deep clone and only swaps live state on a clean return, + // the committed state must be exactly what it was before the call. + const corrupting = { + name: 'corrupting', + initialState: () => ({ items: ['safe'] as string[] }), + commands: { + wreck: (state: { items: string[] }) => { + state.items.push('corrupted'); // mutate the live-looking reference + throw new Error('boom after mutation'); + }, + }, + queries: { + items: (state: { items: string[] }) => state.items, + }, + }; + + const host = new ModuleHost(); + host.register(corrupting as any); + + const before = host.getState('corrupting'); + const res = host.apply(cmd('corrupting', 'wreck', undefined, seed('m1')), META); + + expect(res.status).toBe(500); + // Committed state unchanged: the in-place mutation hit only the clone. + expect(host.query('corrupting', 'items')).toEqual(['safe']); + expect(host.getState('corrupting')).toEqual(before); + }); +}); + +describe('ModuleHost determinism / convergence', () => { + it('two independent hosts reach byte-identical state from the same command stream', () => { + // A module whose reducer pulls from BOTH ctx.random() and ctx.id(), to + // prove both deterministic streams are reproducible across hosts. + const roller = { + name: 'roller', + initialState: () => ({ rolls: [] as Array<{ id: string; r: number; at: string }> }), + commands: { + roll: (state: { rolls: Array<{ id: string; r: number; at: string }> }, _input: unknown, ctx: any) => ({ + state: { rolls: [...state.rolls, { id: ctx.id(), r: ctx.random(), at: ctx.now }] }, + }), + }, + }; + + const build = (): ModuleHost => { + const h = new ModuleHost(); + h.register(counter); + h.register(notes); + h.register(roller as any); + return h; + }; + + const host1 = build(); + const host2 = build(); + + // Identical command stream (identical seeds) applied to both hosts. + const stream: ModuleCommand[] = [ + cmd('counter', 'increment', { by: 3 }, seed('s1')), + cmd('notes', 'create', { text: 'first' }, seed('s2', '2026-01-01T00:00:00.000Z')), + cmd('roller', 'roll', undefined, seed('s3')), + cmd('roller', 'roll', undefined, seed('s3')), // same seed -> same id+random again + cmd('counter', 'increment', undefined, seed('s4')), + cmd('notes', 'create', { text: 'second' }, seed('s5', '2026-02-02T00:00:00.000Z')), + ]; + + for (const c of stream) { + host1.apply(c, META); + host2.apply(c, META); + } + + // Byte-identical state, including ids, timestamps, and random() outputs. + expect(host1.snapshot()).toEqual(host2.snapshot()); + + // Sanity: the random()/id() values actually landed in state. + const rollerState = host1.getState('roller') as { rolls: Array<{ id: string; r: number }> }; + expect(rollerState.rolls).toHaveLength(2); + expect(typeof rollerState.rolls[0].r).toBe('number'); + expect(rollerState.rolls[0].r).toBeGreaterThanOrEqual(0); + expect(rollerState.rolls[0].r).toBeLessThan(1); + // Same seed reused for both rolls -> identical id and random output. + expect(rollerState.rolls[0]).toEqual(rollerState.rolls[1]); + }); + + it('successive ctx.random()/ctx.id() calls within one command advance deterministically', () => { + // Two calls in the SAME reducer invocation must differ from each other, + // but be identical across hosts for the same seed. + const multi = { + name: 'multi', + initialState: () => ({ ids: [] as string[], rs: [] as number[] }), + commands: { + go: (_s: { ids: string[]; rs: number[] }, _i: unknown, ctx: any) => ({ + state: { ids: [ctx.id(), ctx.id()], rs: [ctx.random(), ctx.random()] }, + }), + }, + }; + + const mk = () => { + const h = new ModuleHost(); + h.register(multi as any); + return h; + }; + + const h1 = mk(); + const h2 = mk(); + h1.apply(cmd('multi', 'go', undefined, seed('seed-x')), META); + h2.apply(cmd('multi', 'go', undefined, seed('seed-x')), META); + + const s1 = h1.getState('multi') as { ids: string[]; rs: number[] }; + expect(s1.ids[0]).not.toBe(s1.ids[1]); // successive ids differ + expect(s1.rs[0]).not.toBe(s1.rs[1]); // successive randoms differ + expect(h1.snapshot()).toEqual(h2.snapshot()); // but reproducible across hosts + }); +}); + +describe('ModuleHost snapshot / restore', () => { + it('round-trips state into a fresh host', () => { + const host = freshHost(); + host.apply(cmd('counter', 'increment', { by: 9 }, seed('r1')), META); + host.apply(cmd('notes', 'create', { text: 'persist me' }, seed('r2')), META); + + const snap = host.snapshot(); + + const restored = freshHost(); + restored.restore(snap); + + expect(restored.snapshot()).toEqual(snap); + expect(restored.query('counter', 'value')).toBe(9); + expect((restored.query('notes', 'list') as unknown[]).length).toBe(1); + expect(restored.query('notes', 'list')).toEqual(host.query('notes', 'list')); + }); + + it('snapshot is decoupled from live state (deep clone)', () => { + const host = freshHost(); + host.apply(cmd('counter', 'increment', { by: 1 }, seed('d1')), META); + + const snap = host.snapshot(); + host.apply(cmd('counter', 'increment', { by: 1 }, seed('d2')), META); // mutate after snapshot + + expect((snap.counter as { value: number }).value).toBe(1); // snapshot unaffected + expect(host.query('counter', 'value')).toBe(2); + }); +}); + +describe('ModuleHost registration', () => { + it('throws on duplicate module registration', () => { + const host = new ModuleHost(); + host.register(counter); + expect(() => host.register(counter)).toThrow(/already registered/); + }); +}); diff --git a/tests/runtime/projection.test.ts b/tests/runtime/projection.test.ts new file mode 100644 index 0000000..b27bd63 --- /dev/null +++ b/tests/runtime/projection.test.ts @@ -0,0 +1,232 @@ +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { notes } from '../../src/runtime/modules/notes'; +import { ProjectionHost } from '../../src/runtime/projectionHost'; +import { ProjectionEvent } from '../../src/runtime/projection'; +import { noteIndex } from '../../src/runtime/projections/noteIndex'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +/** A fixed seed makes ids/timestamps reproducible (mirrors moduleHost.test). */ +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +/** + * Build a flat stream of committed `notes.create` events from several actors, + * deriving each event's note id deterministically from its seed (the same id the + * `notes` reducer would mint). Building the events directly keeps these tests + * focused on the read side, without coupling to `ModuleHost` internals — the + * realistic wiring is exercised separately below in "derived from ModuleHost". + */ +function noteCreateEvent(seq: number, actor: string, text: string, noteId: string): ProjectionEvent { + return { + seq, + module: 'notes', + command: 'create', + input: { text }, + result: { id: noteId, text, createdAt: '2026-06-21T00:00:00.000Z' }, + actor, + requestId: `req-${seq}`, + }; +} + +function freshHost(): ProjectionHost { + const host = new ProjectionHost(); + host.register(noteIndex); + return host; +} + +/** A representative multi-actor stream reused across several tests. */ +function sampleStream(): ProjectionEvent[] { + return [ + noteCreateEvent(0, 'alice', 'a1', 'id-a1'), + noteCreateEvent(1, 'bob', 'b1', 'id-b1'), + noteCreateEvent(2, 'alice', 'a2', 'id-a2'), + noteCreateEvent(3, 'carol', 'c1', 'id-c1'), + noteCreateEvent(4, 'alice', 'a3', 'id-a3'), + noteCreateEvent(5, 'bob', 'b2', 'id-b2'), + ]; +} + +describe('Projection: noteIndex rich queries', () => { + it('answers byActor / total / actors that the flat module state does not index', () => { + const host = freshHost(); + for (const e of sampleStream()) { + host.applyEvent(e); + } + + // Indexed O(1) lookup the raw `notes` array would need an O(n) scan for. + expect(host.query('noteIndex', 'byActor', 'alice')).toEqual(['id-a1', 'id-a2', 'id-a3']); + expect(host.query('noteIndex', 'byActor', 'bob')).toEqual(['id-b1', 'id-b2']); + expect(host.query('noteIndex', 'byActor', 'carol')).toEqual(['id-c1']); + + // Unknown actor -> empty, never undefined. + expect(host.query('noteIndex', 'byActor', 'nobody')).toEqual([]); + + expect(host.query('noteIndex', 'total')).toBe(6); + expect(host.query('noteIndex', 'actors')).toEqual(['alice', 'bob', 'carol']); + }); + + it('ignores events for other modules/commands inside the fold', () => { + const host = freshHost(); + host.applyEvent(noteCreateEvent(0, 'alice', 'a1', 'id-a1')); + // A non-notes event and a non-create notes event must not touch the index. + host.applyEvent({ + seq: 1, + module: 'counter', + command: 'increment', + input: { by: 1 }, + result: undefined, + actor: 'alice', + requestId: 'req-1', + }); + host.applyEvent({ + seq: 2, + module: 'notes', + command: 'delete', + input: { id: 'id-a1' }, + result: undefined, + actor: 'alice', + requestId: 'req-2', + }); + + expect(host.query('noteIndex', 'total')).toBe(1); + expect(host.query('noteIndex', 'actors')).toEqual(['alice']); + }); + + it('rejects an unknown projection or query', () => { + const host = freshHost(); + expect(() => host.query('ghost', 'total')).toThrow(/Unknown projection/); + expect(() => host.query('noteIndex', 'nope')).toThrow(/Unknown query/); + }); +}); + +describe('Projection: rebuildable (derived, not authoritative)', () => { + it('rebuild from the same stream reconstructs the identical incremental view', () => { + const events = sampleStream(); + + // Incremental host: folded event-by-event. + const incremental = freshHost(); + for (const e of events) { + incremental.applyEvent(e); + } + const incrementalSnap = incremental.snapshot(); + + // Fresh host: drop everything and rebuild purely from the log stream. + const rebuilt = freshHost(); + rebuilt.rebuild(events); + + // The cache (incremental) and the from-scratch replay agree => the read + // model is DERIVED, reconstructible from the committed stream alone. + expect(rebuilt.snapshot()).toEqual(incrementalSnap); + }); + + it('rebuild resets prior state so the result depends only on the supplied events', () => { + const host = freshHost(); + // Pollute the cache with one stream... + host.applyEvent(noteCreateEvent(0, 'mallory', 'x', 'id-x')); + // ...then rebuild from a DIFFERENT stream; the old event must vanish. + const events = [noteCreateEvent(0, 'alice', 'a1', 'id-a1')]; + host.rebuild(events); + + expect(host.query('noteIndex', 'actors')).toEqual(['alice']); + expect(host.query('noteIndex', 'total')).toBe(1); + expect(host.query('noteIndex', 'byActor', 'mallory')).toEqual([]); + }); +}); + +describe('Projection: convergence across nodes', () => { + it('two independent hosts fed the same stream produce deep-equal snapshots', () => { + const events = sampleStream(); + + const node1 = freshHost(); + const node2 = freshHost(); + for (const e of events) { + node1.applyEvent(e); + node2.applyEvent(e); + } + + // Pure deterministic fold => identical read model on every replica. + expect(node1.snapshot()).toEqual(node2.snapshot()); + }); + + it('deterministic replay: same events in the same committed order => identical view', () => { + const events = sampleStream(); + const a = freshHost(); + const b = freshHost(); + a.rebuild(events); + b.rebuild(events); + expect(a.snapshot()).toEqual(b.snapshot()); + }); +}); + +describe('Projection: snapshot / restore', () => { + it('round-trips the view into a fresh host', () => { + const host = freshHost(); + for (const e of sampleStream()) { + host.applyEvent(e); + } + const snap = host.snapshot(); + + const restored = freshHost(); + restored.restore(snap); + + expect(restored.snapshot()).toEqual(snap); + expect(restored.query('noteIndex', 'byActor', 'alice')).toEqual(['id-a1', 'id-a2', 'id-a3']); + expect(restored.query('noteIndex', 'total')).toBe(6); + }); + + it('snapshot is decoupled from live state (deep clone)', () => { + const host = freshHost(); + host.applyEvent(noteCreateEvent(0, 'alice', 'a1', 'id-a1')); + const snap = host.snapshot(); + host.applyEvent(noteCreateEvent(1, 'alice', 'a2', 'id-a2')); // mutate after snapshot + + const snapView = snap.noteIndex as { total: number }; + expect(snapView.total).toBe(1); // snapshot unaffected by later folds + expect(host.query('noteIndex', 'total')).toBe(2); + }); +}); + +describe('Projection: realistic wiring derived from a ModuleHost apply result', () => { + it('builds ProjectionEvents from committed module commands and indexes them', () => { + // The authoritative write side: a real ModuleHost applying notes.create. + const moduleHost = new ModuleHost(); + moduleHost.register(notes); + + const projections = freshHost(); + + const commands: Array<{ actor: string; c: ModuleCommand }> = [ + { actor: 'alice', c: cmd('notes', 'create', { text: 'hello' }, seed('n1')) }, + { actor: 'bob', c: cmd('notes', 'create', { text: 'world' }, seed('n2')) }, + { actor: 'alice', c: cmd('notes', 'create', { text: 'again' }, seed('n3')) }, + ]; + + commands.forEach(({ actor, c }, seq) => { + const meta = { actor, requestId: `req-${seq}` }; + const res = moduleHost.apply(c, meta); + expect(res.status).toBe(200); + // Derive the read-side event from the authoritative apply outcome. + projections.applyEvent({ + seq, + module: c.module, + command: c.command, + input: c.input, + result: res.result, + actor: meta.actor, + requestId: meta.requestId, + }); + }); + + // The projection indexes the real, leader-minted note ids by actor. + const aliceNotes = moduleHost.query('notes', 'list') as Array<{ id: string }>; + const aliceIds = aliceNotes.filter((_n, i) => i === 0 || i === 2).map((n) => n.id); + expect(projections.query('noteIndex', 'byActor', 'alice')).toEqual(aliceIds); + expect(projections.query('noteIndex', 'total')).toBe(3); + expect(projections.query('noteIndex', 'actors')).toEqual(['alice', 'bob']); + }); +}); diff --git a/tests/runtime/sandbox.test.ts b/tests/runtime/sandbox.test.ts new file mode 100644 index 0000000..d5033ab --- /dev/null +++ b/tests/runtime/sandbox.test.ts @@ -0,0 +1,294 @@ +import { defineModule } from '../../src/runtime/defineModule'; +import { lintReducer } from '../../src/runtime/determinism'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { counter } from '../../src/runtime/modules/counter'; +import { compute } from '../../src/runtime/modules/compute'; +import { ModuleCommand, Seed } from '../../src/runtime/types'; + +const META = { actor: 'tester', requestId: 'req-1' }; + +const seed = (nonce: string, timestamp = '2026-06-21T00:00:00.000Z'): Seed => ({ timestamp, nonce }); + +const cmd = (module: string, command: string, input: unknown, s: Seed): ModuleCommand => ({ + module, + command, + input, + seed: s, +}); + +describe('determinism sandbox: structural enforcement on the apply path', () => { + it('a sandboxed reducer that touches Date throws at apply (no lint reliance)', () => { + // Register WITHOUT the static lint (strict: false) so the only thing that + // can catch the Date access is the vm sandbox itself. This proves the + // sandbox is the structural guarantee, not the lint. + const badDate = defineModule<{ v: number }>( + { + name: 'bad-date-sbx', + initialState: () => ({ v: 0 }), + commands: { + // References the ambient Date global, which is ABSENT in the + // sandbox context -> ReferenceError at runtime. + go: (state) => ({ state: { v: (Date as any).now() } }), + }, + }, + { strict: false, sandbox: true }, + ); + + const host = new ModuleHost(); + host.register(badDate); + + const res = host.apply(cmd('bad-date-sbx', 'go', undefined, seed('d1')), META); + // The thrown ReferenceError is caught by dispatch as a 500; state untouched. + expect(res.status).toBe(500); + expect(res.message).toMatch(/Date is not defined|not defined/); + expect(host.getState('bad-date-sbx')).toEqual({ v: 0 }); + }); + + it('a sandboxed reducer that calls Math.random() throws at apply', () => { + const badRandom = defineModule<{ v: number }>( + { + name: 'bad-random-sbx', + initialState: () => ({ v: 0 }), + commands: { + // Math IS available (deterministic members), but `random` is + // removed from the sandbox's Math, so this throws. + go: (state) => ({ state: { v: (Math as any).random() } }), + }, + }, + { strict: false, sandbox: true }, + ); + + const host = new ModuleHost(); + host.register(badRandom); + + const res = host.apply(cmd('bad-random-sbx', 'go', undefined, seed('r1')), META); + expect(res.status).toBe(500); + // Either the getter throw (ReferenceError) or a not-a-function depending + // on engine; both indicate Math.random was unavailable. + expect(res.message).toMatch(/Math\.random|not a function|not available/); + expect(host.getState('bad-random-sbx')).toEqual({ v: 0 }); + }); + + it('a sandboxed reducer calling toLocaleString throws at apply (locale non-determinism blocked)', () => { + // Register WITHOUT the lint (strict: false) so ONLY the sandbox can catch + // the locale call. `Number.prototype.toLocaleString` is neutralized in the + // sandbox context, so this throws at apply -> 500, state untouched. + const badLocale = defineModule<{ v: string }>( + { + name: 'bad-locale-sbx', + initialState: () => ({ v: '' }), + commands: { + go: (state) => ({ state: { v: (1234.5).toLocaleString() } }), + }, + }, + { strict: false, sandbox: true }, + ); + + const host = new ModuleHost(); + host.register(badLocale); + + const res = host.apply(cmd('bad-locale-sbx', 'go', undefined, seed('l1')), META); + expect(res.status).toBe(500); + expect(res.message).toMatch(/locale|not a function|not available/i); + expect(host.getState('bad-locale-sbx')).toEqual({ v: '' }); + }); + + it('a sandboxed reducer using Intl throws at apply (Intl removed from the sandbox)', () => { + const badIntl = defineModule<{ v: string }>( + { + name: 'bad-intl-sbx', + initialState: () => ({ v: '' }), + commands: { + // `Intl` is ABSENT in the sandbox -> ReferenceError at runtime. + go: (state) => ({ state: { v: new (Intl as any).NumberFormat().format(1234.5) } }), + }, + }, + { strict: false, sandbox: true }, + ); + + const host = new ModuleHost(); + host.register(badIntl); + + const res = host.apply(cmd('bad-intl-sbx', 'go', undefined, seed('i1')), META); + expect(res.status).toBe(500); + expect(res.message).toMatch(/Intl is not defined|not defined/); + expect(host.getState('bad-intl-sbx')).toEqual({ v: '' }); + }); + + it('the lint also flags a toLocaleString reducer (defense-in-depth)', () => { + const violations = lintReducer('go', (state: { v: string }) => ({ + state: { v: (1234.5).toLocaleString() }, + })); + expect(violations.some((v) => /toLocaleString/.test(v))).toBe(true); + }); + + it('rejects a sandboxed keyed module at registration (whole-state only this milestone)', () => { + // Build a keyed-shaped definition with a sandbox flag and confirm the host + // refuses it rather than silently ignoring the flag. + const keyedish = { name: 'k', kind: 'keyed', initialState: () => ({}), commands: {}, sandbox: true }; + const host = new ModuleHost(); + expect(() => host.register(keyedish as any)).toThrow(/whole-state modules only/); + }); +}); + +describe('determinism sandbox: ctx still works inside the sandbox', () => { + /** A sandboxed reducer pulling from ctx.now / ctx.id() / ctx.random(). */ + const ctxMod = defineModule<{ rows: Array<{ id: string; r: number; at: string }> }>( + { + name: 'ctx-sbx', + initialState: () => ({ rows: [] }), + commands: { + go: (state, _input, ctx) => ({ + state: { rows: [...state.rows, { id: ctx.id(), r: ctx.random(), at: ctx.now }] }, + }), + }, + }, + { sandbox: true }, + ); + + /** The same logic, NON-sandboxed, to compare values against. */ + const ctxModPlain = defineModule<{ rows: Array<{ id: string; r: number; at: string }> }>({ + name: 'ctx-plain', + initialState: () => ({ rows: [] }), + commands: { + go: (state, _input, ctx) => ({ + state: { rows: [...state.rows, { id: ctx.id(), r: ctx.random(), at: ctx.now }] }, + }), + }, + }); + + it('produces the same deterministic ctx values as the non-sandboxed path', () => { + const sandboxedHost = new ModuleHost(); + sandboxedHost.register(ctxMod); + const plainHost = new ModuleHost(); + plainHost.register(ctxModPlain); + + const s = seed('beef', '2026-06-21T12:00:00.000Z'); + sandboxedHost.apply(cmd('ctx-sbx', 'go', undefined, s), META); + plainHost.apply(cmd('ctx-plain', 'go', undefined, s), META); + + const sRow = (sandboxedHost.getState('ctx-sbx') as any).rows[0]; + const pRow = (plainHost.getState('ctx-plain') as any).rows[0]; + // Byte-identical id / random / timestamp from the same seed. + expect(sRow).toEqual(pRow); + expect(sRow.at).toBe('2026-06-21T12:00:00.000Z'); + expect(sRow.id).toMatch(/^[0-9a-f]{32}$/); + expect(sRow.r).toBeGreaterThanOrEqual(0); + expect(sRow.r).toBeLessThan(1); + }); + + it('two sandboxed hosts converge on identical state from the same command stream', () => { + const build = (): ModuleHost => { + const h = new ModuleHost(); + h.register(ctxMod); + return h; + }; + const h1 = build(); + const h2 = build(); + + const stream = [ + cmd('ctx-sbx', 'go', undefined, seed('s1')), + cmd('ctx-sbx', 'go', undefined, seed('s2')), + cmd('ctx-sbx', 'go', undefined, seed('s1')), // reuse -> same id+random + ]; + for (const c of stream) { + h1.apply(c, META); + h2.apply(c, META); + } + expect(h1.snapshot()).toEqual(h2.snapshot()); + const rows = (h1.getState('ctx-sbx') as any).rows; + expect(rows).toHaveLength(3); + // First and third reused the same seed -> identical row. + expect(rows[0]).toEqual(rows[2]); + }); +}); + +describe('compute module: apply path is deterministic and timeout-free', () => { + function host(): ModuleHost { + const h = new ModuleHost(); + h.register(compute); + return h; + } + + it('sumTo computes the triangular number deterministically and converges', () => { + const h1 = host(); + const h2 = host(); + const res1 = h1.apply(cmd('compute', 'sumTo', { n: 100 }, seed('c1')), META); + const res2 = h2.apply(cmd('compute', 'sumTo', { n: 100 }, seed('c1')), META); + + expect(res1.status).toBe(200); + expect((res1.result as { sum: number }).sum).toBe(5050); // 1..100 + expect(h1.query('compute', 'last')).toBe(5050); + // Apply path uses NO timeout; both hosts agree byte-for-byte. + expect(res1).toEqual(res2); + expect(h1.snapshot()).toEqual(h2.snapshot()); + }); +}); + +describe('leader-side step meter: admit()', () => { + function host(): ModuleHost { + // A ~100ms budget gives headroom on a slow CI box while still tripping + // quickly: `spin` never terminates, so the vm interrupt fires at the + // budget regardless of its size. A tighter budget (~25ms) risked flaking + // close to the observed runtime; the assertion (ok === false) is unchanged. + const h = new ModuleHost({ stepBudgetMs: 100 }); + h.register(compute); + return h; + } + + it('rejects the runaway `spin` command (budget exceeded) and leaves state UNCHANGED', () => { + const h = host(); + const before = h.snapshot(); + + const verdict = h.admit(cmd('compute', 'spin', undefined, seed('x1')), META); + expect(verdict.ok).toBe(false); + if (verdict.ok === false) { + expect(verdict.status).toBe(503); + expect(verdict.message).toMatch(/step budget/); + } + + // Admission is a pure dry-run: no mutation, no audit, no outbox change. + expect(h.snapshot()).toEqual(before); + expect(h.auditSize()).toBe(0); + }); + + it('admits a normal command, which then applies', () => { + const h = host(); + const verdict = h.admit(cmd('compute', 'sumTo', { n: 10 }, seed('x2')), META); + expect(verdict.ok).toBe(true); + // admit() did not mutate state. + expect(h.query('compute', 'last')).toBe(0); + expect(h.auditSize()).toBe(0); + + // The leader would now submit; apply runs without a timeout. + const res = h.apply(cmd('compute', 'sumTo', { n: 10 }, seed('x2')), META); + expect(res.status).toBe(200); + expect(h.query('compute', 'last')).toBe(55); // 1..10 + expect(h.auditSize()).toBe(1); + }); +}); + +describe('back-compat: non-sandboxed modules and admit()', () => { + it('a non-sandboxed module behaves exactly as before', () => { + const h = new ModuleHost(); + h.register(counter); + const res = h.apply(cmd('counter', 'increment', { by: 7 }, seed('b1')), META); + expect(res.status).toBe(200); + expect(h.query('counter', 'value')).toBe(7); + }); + + it('admit() returns ok for a non-sandboxed module (admission is a sandbox feature)', () => { + const h = new ModuleHost(); + h.register(counter); + expect(h.admit(cmd('counter', 'increment', { by: 1 }, seed('b2')), META)).toEqual({ ok: true }); + // It did not run the reducer / mutate state. + expect(h.query('counter', 'value')).toBe(0); + }); + + it('admit() returns ok for an unknown module/command (404 surfaces on apply, not admission)', () => { + const h = new ModuleHost(); + h.register(counter); + expect(h.admit(cmd('ghost', 'go', {}, seed('b3')), META)).toEqual({ ok: true }); + expect(h.admit(cmd('counter', 'nope', {}, seed('b4')), META)).toEqual({ ok: true }); + }); +}); diff --git a/tests/runtime/sharding.test.ts b/tests/runtime/sharding.test.ts new file mode 100644 index 0000000..cb2c993 --- /dev/null +++ b/tests/runtime/sharding.test.ts @@ -0,0 +1,267 @@ +import { ApplyResult, CommandMeta } from '../../src/consensus/types'; +import { buildModuleCommand } from '../../src/runtime/command'; +import { accounts } from '../../src/runtime/modules/accounts'; +import { runSaga, SagaStep } from '../../src/runtime/saga'; +import { ShardRouter } from '../../src/runtime/shardRouter'; +import { ModuleAppCommand } from '../../src/runtime/types'; +import { buildModuleCluster, leaders, ModuleNode, waitFor } from '../helpers'; + +/** + * M10 (ADR-0020): write scaling via multiple INDEPENDENT Raft groups (shards), a + * deterministic shard router, and cross-shard transactions via a saga + * (try/compensate). Each shard is an ordinary `buildCluster(3)`, so per-shard + * correctness from M1-M9 is unchanged; cross-shard atomicity is by COMPENSATION, + * not isolation. + */ +describe('Multi-Raft sharding (ADR-0020)', () => { + // Two genuinely independent clusters: each buildCluster makes its own + // registry + transport, so the shards share nothing but the router's view. + let shardA: ModuleNode[]; + let shardB: ModuleNode[]; + let router: ShardRouter; + + /** All nodes across all shards, for blanket replication asserts + teardown. */ + const allNodes = (): ModuleNode[] => [...shardA, ...shardB]; + + beforeEach(async () => { + // Each shard is its own independent cluster running the keyed `accounts` + // module, so a MODULE command applies identically within each group. + shardA = buildModuleCluster(3, [accounts]); + shardB = buildModuleCluster(3, [accounts]); + allNodes().forEach((n) => n.start()); + + router = new ShardRouter([{ nodes: shardA }, { nodes: shardB }]); + + // Wait for each shard to elect its OWN leader (independent elections). + await waitFor(() => leaders(shardA).length === 1 && leaders(shardB).length === 1); + }); + + afterEach(() => { + // Stop ALL nodes of ALL shards to clear timers (no open-handle leaks). + allNodes().forEach((n) => n.stop()); + }); + + /** A unique requestId per logical command keeps idempotency dedup honest. */ + const meta = (requestId: string, actor = 'tester'): CommandMeta => ({ + requestId, + actor, + timestamp: new Date().toISOString(), + }); + + /** + * Submit through the router and THROW on a non-200 status. `RaftNode.submit` + * resolves with an ApplyResult even for a business rejection (a thrown reducer + * maps to status 500), so a saga step must convert non-200 into a throw to + * trigger compensation. + */ + const routeOrThrow = async ( + key: string, + module: string, + command: string, + input: unknown, + requestId: string, + ): Promise => { + const m = meta(requestId); + const res = await router.submit(key, buildModuleCommand(module, command, input, m), m); + if (res.status !== 200) { + throw new Error(`command ${module}.${command} failed (${res.status}): ${res.message}`); + } + return res; + }; + + /** Balance of `id` as seen by `node` (undefined if the account is unknown). */ + const balanceOn = (node: ModuleNode, id: string): number | undefined => + node.app.host.query('accounts', 'balance', { id }) as number | undefined; + + /** + * Pick two account ids that the router maps to DIFFERENT shards, so each demo + * account genuinely lives in a different Raft group. Deterministic search. + */ + const pickKeysOnDifferentShards = (): { onA: string; onB: string } => { + let onA: string | undefined; + let onB: string | undefined; + for (let i = 0; i < 1000 && (onA === undefined || onB === undefined); i++) { + const key = `acct-${i}`; + const shard = router.shardFor(key); + if (shard === 0 && onA === undefined) onA = key; + if (shard === 1 && onB === undefined) onB = key; + } + if (onA === undefined || onB === undefined) { + throw new Error('could not find keys on both shards'); + } + return { onA, onB }; + }; + + it('routes keys deterministically and stably across router instances', () => { + // Determinism: a fresh router over the same shard count maps identically. + const other = new ShardRouter([{ nodes: shardA }, { nodes: shardB }]); + for (let i = 0; i < 50; i++) { + const key = `acct-${i}`; + expect(router.shardFor(key)).toBe(other.shardFor(key)); + expect(router.shardFor(key)).toBeGreaterThanOrEqual(0); + expect(router.shardFor(key)).toBeLessThan(2); + } + + // The two demo keys must land on DIFFERENT shards for the cross-shard tests. + const { onA, onB } = pickKeysOnDifferentShards(); + expect(router.shardFor(onA)).not.toBe(router.shardFor(onB)); + }); + + it('writes to different shards proceed independently (parallel, isolated state)', async () => { + const { onA, onB } = pickKeysOnDifferentShards(); + + // Open + deposit each account on ITS OWN shard, in parallel. + await Promise.all([ + routeOrThrow(onA, 'accounts', 'open', { id: onA }, `open-${onA}`), + routeOrThrow(onB, 'accounts', 'open', { id: onB }, `open-${onB}`), + ]); + await Promise.all([ + routeOrThrow(onA, 'accounts', 'deposit', { id: onA, amount: 100 }, `dep-${onA}`), + routeOrThrow(onB, 'accounts', 'deposit', { id: onB, amount: 250 }, `dep-${onB}`), + ]); + + // Each converges within its OWN 3-node cluster. + await waitFor(() => shardA.every((n) => balanceOn(n, onA) === 100)); + await waitFor(() => shardB.every((n) => balanceOn(n, onB) === 250)); + + // Shards are independent: A's account is unknown on shard B and vice-versa. + expect(shardB.every((n) => balanceOn(n, onA) === undefined)).toBe(true); + expect(shardA.every((n) => balanceOn(n, onB) === undefined)).toBe(true); + }); + + it('completes a cross-shard transfer via a saga (withdraw on X, deposit on Y)', async () => { + const { onA: alice, onB: bob } = pickKeysOnDifferentShards(); + + // Set up: alice has 100 on shard X, bob has 0 on shard Y. + await routeOrThrow(alice, 'accounts', 'open', { id: alice }, `open-${alice}`); + await routeOrThrow(bob, 'accounts', 'open', { id: bob }, `open-${bob}`); + await routeOrThrow(alice, 'accounts', 'deposit', { id: alice, amount: 100 }, `dep-${alice}`); + await waitFor(() => shardA.every((n) => balanceOn(n, alice) === 100)); + await waitFor(() => shardB.every((n) => balanceOn(n, bob) === 0)); + + const amount = 40; + const steps: SagaStep[] = [ + { + name: 'debit-alice', + invoke: () => routeOrThrow(alice, 'accounts', 'withdraw', { id: alice, amount }, `sx-wd-${alice}`), + // Compensation: deposit the same amount back (distinct requestId). + compensate: async () => { + await routeOrThrow(alice, 'accounts', 'deposit', { id: alice, amount }, `sx-comp-${alice}`); + }, + }, + { + name: 'credit-bob', + invoke: () => routeOrThrow(bob, 'accounts', 'deposit', { id: bob, amount }, `sx-dep-${bob}`), + compensate: async () => { + await routeOrThrow(bob, 'accounts', 'withdraw', { id: bob, amount }, `sx-comp-${bob}`); + }, + }, + ]; + + const result = await runSaga(steps); + expect(result.ok).toBe(true); + + // Alice debited on every node of shard X; bob credited on every node of Y. + await waitFor(() => shardA.every((n) => balanceOn(n, alice) === 60)); + await waitFor(() => shardB.every((n) => balanceOn(n, bob) === 40)); + + // Funds conserved across both shards — checked against EVERY node (the + // shards converged above), not just one sampled replica. + expect(shardA.every((n) => balanceOn(n, alice) === 60)).toBe(true); + expect(shardB.every((n) => balanceOn(n, bob) === 40)).toBe(true); + expect(balanceOn(shardA[0], alice)! + balanceOn(shardB[0], bob)!).toBe(100); + }); + + it('compensates the source debit when the credit leg fails (no funds lost)', async () => { + const { onA: alice } = pickKeysOnDifferentShards(); + // A target account that DOES NOT EXIST: depositing into it fails, forcing + // compensation of the already-committed debit. + const ghost = 'ghost-target-never-opened'; + + await routeOrThrow(alice, 'accounts', 'open', { id: alice }, `open2-${alice}`); + await routeOrThrow(alice, 'accounts', 'deposit', { id: alice, amount: 100 }, `dep2-${alice}`); + await waitFor(() => shardA.every((n) => balanceOn(n, alice) === 100)); + + const amount = 30; + const steps: SagaStep[] = [ + { + name: 'debit-alice', + invoke: () => routeOrThrow(alice, 'accounts', 'withdraw', { id: alice, amount }, `fail-wd-${alice}`), + compensate: async () => { + await routeOrThrow(alice, 'accounts', 'deposit', { id: alice, amount }, `fail-comp-${alice}`); + }, + }, + { + name: 'credit-ghost', + // ghost does not exist on its shard ⇒ deposit rejects ⇒ step fails. + invoke: () => routeOrThrow(ghost, 'accounts', 'deposit', { id: ghost, amount }, `fail-dep-${ghost}`), + compensate: async () => { + await routeOrThrow(ghost, 'accounts', 'withdraw', { id: ghost, amount }, `fail-comp-${ghost}`); + }, + }, + ]; + + const result = await runSaga(steps); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.failedAt).toBe('credit-ghost'); + expect(result.compensated).toEqual(['debit-alice']); + // The compensation succeeded, so nothing was stranded (no fund loss). + expect(result.compensationFailures).toEqual([]); + } + + // Source balance restored on every node of shard X — no funds lost. + await waitFor(() => shardA.every((n) => balanceOn(n, alice) === 100)); + + // Funds conserved on EVERY node: the ghost never received anything; alice whole. + expect(shardA.every((n) => balanceOn(n, alice) === 100)).toBe(true); + }); +}); + +/** + * Pure saga semantics (no clusters) — in particular the case the conservation + * coordinator must never hide: a COMPENSATION that itself throws leaves a forward + * effect un-undone, so it is reported in `compensationFailures`, NOT `compensated`. + */ +describe('saga compensation failure reporting', () => { + it('surfaces a throwing compensation instead of marking it compensated', async () => { + const order: string[] = []; + const steps: SagaStep[] = [ + { + name: 's1', + invoke: async () => order.push('inv-s1'), + // s1's compensation FAILS — its forward effect is not undone. + compensate: async () => { + order.push('comp-s1'); + throw new Error('compensation boom'); + }, + }, + { + name: 's2', + invoke: async () => order.push('inv-s2'), + compensate: async () => { + order.push('comp-s2'); + }, + }, + { + name: 's3', + invoke: async () => { + throw new Error('s3 failed'); + }, + compensate: async () => order.push('comp-s3'), + }, + ]; + + const result = await runSaga(steps); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.failedAt).toBe('s3'); + // s2 compensated cleanly; s1's compensation threw — reported separately. + expect(result.compensated).toEqual(['s2']); + expect(result.compensationFailures.map((f) => f.step)).toEqual(['s1']); + expect(result.compensationFailures[0].error.message).toBe('compensation boom'); + } + // Compensations ran in reverse order (s2 before s1); s3 never committed. + expect(order).toEqual(['inv-s1', 'inv-s2', 'comp-s2', 'comp-s1']); + }); +}); diff --git a/tests/runtime/signing.test.ts b/tests/runtime/signing.test.ts new file mode 100644 index 0000000..8cc68ac --- /dev/null +++ b/tests/runtime/signing.test.ts @@ -0,0 +1,272 @@ +import { CommandMeta } from '../../src/consensus/types'; +import { buildModuleCommand, buildSignedModuleCommand } from '../../src/runtime/command'; +import { ModuleHost } from '../../src/runtime/moduleHost'; +import { counter } from '../../src/runtime/modules/counter'; +import { + generateActorKeypair, + KeyRegistry, + signCommand, + SignablePayload, + verifyCommand, +} from '../../src/runtime/signing'; +import { buildModuleCluster, leaders, ModuleNode, waitFor } from '../helpers'; + +/** + * Milestone 7 (ADR-0019 pillar 7): actor-signed module commands. The actor signs + * the LOGICAL command (NOT the leader-resolved seed); every node verifies on the + * apply path against an actor->public-key registry. A forged/tampered command is + * rejected DETERMINISTICALLY (401) on every node, so a malicious leader cannot + * forge `actor`. With NO registry configured, behavior is unchanged (back-compat). + */ + +/** A fixed leader-resolved seed; the seed is excluded from the signature. */ +const SEED = { timestamp: '2026-06-21T00:00:00.000Z', nonce: 'deadbeef' }; + +describe('actor command signing primitives', () => { + it('round-trips sign + verify with the matching key', () => { + const { publicKey, privateKey } = generateActorKeypair(); + const payload: SignablePayload = { + module: 'counter', + command: 'increment', + input: { by: 5 }, + actor: 'alice', + requestId: 'req-1', + }; + const sig = signCommand(privateKey, payload); + expect(verifyCommand(publicKey, payload, sig)).toBe(true); + }); + + it('fails verification when the input is tampered', () => { + const { publicKey, privateKey } = generateActorKeypair(); + const payload: SignablePayload = { + module: 'counter', command: 'increment', input: { by: 5 }, actor: 'alice', requestId: 'req-1', + }; + const sig = signCommand(privateKey, payload); + expect(verifyCommand(publicKey, { ...payload, input: { by: 6 } }, sig)).toBe(false); + }); + + it('fails verification when the actor is tampered', () => { + const { publicKey, privateKey } = generateActorKeypair(); + const payload: SignablePayload = { + module: 'counter', command: 'increment', input: { by: 5 }, actor: 'alice', requestId: 'req-1', + }; + const sig = signCommand(privateKey, payload); + expect(verifyCommand(publicKey, { ...payload, actor: 'mallory' }, sig)).toBe(false); + }); + + it('fails verification when the requestId is tampered', () => { + const { publicKey, privateKey } = generateActorKeypair(); + const payload: SignablePayload = { + module: 'counter', command: 'increment', input: { by: 5 }, actor: 'alice', requestId: 'req-1', + }; + const sig = signCommand(privateKey, payload); + expect(verifyCommand(publicKey, { ...payload, requestId: 'req-2' }, sig)).toBe(false); + }); + + it('fails verification under the wrong key', () => { + const alice = generateActorKeypair(); + const mallory = generateActorKeypair(); + const payload: SignablePayload = { + module: 'counter', command: 'increment', input: { by: 5 }, actor: 'alice', requestId: 'req-1', + }; + const sig = signCommand(alice.privateKey, payload); + expect(verifyCommand(mallory.publicKey, payload, sig)).toBe(false); + }); +}); + +describe('ModuleHost signature enforcement', () => { + const alice = generateActorKeypair(); + + /** A host with a registry binding alice -> alice's public key. */ + function signingHost(): ModuleHost { + const host = new ModuleHost(); + host.register(counter); + const reg = new KeyRegistry(); + reg.registerActor('alice', alice.publicKey); + host.setKeyRegistry(reg); + return host; + } + + it('applies a correctly-signed command (200)', () => { + const host = signingHost(); + const sig = signCommand(alice.privateKey, { + module: 'counter', command: 'increment', input: { by: 4 }, actor: 'alice', requestId: 'r1', + }); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 4 }, seed: SEED, sig }, + { actor: 'alice', requestId: 'r1' }, + ); + expect(res.status).toBe(200); + expect(host.query('counter', 'value')).toBe(4); + }); + + it('rejects an unsigned command (401) and leaves state/outbox unchanged', () => { + const host = signingHost(); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 4 }, seed: SEED }, + { actor: 'alice', requestId: 'r1' }, + ); + expect(res.status).toBe(401); + expect(host.query('counter', 'value')).toBe(0); + expect(host.getOutbox()).toEqual([]); + }); + + it('rejects a command signed by the wrong actor (401)', () => { + const host = signingHost(); + const mallory = generateActorKeypair(); + // Signed by mallory but CLAIMING actor: 'alice' (the forgery attempt). + const sig = signCommand(mallory.privateKey, { + module: 'counter', command: 'increment', input: { by: 4 }, actor: 'alice', requestId: 'r1', + }); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 4 }, seed: SEED, sig }, + { actor: 'alice', requestId: 'r1' }, + ); + expect(res.status).toBe(401); + expect(host.query('counter', 'value')).toBe(0); + }); + + it('rejects a tampered input (401)', () => { + const host = signingHost(); + // Signed over by:4, but the command carries by:99 (leader altered input). + const sig = signCommand(alice.privateKey, { + module: 'counter', command: 'increment', input: { by: 4 }, actor: 'alice', requestId: 'r1', + }); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 99 }, seed: SEED, sig }, + { actor: 'alice', requestId: 'r1' }, + ); + expect(res.status).toBe(401); + expect(host.query('counter', 'value')).toBe(0); + }); + + it('rejects an unknown actor with no registered key (401)', () => { + const host = signingHost(); + const eve = generateActorKeypair(); + const sig = signCommand(eve.privateKey, { + module: 'counter', command: 'increment', input: { by: 1 }, actor: 'eve', requestId: 'r1', + }); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 1 }, seed: SEED, sig }, + { actor: 'eve', requestId: 'r1' }, + ); + expect(res.status).toBe(401); + expect(host.query('counter', 'value')).toBe(0); + }); + + it('converges: two hosts with the same registry accept/reject identically', () => { + const mallory = generateActorKeypair(); + const goodSig = signCommand(alice.privateKey, { + module: 'counter', command: 'increment', input: { by: 2 }, actor: 'alice', requestId: 'g1', + }); + const forgedSig = signCommand(mallory.privateKey, { + module: 'counter', command: 'increment', input: { by: 2 }, actor: 'alice', requestId: 'f1', + }); + + const run = (host: ModuleHost): number[] => { + const a = host.apply( + { module: 'counter', command: 'increment', input: { by: 2 }, seed: SEED, sig: goodSig }, + { actor: 'alice', requestId: 'g1' }, + ).status; + const b = host.apply( + { module: 'counter', command: 'increment', input: { by: 2 }, seed: SEED, sig: forgedSig }, + { actor: 'alice', requestId: 'f1' }, + ).status; + return [a, b]; + }; + + const h1 = signingHost(); + const h2 = signingHost(); + expect(run(h1)).toEqual([200, 401]); + expect(run(h2)).toEqual([200, 401]); + // Deep-equal snapshots prove byte-identical state AND audit after the + // same accept/reject sequence. + expect(h1.snapshot()).toEqual(h2.snapshot()); + expect(h1.auditRoot()).toBe(h2.auditRoot()); + }); + + it('back-compat: a host with NO registry applies an unsigned command unchanged', () => { + const host = new ModuleHost(); + host.register(counter); + const res = host.apply( + { module: 'counter', command: 'increment', input: { by: 7 }, seed: SEED }, + { actor: 'alice', requestId: 'r1' }, + ); + expect(res.status).toBe(200); + expect(host.query('counter', 'value')).toBe(7); + }); +}); + +describe('signed module commands over Raft consensus', () => { + const alice = generateActorKeypair(); + let nodes: ModuleNode[]; + + beforeEach(() => { + // Every node configured with the SAME registry (alice's pubkey). + const reg = new KeyRegistry(); + reg.registerActor('alice', alice.publicKey); + nodes = buildModuleCluster(3, [counter], { keyRegistry: reg }); + nodes.forEach((n) => n.start()); + }); + + afterEach(() => { + nodes.forEach((n) => n.stop()); + }); + + it('replicates a signed command from alice and converges on all nodes (200)', async () => { + await waitFor(() => leaders(nodes).length === 1); + const leader = leaders(nodes)[0]; + + const meta: CommandMeta = { requestId: 'req-signed', actor: 'alice', timestamp: SEED.timestamp }; + const cmd = buildSignedModuleCommand('counter', 'increment', { by: 5 }, { + actor: 'alice', + requestId: 'req-signed', + privateKeyPem: alice.privateKey, + }); + const res = await leader.submit(cmd, meta); + expect(res.status).toBe(200); + + await waitFor(() => nodes.every((n) => n.app.host.query('counter', 'value') === 5)); + const values = new Set(nodes.map((n) => n.app.host.query('counter', 'value'))); + expect(values).toEqual(new Set([5])); + const roots = new Set(nodes.map((n) => n.app.host.auditRoot())); + expect(roots.size).toBe(1); + }); + + it('FORGERY: a command claiming actor alice but signed by another key is rejected (401) on every node; counter unchanged; nodes converged', async () => { + await waitFor(() => leaders(nodes).length === 1); + const leader = leaders(nodes)[0]; + + // Mallory forges actor: 'alice' but signs with her own key. + const mallory = generateActorKeypair(); + const meta: CommandMeta = { requestId: 'req-forge', actor: 'alice', timestamp: SEED.timestamp }; + const forged = buildSignedModuleCommand('counter', 'increment', { by: 100 }, { + actor: 'alice', + requestId: 'req-forge', + privateKeyPem: mallory.privateKey, + }); + const res = await leader.submit(forged, meta); + // Rejected deterministically with 401 (the command still committed to the + // log and applied, but the reducer never ran). + expect(res.status).toBe(401); + + // Also submit an unsigned forgery for good measure. + const unsignedMeta: CommandMeta = { requestId: 'req-unsigned', actor: 'alice', timestamp: SEED.timestamp }; + const unsigned = buildModuleCommand('counter', 'increment', { by: 50 }, unsignedMeta); + const res2 = await leader.submit(unsigned, unsignedMeta); + expect(res2.status).toBe(401); + + // Wait until both forgery entries have committed/applied on every node by + // gating on their audit appearing, then assert the counter never moved. + await waitFor(() => + nodes.every((n) => + n.stateMachine.getAuditLog().some((e) => e.requestId === 'req-unsigned'), + ), + ); + const values = new Set(nodes.map((n) => n.app.host.query('counter', 'value'))); + expect(values).toEqual(new Set([0])); + // Nodes stay converged (identical module audit roots). + const roots = new Set(nodes.map((n) => n.app.host.auditRoot())); + expect(roots.size).toBe(1); + }); +});