@mcsherrylabs/pi-meerkat 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +34 -10
  2. package/dist/installer/committee-init.sh +17 -33
  3. package/dist/installer/committee.conf +12 -34
  4. package/dist/installer/genesis.devnet.conf +92 -0
  5. package/dist/installer/node-follower.conf +232 -0
  6. package/dist/installer/node-join.sh +15 -1
  7. package/dist/installer/node.conf +4 -231
  8. package/dist/installer/node.local.conf +8 -0
  9. package/dist/installer/runtime.json +1 -1
  10. package/dist/installer-runtime/dist/scripts/recover-owner.js +7 -9
  11. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +3 -4
  12. package/dist/installer-runtime/dist/scripts/versions.mjs +5 -10
  13. package/dist/installer-runtime/dist/src/announcements.js +1 -289
  14. package/dist/installer-runtime/dist/src/app.js +12 -535
  15. package/dist/installer-runtime/dist/src/chainfetch.js +0 -64
  16. package/dist/installer-runtime/dist/src/env.js +47 -32
  17. package/dist/installer-runtime/dist/src/registry/index.js +11 -2
  18. package/dist/installer-runtime/dist/src/registry/provision.js +46 -1
  19. package/dist/installer-runtime/dist/src/service.js +151 -0
  20. package/dist/installer.d.ts +185 -18
  21. package/dist/installer.d.ts.map +1 -1
  22. package/dist/installer.js +161 -52
  23. package/dist/installer.js.map +1 -1
  24. package/dist/mcp-announce.d.ts +18 -0
  25. package/dist/mcp-announce.d.ts.map +1 -1
  26. package/dist/mcp-announce.js +18 -4
  27. package/dist/mcp-announce.js.map +1 -1
  28. package/dist/mcp-cli.d.ts.map +1 -1
  29. package/dist/mcp-cli.js +28 -18
  30. package/dist/mcp-cli.js.map +1 -1
  31. package/dist/mcp-config.d.ts +1 -0
  32. package/dist/mcp-config.d.ts.map +1 -1
  33. package/dist/mcp-config.js +2 -1
  34. package/dist/mcp-config.js.map +1 -1
  35. package/dist/mcp-upstream.d.ts +1 -1
  36. package/dist/mcp-upstream.d.ts.map +1 -1
  37. package/dist/mcp-upstream.js +7 -2
  38. package/dist/mcp-upstream.js.map +1 -1
  39. package/dist/tools.js +4 -4
  40. package/dist/tools.js.map +1 -1
  41. package/installer/Node.Dockerfile +5 -0
  42. package/installer/Registry.Dockerfile +34 -9
  43. package/installer/check-single-sdk.mjs +151 -0
  44. package/installer/runtime-control.mjs +179 -16
  45. package/package.json +5 -5
  46. package/skills/channel-subscription/SKILL.md +3 -3
  47. package/dist/installer-runtime/dist/src/runtime.js +0 -164
package/README.md CHANGED
@@ -5,7 +5,9 @@ Meerkat tools plus every tool dynamically discovered from the private
5
5
  `@mcsherrylabs/evolver-mcp@0.15.0` child. The released upstream baseline is 65 tools, so healthy
6
6
  discovery currently returns 72. The count is a comparison baseline, not a routing allowlist.
7
7
 
8
- **Version 0.8.0 adds the zero-required-parameter installer.** Version 0.7.0 introduced the
8
+ **Version 0.9.0 makes DevNet the default install target** (`install` installs against the public
9
+ DevNet; `install --local` creates a private local chain). Version 0.8.0 added the
10
+ zero-required-parameter installer (local chain only). Version 0.7.0 introduced the
9
11
  facade but required an existing node, registry, identity and key. Version 0.6.0 is the old
10
12
  Pi/local-signing package. Always use an exact published version, not npm `latest`.
11
13
 
@@ -23,15 +25,34 @@ Prerequisites in the environment where your MCP harness runs:
23
25
  ### 1. Install
24
26
 
25
27
  ```bash
26
- npx --yes @mcsherrylabs/pi-meerkat@0.8.0 install
28
+ npx --yes @mcsherrylabs/pi-meerkat@0.9.0 install # public DevNet (default)
29
+ npx --yes @mcsherrylabs/pi-meerkat@0.9.0 install --local # private local chain
27
30
  ```
28
31
 
29
32
  No identity, key, endpoint, registry, chain ID, channel, port, GID, path, environment variables
30
- or paid model service is required. This creates an **independent private local chain** and
31
- starts its owned consensus node, separate processing node and Meerkat registry/content/inbox services. It does **not** join the
32
- shared devnet, claim from its faucet, inspect unrelated keys, or change another deployment.
33
- The first install downloads public pinned artifacts and builds three wrapper images, so
34
- allow time for Docker/npm downloads. Failed preflight/build/readiness exits nonzero with a reason.
33
+ or paid model service is required.
34
+
35
+ **Default (DevNet)** installs a single non-genesis follower node against the public DevNet, syncing
36
+ its shared chain, and a Meerkat registry that reads and indexes the shared, pinned
37
+ `ce.meerkat.announcements` channel (owned by the pinned `evolvermeerkatA483344` operator). That
38
+ registry runs **read-only**: it never claims, signs or submits with any identity. DevNet catch-up
39
+ syncs from block 0 (~14k blocks at the time of release) and is paced by the registry's own cursor
40
+ drain, so the first install can take a while; it reports progress and fails only if sync stalls.
41
+
42
+ A DevNet install **permanently claims two DevNet identities**, both funded by the public DevNet
43
+ faucet: its node (`meerkatnode<id>`) and your agent (`meerkatuser<id>`), which signs your
44
+ announcements. Names on DevNet cannot be reused. Re-running install never claims again. DevNet mode
45
+ needs **outbound internet access** from Docker (the node dials DevNet's front nodes, and the faucet
46
+ is reached over HTTPS); it still publishes no host ports.
47
+
48
+ **`--local`** creates an **independent private local chain** — an owned consensus node, a separate
49
+ processing node, and a Meerkat registry/content/inbox service. The agent's own identity
50
+ (`meerkatuser<id>`) owns the install's private `meerkat.<id>.announcements` channel (claimed once,
51
+ idempotently), and nothing touches DevNet. In either mode the registry claims no identity, and the
52
+ installer never inspects unrelated keys or changes another deployment. The first install downloads public pinned artifacts and
53
+ builds the wrapper images, so allow time for Docker/npm downloads. Failed
54
+ preflight/build/readiness exits nonzero with a reason. Each mode keeps its own state directory
55
+ (`devnet/` or `local/` under your XDG state directory); 0.8.0 state is never touched.
35
56
 
36
57
  ### 2. Register one server
37
58
 
@@ -40,17 +61,20 @@ if you do not already have a registration named `meerkat`, the equivalent comman
40
61
 
41
62
  ```bash
42
63
  claude mcp add --scope user --transport stdio meerkat -- \
43
- npx --yes @mcsherrylabs/pi-meerkat@0.8.0 serve
64
+ npx --yes @mcsherrylabs/pi-meerkat@0.9.0 serve
44
65
  ```
45
66
 
67
+ Add `--local` after `serve` if you installed with `install --local`.
68
+
46
69
  Do not remove or replace an unrelated `meerkat` registration. Choose a different harness
47
70
  registration name for this entry if necessary; its underlying saved deployment remains the same.
48
71
  Other MCP clients can add the printed stdio JSON entry directly. Do **not** register an Evolver
49
72
  child separately. Restart the harness connection, inspect its tools, and begin with
50
73
  `meerkat_summary`, `meerkat_services`, `meerkat_inbox` and `evolver_chain_height`.
51
74
 
52
- `serve` finds the installer's saved state automatically; there is no `--config` or environment
53
- ceremony. The public package launcher is pinned and survives npm-cache cleanup. Each client
75
+ `serve` finds the installer's saved state automatically (default `devnet`, `--local` for the
76
+ private chain); there is no `--config` or environment ceremony. The public package launcher is
77
+ pinned and survives npm-cache cleanup. Each client
54
78
  gets its **own facade and exact Evolver child/session/store** inside the owned runtime container,
55
79
  connected through Docker exec stdio. Node/content/registry HTTP stays on the private owned
56
80
  Docker network, with **no host-published ports**, so a separate Docker daemon needs neither
@@ -1,25 +1,13 @@
1
1
  #!/bin/sh
2
- # node entrypoint: host keys + genesis on first boot, then exec the node.
2
+ # committee entrypoint: host keys + genesis on first boot, then exec the node.
3
3
  #
4
- # On FIRST boot it generates this node's key material ON THE HOST (never in the
5
- # image or the repo), renders a one-node genesis committee from the derived public
6
- # keys, and persists everything into the /data volume. On every subsequent boot it
7
- # REUSES the same keys + genesis so the chain continues — the genesis committee must
8
- # stay byte-stable or the chain identity diverges.
9
- #
10
- # Two secrets, handled identically (spec 101 — both are host-only secrets):
11
- # - Ed25519 node-identity key -> ce.evolver.node.private-key (EVOLVER_PRIVATE_KEY)
12
- # - BLS consensus key (IKM) -> ce.evolver.node.bls-secret (EVOLVER_BLS_SEED)
13
- # Their public keys go into genesis (public-key / bls-public-key).
4
+ # On FIRST boot it generates this node's key material ON THE HOST (never in the image or the repo),
5
+ # renders a one-node genesis committee from the derived public keys, and persists everything into
6
+ # the /data volume. On every subsequent boot it REUSES the same keys + genesis so the chain
7
+ # continues. It also EXPORTS the public chain spec into /public-genesis/genesis.conf so the follower
8
+ # can join the chain from it.
14
9
  set -eu
15
10
 
16
- # Deliberately NOT derived from the app name. This id is written into the genesis
17
- # file on the volume at FIRST BOOT, so changing it on an existing chain would launch
18
- # the node under a name absent from its own genesis. Keeping it neutral means
19
- # `npm run rename` never has to touch this file and cannot leave a previous project's
20
- # name behind — the template used to default to `tmplnode0`, so every app built from
21
- # it had a node introducing itself by the TEMPLATE's name, in its logs and in its
22
- # published deployment artifact.
23
11
  NODE_ID="${NODE_ID:-node0}"
24
12
  KEYS=/data/keys
25
13
  GENESIS=/data/genesis.gen.conf
@@ -27,6 +15,8 @@ ED_KEY="$KEYS/ed25519.key" # base64 PKCS#8 Ed25519 private
27
15
  BLS_KEY="$KEYS/bls.key" # base64 BLS IKM (>=32 bytes)
28
16
 
29
17
  if [ -f "$ED_KEY" ] || [ -f "$BLS_KEY" ] || [ -f "$GENESIS" ]; then
18
+ # PARTIAL STATE REFUSES: an incomplete owned key set is never regenerated (that would fork the
19
+ # chain identity), so a node with one of three and not the other two stops rather than claims on.
30
20
  [ -s "$ED_KEY" ] && [ -s "$BLS_KEY" ] && [ -s "$GENESIS" ] || {
31
21
  echo '[node-init] incomplete owned keys/genesis; refusing regeneration' >&2; exit 1;
32
22
  }
@@ -56,15 +46,8 @@ if [ ! -f "$ED_KEY" ]; then
56
46
  printf '%s' "$ED_PRIV" > "$ED_KEY"; chmod 600 "$ED_KEY"
57
47
  printf '%s' "$BLS_SECRET" > "$BLS_KEY"; chmod 600 "$BLS_KEY"
58
48
 
59
- # Render the chain spec (089): committee PUBLIC keys + the chain-wide consensus
60
- # parameters. This exact file is published verbatim to every follower / app node
61
- # (102 follower-join: the genesis file IS the chain, byte-identical everywhere) and
62
- # its canonical hash is pinned as the genesis block id (ce.evolver.genesis-file in
63
- # node.conf). 103: the host is the ROUTABLE public address — followers dial the
64
- # committee at exactly this host:port. Chain-wide params live HERE, not in node.conf,
65
- # so joiners inherit them automatically — quiescence especially (FR-019: a joiner
66
- # with different quiescence would time out the deliberately-lingering leader).
67
- PUBLIC_HOST="${PUBLIC_HOST:-localhost}"
49
+ # Render the chain spec (089): committee PUBLIC keys + the chain-wide consensus parameters.
50
+ PUBLIC_HOST="${PUBLIC_HOST:-committee}"
68
51
  cat > "$GENESIS" <<EOF
69
52
  ce.evolver.genesis.nodes = [
70
53
  {
@@ -85,10 +68,7 @@ ce.evolver.consensus {
85
68
  snapshot-interval-blocks = 0 # single-node: no snapshot cadence; joiners sync from block 0
86
69
  identity-sponsorship-cost = 100
87
70
 
88
- # Low idle disk-write: after 3 empty blocks the leader lingers on a 5-MINUTE heartbeat
89
- # instead of the 600ms cadence (#31: idle-heartbeat-ms > block-time-ms activates quiescence);
90
- # wake-on-work (node-local, always on) still proposes immediately on incoming txs.
91
- # CHAIN-WIDE — identical on every node by construction.
71
+ # Low idle disk-write: after 3 empty blocks the leader lingers on a 5-MINUTE heartbeat.
92
72
  quiescence {
93
73
  idle-heartbeat-ms = 300000
94
74
  idle-after-empty-rounds = 3
@@ -96,7 +76,6 @@ ce.evolver.consensus {
96
76
  }
97
77
  EOF
98
78
  echo "[node-init] generated keys (secrets in $KEYS, mode 600) + chain spec (host=$PUBLIC_HOST)"
99
- echo "[node-init] PUBLISH the chain spec (public keys + chain-wide config): docker compose cp node:/data/genesis.gen.conf ."
100
79
  else
101
80
  echo "[node-init] existing keys found in $KEYS — reusing (chain continues)"
102
81
  fi
@@ -106,7 +85,10 @@ EVOLVER_PRIVATE_KEY="$(cat "$ED_KEY")"; export EVOLVER_PRIVATE_KEY
106
85
  EVOLVER_BLS_SEED="$(cat "$BLS_KEY")"; export EVOLVER_BLS_SEED
107
86
  mkdir -p /data/chain
108
87
 
109
- echo "[node-init] launching evolver node '$NODE_ID'"
88
+ # Publish the PUBLIC chain spec (keys + consensus) into the shared volume. The follower joins
89
+ # from this copy, never from the committee's private data/keys volume. A differing existing copy
90
+ # refuses replacement — the chain identity is hash-pinned, so a byte-different genesis is a
91
+ # different chain.
110
92
  if [ -f /public-genesis/genesis.conf ]; then
111
93
  cmp -s "$GENESIS" /public-genesis/genesis.conf || { echo '[node-init] public genesis differs; refusing replacement' >&2; exit 1; }
112
94
  else
@@ -114,4 +96,6 @@ else
114
96
  chmod 640 /public-genesis/genesis.pending
115
97
  mv /public-genesis/genesis.pending /public-genesis/genesis.conf
116
98
  fi
99
+
100
+ echo "[node-init] launching evolver node '$NODE_ID'"
117
101
  exec /opt/evolver/bin/evolver -Dconfig.file=/config/node.conf "$NODE_ID"
@@ -17,29 +17,21 @@ ce.evolver.node {
17
17
  evolver.peer-discovery {
18
18
  # What this node ADVERTISES about ITSELF to peers — not somewhere it connects to.
19
19
  # This chain is standalone (one node, its own genesis, no seeds) so nothing reads
20
- # these; localhost is used because the alternative is advertising an address that
21
- # is not this node. They named devnet.mcsherrylabs.com purely because this config
22
- # was copied from that deployment. Joining a real chain means setting them to the
23
- # ROUTABLE p2p transport (raw TCP mTLS :7000) and HTTPS API your peers can dial.
24
- # 163 — `host:port`, no scheme: the transport is raw TCP + mTLS and never was a URL.
20
+ # these. Joining a real chain means setting them to the ROUTABLE p2p transport
21
+ # (raw TCP mTLS :7000) and HTTPS API your peers can dial.
25
22
  transport-address = "committee:7000"
26
23
  # No `peer-url`: this chain is one node, so nothing fetches content from it, and the
27
24
  # plain-HTTP :8440 above could not serve a peer anyway — a content fetch carries the
28
- # caller's client certificate and needs the node's OWN TLS listener. Joining a real
29
- # chain means setting `transport-address` to the routable :7000 and `peer-url` to this
30
- # node's https listener (never a reverse proxy: it presents no client certificate).
25
+ # caller's client certificate and needs the node's OWN TLS listener.
31
26
  gossip-interval-seconds = 60
32
27
  }
33
28
 
34
- # Chain-wide consensus params (n-nodes, block-time, quiescence, ...) come from the
35
- # included chain spec — do NOT set them here, they must stay byte-identical on every
36
- # node. Only node-local knobs below.
29
+ # Chain-wide consensus params come from the included chain spec — do NOT set them here.
37
30
  ce.evolver.consensus {
38
31
  validate-chain-on-startup = false
39
32
 
40
33
  # Reactivity (node-local, always on since #28): on incoming work the leader proposes
41
- # IMMEDIATELY rather than waiting out the (5-minute) idle heartbeat — tx latency stays
42
- # sub-second despite slow idle.
34
+ # IMMEDIATELY rather than waiting out the (5-minute) idle heartbeat.
43
35
  wake-on-work {
44
36
  forward-watermark = 16
45
37
  }
@@ -47,34 +39,17 @@ ce.evolver.consensus {
47
39
 
48
40
  data.base = "/data/chain"
49
41
  state.snapshots = "snapshots"
50
- # PLAIN HTTP on 8440 for everything that TALKS to this node, deliberately. The HTTPS
51
- # listener presents the node's IDENTITY certificate — self-signed, and valid for ~13
52
- # MINUTES, because it is a peer authentication credential and not a web certificate. No
53
- # browser can be made happy with that: an exception you click through expires before you
54
- # finish reading the page. Tolerating it in code means NODE_TLS_REJECT_UNAUTHORIZED=0,
55
- # which disables verification PROCESS-WIDE for every host — a bad habit to ship in a
56
- # template.
57
- #
58
- # TLS on 8441 for PEERS, because the content cache needs somewhere mTLS to live: its peer
59
- # route identifies callers by client certificate and the node refuses to start content
60
- # without a TLS listener. Before evolver v0.16.0 those were the same listener, so
61
- # "hold documents" and "put every client on an unpinnable certificate" were one decision.
62
- # They are not, and this is what that separation looks like in a deployment.
42
+ # PLAIN HTTP on 8440 for everything that TALKS to this node, deliberately. TLS on 8441 for
43
+ # PEERS (the content cache needs somewhere mTLS to live).
63
44
  http.port = 8440
64
45
  # ⚠️ REQUIRED since evolver #131: the plain API listener now defaults to 127.0.0.1. The app
65
46
  # container reaches this node over the CONTAINER NETWORK, which is not loopback from the
66
- # node's point of view — without this line the app gets connection refused and the node
67
- # logs nothing unusual. "0.0.0.0" is what this deployment effectively had before.
47
+ # node's point of view — without this line the app gets connection refused.
68
48
  http.bind-host = "0.0.0.0"
69
49
  https.port = 8441
70
50
  evolver.grpc.port = 9000
71
51
 
72
52
  # 139 — CONTENT, so a detail document announced on this chain can actually be fetched.
73
- #
74
- # `bind-host` is what lets a SIBLING CONTAINER reach the publish/read route: it is
75
- # authorized by what can reach it, and the default (loopback) means "inside this node's
76
- # container and nowhere else". 0.0.0.0 here says: this compose project's network is the
77
- # boundary. It holds because the port below is published to 127.0.0.1 only.
78
53
  ce.evolver.content {
79
54
  enabled = true
80
55
  http-port = 8450
@@ -99,4 +74,7 @@ chainDb {
99
74
  useShutdownHook = false
100
75
  freeBlobsEarly = false
101
76
  }
102
- ce.evolver.execution.enabled = false
77
+
78
+ # The committee is CONSENSUS ONLY, never the processing node. Execution is disabled so this node
79
+ # never serves the single-consumer channel cursor; the follower below owns that.
80
+ ce.evolver.execution.enabled = false
@@ -0,0 +1,92 @@
1
+ # devnet genesis — the committee, plus SEED NODES (170).
2
+ #
3
+ # ⚠️ THIS FILE IS THE CHAIN'S IDENTITY. Its canonical hash is the genesis block id, so any edit
4
+ # here produces a DIFFERENT CHAIN. Every node must hold a byte-identical copy; a node with an
5
+ # older one does not disagree politely, it cannot join at all.
6
+ #
7
+ # ── Why the seed entries exist (170, issue #121) ────────────────────────────────────────────
8
+ # A node starting with an empty volume can only learn a peer's ADDRESS from this file, and can
9
+ # only VERIFY a peer whose key is already in its chain state — which, before it has synced, is
10
+ # also only this file. Both roads lead here. Before 170 this file named one node, the committee,
11
+ # whose `allowed-inbound` is deliberately full, so a newcomer could reach nothing it was able to
12
+ # trust and nothing it was able to trust would accept it. No configuration could fix that.
13
+ #
14
+ # A `quorum-weight = 0` entry is present-and-trusted WITHOUT being on the committee: it is seeded
15
+ # as an identity, its Ed25519 key becomes verifiable, and its address becomes dialable — while
16
+ # `seedGenesis` keeps it out of the quorum store and leader rotation filters it out of the
17
+ # proposer set. That is the whole mechanism; no new key, no new trust path.
18
+ #
19
+ # ⚠️ The two front nodes keep the names they already had. They were NOT in genesis before, so a
20
+ # chain rebuild would have reassigned their names and invalidated every pin across the estate and
21
+ # the published installer. Pinning them here makes the names stable by construction.
22
+ ce.evolver.genesis.nodes = [
23
+ # ── the committee ──────────────────────────────────────────────────────────────────────────
24
+ {
25
+ node-id = "devnet1-node0"
26
+ host = "devnet.mcsherrylabs.com"
27
+ port = 7000
28
+ public-key = "MCowBQYDK2VwAyEAxTC3mipBz3Ae6nkUzORWLAco21eX3iSSC+QEUSbJMCs="
29
+ bls-public-key = "rCrpHeLH85ECkow6A58I54fjvtCmOHfdm1llPrish+aD49l32sBciyYAzytRxwak"
30
+ quorum-weight = 1000
31
+ },
32
+
33
+ # ── seed nodes: weight 0, so present and trusted but never voting, never leading ───────────
34
+ #
35
+ # ⚠️ Keys are the ones these two nodes ALREADY hold, read from their own /status. Their host
36
+ # secrets are untouched and stay where they are — only the public halves appear here.
37
+ {
38
+ node-id = "evolvermeerkatnode920080"
39
+ host = "65.21.255.224"
40
+ port = 7100
41
+ public-key = "MCowBQYDK2VwAyEAvDNSSDsSYRHfvFCOcl/Fba89VnBrkLzYBy28xqjg8Zg="
42
+ bls-public-key = "iJZMbE+J+TnNnHpYxk2lQDJipTvvUg0qCQNVJViTfoatqZkqazLCZbUmSP4W26Vj"
43
+ quorum-weight = 0
44
+ },
45
+ {
46
+ node-id = "schednode2"
47
+ host = "65.21.255.224"
48
+ port = 7200
49
+ public-key = "MCowBQYDK2VwAyEAvUOp3fA1nTjv546jme0n14wYDVjHshLL3I7XpQxQ8V4="
50
+ bls-public-key = "ht3U8qRoQtKIU0NUbuuwx/ni4iF+rcAs2dJgxDOcf+YOs+brlSKSnC2XBFBp1rR1"
51
+ quorum-weight = 0
52
+ },
53
+
54
+ # ⚠️ devnet-seed-3 IS NEVER DEPLOYED, ON PURPOSE, AND MUST STAY THAT WAY. A list of seeds is
55
+ # only worth having if a dead entry is walked past rather than waited on, and a seed that is
56
+ # permanently absent tests that on every single join instead of on the one afternoon somebody
57
+ # remembers to stop a live one. Its private key material was generated in a throwaway container
58
+ # and destroyed with it — nobody can bring this node up, which is the point. `.invalid` is
59
+ # reserved by RFC 2606 and can never resolve, so the attempt fails fast instead of hanging.
60
+ # If you are reading this because something reports it unreachable: that is the test passing.
61
+ {
62
+ node-id = "devnet-seed-3"
63
+ host = "devnet-seed-3.invalid"
64
+ port = 7300
65
+ public-key = "MCowBQYDK2VwAyEAHY2onS4+fZss0VZaT84AMcQQugILPagvBm3PFDE5cYY="
66
+ bls-public-key = "oO91EyEt4jvpP3XuuNiT+6F/9djCjAqgs/+e7eI64eGz9iRCcHT5HY17coLLKfsk"
67
+ quorum-weight = 0
68
+ }
69
+ ]
70
+
71
+ ce.evolver.consensus {
72
+ # ⚠️ ONE voting node. This is the committee size, not the number of genesis entries — the three
73
+ # seed entries above carry weight 0 and are not counted (NodeBootstrap derives the consensus
74
+ # cluster size with the same `quorumWeight > 0` filter the quorum store and leader rotation
75
+ # already use; before 170 it counted every entry, which would have put the relay threshold
76
+ # beyond anything a one-member committee could reach).
77
+ n-nodes = 1
78
+ block-time-ms = 600
79
+ round-timeout-ms = 1500
80
+ block-reward-tokens = 10000 # generous dev reward: the faucet (5000/identity) is funded from the FIRST blocks, even under quiescence
81
+ snapshot-interval-blocks = 0 # single-node: no snapshot cadence; joiners sync from block 0
82
+ identity-sponsorship-cost = 100
83
+
84
+ # Low idle disk-write: after 3 empty blocks the leader lingers on a 5-MINUTE heartbeat
85
+ # instead of the 600ms cadence (#31: idle-heartbeat-ms > block-time-ms activates quiescence);
86
+ # wake-on-work (node-local, always on) still proposes immediately on incoming txs.
87
+ # CHAIN-WIDE — identical on every node by construction.
88
+ quiescence {
89
+ idle-heartbeat-ms = 300000
90
+ idle-after-empty-rounds = 3
91
+ }
92
+ }
@@ -0,0 +1,232 @@
1
+ # This node: a NON-GENESIS FOLLOWER. Chain-wide params come from the genesis spec selected by the
2
+ # wrapper that includes this file — never set them here, they must stay byte-identical on every
3
+ # node. Only node-local knobs below.
4
+
5
+ # This node's OWN identity, from the first-boot faucet claim (node-join.sh). REQUIRED
6
+ # substitutions (no `?`): a node booting with missing keys must fail fast, not later and
7
+ # obscurely at the first TLS handshake.
8
+ ce.evolver.node {
9
+ private-key = ${EVOLVER_PRIVATE_KEY} # Ed25519 PKCS#8 base64
10
+ public-key = ${EVOLVER_NODE_PUBLIC_KEY} # Ed25519 X.509 base64
11
+ bls-secret = ${EVOLVER_BLS_SEED} # BLS IKM base64
12
+ port = ${EVOLVER_NODE_PORT} # p2p transport (raw TCP, mTLS)
13
+ }
14
+
15
+ evolver.peer-discovery {
16
+ # THIS NODE ADVERTISES NOTHING, AND MUST NOT. `preferred-url` is the address this node
17
+ # gossips as its OWN (`""` = feature off), and a joined app node behind Docker HAS no
18
+ # routable p2p address: its 7000 is container-local and deliberately unpublished. So
19
+ # advertising one means every node on the chain records `<us> -> https://127.0.0.1:7000`
20
+ # and then dials its own loopback.
21
+ #
22
+ # Observed on devnet, and it is worse than noise: this node CONNECTED TO ITSELF, logged
23
+ # `Peer connected: <self>`, sent its own SyncManager `BlockRangeRequest(4841,4845)`, and
24
+ # TipGossip then reported `rotating to <self> (failed=devnet1-node0)` followed by
25
+ # `stalled (upstream=none)`. After that its submits were never forwarded and every write
26
+ # timed out. The table also keeps the dead: `evolvertemplateappnode965813 at
27
+ # 127.0.0.1:7000` from an earlier deployment was still being dialled hours later.
28
+ # ADVERTISED AGAIN, and ⚠️ ONLY BECAUSE IT IS NOW REAL. This was `""` because the node
29
+ # had been advertising `https://127.0.0.1:7000`: every peer that read the discovery
30
+ # table dialled its OWN loopback, so this node connected to itself, sent its own
31
+ # SyncManager block requests, reported `candidates=<self>` / `stalled (upstream=none)`
32
+ # and stopped forwarding submits. An unroutable value reinstates that exactly — which
33
+ # is why `EVOLVER_TRANSPORT_ADDRESS` is REQUIRED here rather than defaulted, and why the
34
+ # command suite refuses a loopback value.
35
+ # ⚠️ EMPTY, DELIBERATELY. This node is a READER behind NAT: it has no routable p2p address.
36
+ # Advertising one is not merely useless, it is harmful — devnet recorded a node that advertised
37
+ # an unroutable address CONNECTING TO ITSELF, sending its own sync requests, reporting
38
+ # `stalled (upstream=none)`, and leaving dead entries other nodes dialled for hours.
39
+ #
40
+ # It costs this node nothing: advertising is how others DIAL YOU, and nothing needs to. Writing
41
+ # still works — this node dials OUT, discovers the network, learns its distance to the committee
42
+ # and forwards transactions along that path. Being undialable only stops bodies being flooded TO
43
+ # it, which is a DA member's concern and not a reader's.
44
+ transport-address = ""
45
+ peer-url = ""
46
+ gossip-interval-seconds = 60
47
+
48
+ # 16 INBOUND, not 0. The 141 leaf recipe (`in=0`) is right for an app node that only
49
+ # talks upstream — and wrong here: the registry's channel requires announcers' nodes to
50
+ # reach THIS node so their flooded bodies can be attested (research D2). Each announcer
51
+ # holds one inbound slot, so 16 is the number of concurrent announcer nodes supported.
52
+ # Generous on both sides so DISCOVERY actually happens. The seed below is pinned so the node
53
+ # always has a way back to the chain, but `allowed-outbound` is deliberately LARGER than the
54
+ # pinned list — the pin is strict only while the two are equal, and a strict pin here would mean
55
+ # this node never dials anything it discovers.
56
+ allowed-inbound = 16
57
+ # `allowed-outbound` and `preferred-peers` are DELIBERATELY ABSENT HERE — node-join.sh renders
58
+ # both into peers.conf so the two cannot drift apart in two files.
59
+ #
60
+ # ⚠️ THIS COMMENT USED TO CLAIM THE PIN IS STRICT. It is NOT, and the difference matters to
61
+ # anyone reasoning about what this node will dial. It said `|preferred| == allowed-outbound`
62
+ # holds "by construction"; node-join.sh renders ONE pinned seed and an outbound budget of 16,
63
+ # so 1 != 16 and the pin is deliberately LOOSE. That is the intended design — a follower has to
64
+ # dial what it discovers or it is a leaf — but the old wording described the opposite property
65
+ # and named the incidents (a dead address from a torn-down deployment still being dialled, this
66
+ # node dialling its own loopback) that a strict pin was supposed to prevent. Those are the COST
67
+ # of the loose pin, not evidence against it: the seed guarantees a way back to the chain, and
68
+ # discovery does the rest. Which is what keeps a shared chain's discovery table out of the
69
+ # dial queue — it keeps the dead (an address from a torn-down deployment was still
70
+ # being dialled hours later) and once made this node dial its own loopback. Raising
71
+ # the budget without naming a peer would silently unpin it; naming a peer without
72
+ # raising the budget could never reach it. One list, both keys, no drift.
73
+ #
74
+ # ⚠️ THIS USED TO BE `out=1`, copied verbatim from the 141 LEAF recipe, and it decided
75
+ # WHICH CHANNELS THIS NODE COULD READ — silently. `ChannelOverlay.peers(ledgerId)` is
76
+ # the channel's DA group ∩ connected, so with the one slot spent on the upstream every
77
+ # channel whose DA member is somebody else resolved to an EMPTY pull set: the node
78
+ # records the miss, sends no request, and says so at DEBUG only (evolver#89). A node
79
+ # reading a channel it does not itself serve needs the upstream AND one reachable DA
80
+ # member per such channel, so a fixed 1 is only ever right for a node that reads
81
+ # nothing but its own.
82
+ }
83
+
84
+ ce.evolver.consensus {
85
+ validate-chain-on-startup = false
86
+ wake-on-work { forward-watermark = 16 }
87
+ }
88
+
89
+ # PROCESSING MODE — off by default, and THE REASON THIS DEPLOYMENT EXISTS once your app
90
+ # reads a channel.
91
+ #
92
+ # `/api/channel/next` is a SINGLE-CONSUMER, FORWARD-ONLY cursor: two readers do not
93
+ # error, they silently split the stream and the message is unrecoverable. So an app that
94
+ # consumes an ordered channel must drain a node NOBODY ELSE drains — which means a node
95
+ # of its own. It cannot share a public one, and a single-node chain (devnet today) IS the
96
+ # committee, so that node is not a processing node and serves no stream at all.
97
+ #
98
+ # And the committee's node must never be put in this mode: processing mode DEFERS apply
99
+ # and paces it from the app's polling, so a committee node in processing mode would
100
+ # couple consensus progress to an HTTP poller. Ordering is its job; give the app its own.
101
+ #
102
+ # Turning it on changes what committed reads MEAN here — they answer "as of MY cursor".
103
+ # Measured in this family (evolver-scheduler): right after a successful faucet claim a
104
+ # quorum node reported `{"balance":5000}` for an identity while a processing node
105
+ # reported `{"error":"not_found"}` for the same one. An app in that mode needs to know
106
+ # which of its questions are "has the chain agreed?" (ask a quorum node) and which are
107
+ # "what have I processed?" (ask this one).
108
+ #
109
+ # ON, because this app owns a channel (`announcements`) and reads its ordered stream —
110
+ # steps 5-7 of src/app.ts. BOTH lines are needed and neither works alone: a node in
111
+ # processing mode with no channels declared has nothing to offer, and a node that
112
+ # declares channels without execution stores bodies it never streams.
113
+ #
114
+ # The channel must be named HERE as well as claimed on chain. A processing node stores
115
+ # bodies only for the channels it lists, so an undeclared channel's message is applied
116
+ # in the slice and never offered — the write looks fine and the stream stays silent,
117
+ # which is the failure the app's step 7 names explicitly.
118
+ # Processing is enabled only by the paired managed composition with its persistent zero-module
119
+ # shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
120
+ ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
121
+ # Private node-owned lifecycle capability; only the paired processor receives this volume.
122
+ # No control route is exposed on any TCP listener. 202 is acceptance, not ACTIVE.
123
+ ce.evolver.node-control.socket-path = "/node-control/node.sock"
124
+ # Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
125
+ # Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
126
+ include required(file("/subscriptions/channels.conf"))
127
+ # `required` for the same reason: an absent optional include is IGNORED, and a node
128
+ # that silently fell back to the 64/64 defaults would dial the discovery table.
129
+ include required(file("/data/chain/peers.conf"))
130
+
131
+ # ── THE LOCAL ROUTES THIS DEPLOYMENT OPENS TO ITS OWN CONTAINERS ────────────
132
+ #
133
+ # The agent and the registry run BESIDE this node, each in its own container on this compose
134
+ # project's network. These routes carry no token and no client certificate — reaching the address
135
+ # IS the authorization — so the node binds them to loopback unless told otherwise, and loopback
136
+ # inside this container is reachable by nothing else in the stack.
137
+ #
138
+ # `bind-host = 0.0.0.0` here means: THIS COMPOSE NETWORK IS THE BOUNDARY. That claim holds only
139
+ # because none of these ports appear under `ports:` in docker-compose.yml and the stack does not
140
+ # use host networking. Publish one, or run with `--network host`, and the claim is simply false.
141
+ #
142
+ # ⚠️ `ce.evolver.login` is deliberately NOT here and cannot be: that route signs as any identity
143
+ # this host holds a signer for, which on this node is the node's own. Setting `bind-host` under
144
+ # that namespace fails the boot rather than being ignored.
145
+
146
+ # 145 — the agent sends and drains application messages. `/api/p2p/send` relays a message the
147
+ # agent signed ITSELF (its own identity, verified at the far end); `/api/p2p/sign-and-send` makes
148
+ # this node sign as itself. One listener, so opening it grants both.
149
+ ce.evolver.p2p {
150
+ http-port = 8460
151
+ bind-host = "0.0.0.0"
152
+ }
153
+
154
+ # 158 — the agent may ASK an enrolled phone to authorise a transaction. It cannot sign: the device
155
+ # holder approves every one, and that gate does not move when the bind does. Inert until a
156
+ # `mobile-signer` block exists, and the node says so at boot rather than failing.
157
+ ce.evolver.mobile {
158
+ http-port = 8470
159
+ bind-host = "0.0.0.0"
160
+ }
161
+
162
+ # 139 — CONTENT, so the agent can publish a document and the registry can read one back.
163
+ #
164
+ # `POST /content` writes to this node's own store, keyed by the bytes' own digest, and talks to
165
+ # no peer at all (FR-006). `bind-host` below is what lets the agent and the registry reach that
166
+ # route from their own containers. Fetching a document another node holds is the part that uses
167
+ # the overlay, and that is what the TLS listener underneath is for.
168
+ ce.evolver.content {
169
+ enabled = true
170
+ http-port = 8450
171
+ bind-host = "0.0.0.0"
172
+ }
173
+
174
+ data.base = "/data/chain"
175
+ state.snapshots = "snapshots"
176
+ # BOTH LISTENERS, and the pair is the point.
177
+ #
178
+ # 8440 PLAIN is what the agent and the registry talk to. They are ordinary HTTP clients in their
179
+ # own containers, and the alternative is not "use TLS" — this node's HTTPS listener presents its
180
+ # own IDENTITY certificate, self-signed, CN=<nodeId>, ~13 minutes old. It rotates, so it cannot
181
+ # be pinned as a CA, so a client that verifies it must turn verification off process-wide. For
182
+ # the agent that would also stop it verifying GoToSocial, which is not a trade worth making for
183
+ # a hop between two containers on one network.
184
+ #
185
+ # 8441 TLS is what PEERS use, and it exists so `ce.evolver.content` above can: the content peer
186
+ # route identifies its requester by client certificate, and refuses to start without somewhere
187
+ # mTLS to live. NOT PUBLISHED in docker-compose.yml — this node is a reader behind NAT that
188
+ # fetches outbound and serves nobody, so nothing needs to dial it.
189
+ #
190
+ # ⚠️ IF THIS NODE EVER SERVES CONTENT, `evolver.peer-discovery.api-url` must name 8441 and that
191
+ # port must be publicly reachable. It is empty here (see above): a node with no routable address
192
+ # advertises nothing and can only fetch.
193
+ http.port = 8440
194
+ # Only this private Compose network may reach the unsigned processing API.
195
+ http.bind-host = "0.0.0.0"
196
+ # ⚠️ REQUIRED SINCE 131 (#131), and without it this stack breaks. `http.bind-host` now defaults
197
+ # to 127.0.0.1 — the plain listener joined the bind-is-authorization set the four local listeners
198
+ # above already belong to, because it carries the whole API with no token and no client
199
+ # certificate, including (this node runs `ce.evolver.execution.enabled`) the SINGLE-CONSUMER
200
+ # cursor `GET /api/channel/next`, which a stranger can step past the registry's own deliveries.
201
+ #
202
+ # The claim being made here is the same one `ce.evolver.content.bind-host` and the p2p/login ones
203
+ # above make: THIS COMPOSE NETWORK IS THE BOUNDARY. It holds because 8440 is NOT published in
204
+ # docker-compose.yml and nothing uses host networking — the agent and the registry reach this
205
+ # node as `node:8440`, a service name on that network. Publish the port, or run with host
206
+ # networking, and the claim stops being true. The node cannot tell, so it logs a WARN naming it.
207
+ http.bind-host = "0.0.0.0"
208
+ https.port = 8441
209
+ evolver.grpc.port = 9000
210
+
211
+ # REQUIRED, and there is no default: `NodeBootstrap.initChainDb` reads this block
212
+ # directly, so a node without it dies with `No configuration setting found for key
213
+ # 'chainDb'` after the migrations have already run — which is exactly how this omission
214
+ # was found. Same values as the bundled deployment's node.
215
+ chainDb {
216
+ datasource {
217
+ driver = "org.hsqldb.jdbc.JDBCDriver"
218
+ user = "sa"
219
+ pass = ""
220
+ maxPoolSize = 5
221
+ connectionProperties = ""
222
+ cachePrepStmts = true
223
+ prepStmtCacheSize = 250
224
+ prepStmtCacheSqlLimit = 2048
225
+ connectionTimeout = 5000
226
+ useServerPrepStmts = false
227
+ transactionIsolationLevel = "TRANSACTION_READ_COMMITTED"
228
+ }
229
+ viewCachesSize = 50
230
+ useShutdownHook = false
231
+ freeBlobsEarly = false
232
+ }
@@ -8,6 +8,10 @@
8
8
  # faucet-claim its identity on devnet (the committee node sponsors and funds it — no
9
9
  # admin secret exists anywhere in this deployment), persist the result. EVERY boot:
10
10
  # source it and start as a non-genesis follower, syncing the chain from block 0.
11
+ #
12
+ # EVOLVER_NODE_CONFIG (optional) selects the HOCON config file the JVM boots with; the default
13
+ # is /config/node.conf (the devnet wrapper). A local-chain deployment sets it to
14
+ # /config/node.local.conf to boot from /public-genesis/genesis.conf instead.
11
15
  set -eu
12
16
 
13
17
  # Same read-only guard on normal boot and the owned-fixture preflight. It NEVER initializes,
@@ -223,6 +227,16 @@ echo "[node-join] pinning ${_peers} as the seed; allowing ${_outbound} outbound
223
227
  ' "$_outbound"
224
228
  } > /data/chain/peers.conf
225
229
 
230
+ # #21: an unclean exit (OOM kill, power loss, SIGKILL after the stop timeout) leaves the private
231
+ # node-control socket behind, and the node refuses ANY pre-existing entry, so restart: unless-stopped
232
+ # would crash-loop forever. Remove it only if it is a SOCKET: this directory is exclusive to this
233
+ # node (the processor mount is read-only) and this entrypoint runs only while this container's
234
+ # node JVM is not running. Any other entry type is left for the node itself to refuse.
235
+ if [ -S /node-control/node.sock ]; then
236
+ echo "[node-join] removing stale node-control socket left by an unclean previous exit"
237
+ rm -f /node-control/node.sock
238
+ fi
239
+
226
240
  echo "[node-join] launching follower '$NODE_ID' — syncing devnet from block 0"
227
- exec /opt/evolver/bin/evolver -Dconfig.file=/config/node.conf "$NODE_ID"
241
+ exec /opt/evolver/bin/evolver -Dconfig.file="${EVOLVER_NODE_CONFIG:-/config/node.conf}" "$NODE_ID"
228
242