@mcsherrylabs/pi-meerkat 0.6.0 → 0.8.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 (67) hide show
  1. package/README.md +234 -54
  2. package/dist/installer/committee-init.sh +117 -0
  3. package/dist/installer/committee.conf +102 -0
  4. package/dist/installer/node-join.sh +228 -0
  5. package/dist/installer/node.conf +235 -0
  6. package/dist/installer/runtime.json +1 -0
  7. package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
  8. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
  9. package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
  10. package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
  11. package/dist/installer-runtime/dist/src/announcements.js +448 -0
  12. package/dist/installer-runtime/dist/src/app.js +539 -0
  13. package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
  14. package/dist/installer-runtime/dist/src/env.js +425 -0
  15. package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
  16. package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
  17. package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
  18. package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
  19. package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
  20. package/dist/installer-runtime/dist/src/registry/http.js +194 -0
  21. package/dist/installer-runtime/dist/src/registry/index.js +52 -0
  22. package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
  23. package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
  24. package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
  25. package/dist/installer-runtime/dist/src/registry/store.js +242 -0
  26. package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
  27. package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
  28. package/dist/installer-runtime/dist/src/runtime.js +164 -0
  29. package/dist/installer-runtime/dist/src/store.js +138 -0
  30. package/dist/installer-runtime/package.json +1 -0
  31. package/dist/installer.d.ts +224 -0
  32. package/dist/installer.d.ts.map +1 -0
  33. package/dist/installer.js +336 -0
  34. package/dist/installer.js.map +1 -0
  35. package/dist/mcp-announce.d.ts +42 -0
  36. package/dist/mcp-announce.d.ts.map +1 -0
  37. package/dist/mcp-announce.js +231 -0
  38. package/dist/mcp-announce.js.map +1 -0
  39. package/dist/mcp-cli.d.ts +3 -0
  40. package/dist/mcp-cli.d.ts.map +1 -0
  41. package/dist/mcp-cli.js +137 -0
  42. package/dist/mcp-cli.js.map +1 -0
  43. package/dist/mcp-config.d.ts +28 -0
  44. package/dist/mcp-config.d.ts.map +1 -0
  45. package/dist/mcp-config.js +104 -0
  46. package/dist/mcp-config.js.map +1 -0
  47. package/dist/mcp-facade.d.ts +20 -0
  48. package/dist/mcp-facade.d.ts.map +1 -0
  49. package/dist/mcp-facade.js +200 -0
  50. package/dist/mcp-facade.js.map +1 -0
  51. package/dist/mcp-upstream.d.ts +42 -0
  52. package/dist/mcp-upstream.d.ts.map +1 -0
  53. package/dist/mcp-upstream.js +151 -0
  54. package/dist/mcp-upstream.js.map +1 -0
  55. package/dist/registry.d.ts.map +1 -1
  56. package/dist/registry.js +11 -5
  57. package/dist/registry.js.map +1 -1
  58. package/dist/tools.d.ts +6 -2
  59. package/dist/tools.d.ts.map +1 -1
  60. package/dist/tools.js +123 -119
  61. package/dist/tools.js.map +1 -1
  62. package/installer/Committee.Dockerfile +6 -0
  63. package/installer/Node.Dockerfile +6 -0
  64. package/installer/Registry.Dockerfile +13 -0
  65. package/installer/runtime-control.mjs +172 -0
  66. package/installer/session-files.mjs +16 -0
  67. package/package.json +14 -5
@@ -0,0 +1,228 @@
1
+ #!/bin/sh
2
+ # Claim-then-boot (evolver feature 102) for an explicitly managed follower.
3
+ # Static config is baked into the candidate image. The separately initialized subscription
4
+ # directory is mounted read-only; the installer provisions/persists APP_NODE_ID before boot.
5
+ # Ordinary entry never initializes subscription preferences or adopts existing valued state.
6
+ #
7
+ # FIRST boot: generate this node's keys LOCALLY (they never leave this volume),
8
+ # faucet-claim its identity on devnet (the committee node sponsors and funds it — no
9
+ # admin secret exists anywhere in this deployment), persist the result. EVERY boot:
10
+ # source it and start as a non-genesis follower, syncing the chain from block 0.
11
+ set -eu
12
+
13
+ # Same read-only guard on normal boot and the owned-fixture preflight. It NEVER initializes,
14
+ # rewrites or merges a declaration; canonical fragment validation belongs to the shared SDK.
15
+ require_subscriptions() {
16
+ if [ ! -r "$1/channels.conf" ] ||
17
+ [ "$(cat "$1/channels.conf.initialized" 2>/dev/null || true)" != "evolver-subscriptions-v1" ]; then
18
+ echo "[node-join] FATAL: initialized subscription declaration missing; refusing to reseed"
19
+ return 1
20
+ fi
21
+ }
22
+ if [ "${1:-}" = "--check-subscriptions" ]; then
23
+ : "${2:?owned subscription directory required}"
24
+ require_subscriptions "$2"
25
+ exit $?
26
+ fi
27
+ require_subscriptions /subscriptions
28
+
29
+ : "${DEVNET_API:?DEVNET_API required (the faucet base url, e.g. https://devnet.mcsherrylabs.com)}"
30
+
31
+ # ⚠️ CHECKED HERE, not left to HOCON. `preferred-url` is a REQUIRED substitution in the
32
+ # node config, so an unset value fails with
33
+ # ConfigException$UnresolvedSubstitution: Could not resolve substitution to a value
34
+ # and a stack trace that says nothing about what an operator must do. Required is the
35
+ # right choice — an unroutable default is what made this node dial its own loopback and
36
+ # stop forwarding submits — but the refusal has to be readable.
37
+ # NOTE: meerkat's version of this script FATALs here unless EVOLVER_TRANSPORT_ADDRESS is set. That is
38
+ # an ANNOUNCER's requirement — its channel has a DA group, so other nodes must be able to dial it
39
+ # to flood bodies to it. A reader has no such need and must advertise nothing, so the guard is
40
+ # deliberately absent rather than satisfied with a made-up value.
41
+
42
+
43
+ KEYS=/data/keys
44
+ CLAIM="$KEYS/claim.env"
45
+ ID_FILE="$KEYS/node-id"
46
+ mkdir -p "$KEYS"
47
+ chmod 700 "$KEYS"
48
+
49
+ # The node id is chosen ONCE and persisted, because it is an on-chain name: re-deriving
50
+ # it on a later boot would claim a SECOND identity and abandon the first.
51
+ if [ -f "$ID_FILE" ]; then
52
+ NODE_ID="$(cat "$ID_FILE")"
53
+ echo "[node-join] reusing node id '$NODE_ID' (chain state continues)"
54
+ else
55
+ if [ -n "${APP_NODE_ID:-}" ]; then
56
+ NODE_ID="$APP_NODE_ID"
57
+ else
58
+ # Identity names are ^[A-Za-z0-9]{1,50}$ — no hyphens, dots or underscores — so the
59
+ # app name is stripped to what a node id may contain (the same rule as
60
+ # scripts/versions.mjs identityPrefix), and a random suffix keeps a shared chain
61
+ # collision-free: a fixed name would come back `exists` with keys nobody holds.
62
+ BASE="$(printf '%s' "${APP_NAME:-app}" | tr -cd '[:alnum:]' | cut -c1-40)"
63
+ [ -n "$BASE" ] || BASE=app
64
+ NODE_ID="${BASE}node$(tr -cd '0-9' < /dev/urandom | head -c 6)"
65
+ fi
66
+ printf '%s' "$NODE_ID" > "$ID_FILE"
67
+ echo "[node-join] node id '$NODE_ID' chosen and persisted"
68
+ fi
69
+
70
+ : "${APP_NODE_ID:?managed deployment requires its saved explicit node id}"
71
+ if [ "$NODE_ID" != "$APP_NODE_ID" ]; then
72
+ echo "[node-join] FATAL: saved node id differs from managed binding; refusing to claim or boot"
73
+ exit 1
74
+ fi
75
+
76
+ if [ ! -f "$CLAIM" ]; then
77
+ # A CLAIM WHOSE CONFIRMATION TIMED OUT MUST NOT BE RE-SENT BLIND.
78
+ #
79
+ # Observed on the live devnet: the faucet answered
80
+ # `504 {"error":"confirmation_timeout","message":"Transaction submitted but block
81
+ # confirmation timed out"}` — submitted, outcome UNKNOWN — the container exited, and
82
+ # the next boot found the id file but no claim file and claimed AGAIN. That is the one
83
+ # move that cannot be undone: the client persists its keypair only on success, so the
84
+ # first attempt's keys are gone, and if that attempt committed the name now belongs to
85
+ # keys nobody holds. The retry then either takes a name with a fresh keypair (and the
86
+ # first identity is abandoned) or comes back `exists`, after which the node fails every
87
+ # peer handshake with `Signature does not match` — the 103 failure verbatim.
88
+ #
89
+ # So the outcome is settled by READING, exactly as the app settles a submit: if the
90
+ # name is on chain and we hold no keys for it, stop and say so. The name cannot be
91
+ # reclaimed, so this is terminal by nature and the operator needs a different one.
92
+ # ASK, AND DISTINGUISH "NO" FROM "COULD NOT ASK".
93
+ #
94
+ # `wget | grep -q '"balance"'` is false for BOTH "the name is free" and "the faucet
95
+ # is unreachable", and on this path the second must not be read as the first: the
96
+ # whole point is to avoid claiming a name that may already be bound to keys nobody
97
+ # holds. So the identity route is asked with the exit status kept, and only a body
98
+ # that says `not_found` counts as free.
99
+ # 0 with "balance" -> taken
100
+ # 0 with "not_found" -> free
101
+ # anything else -> could not tell, which is not a licence to claim
102
+ # ⚠️ THE STATUS LINE, NOT THE EXIT CODE. `/api/identity/<name>` answers 404 for a name
103
+ # that is FREE — the commonest case — and `wget -qO-` reports any 404 as exit 8 with no
104
+ # body, so keying on the exit code classified "free" as "cannot ask" and refused to
105
+ # claim anything at all. Caught by the local rig, where every name is free.
106
+ #
107
+ # `-S` prints the response line even for an error status (GNU wget 1.25 in this image),
108
+ # so the three answers are distinguishable:
109
+ # 200 + "balance" -> taken
110
+ # 404 -> free
111
+ # anything else -> could not ask, which is NOT a licence to claim
112
+ id_state() {
113
+ # `|| true` IS LOAD-BEARING, and it is the second layer of the same trap. Keying on
114
+ # the status line instead of the exit code is necessary but not sufficient: under
115
+ # `set -e` the ASSIGNMENT inherits wget's exit status, so a 404 killed this function
116
+ # before the case could run — the caller saw an empty answer and the script exited 8.
117
+ _resp="$(wget --timeout=5 --tries=1 -S -qO- "$DEVNET_API/api/identity/$1" 2>&1)" || true
118
+ case "$_resp" in
119
+ *"200 OK"*'"balance"'*) echo taken ;;
120
+ *"200 OK"*) echo taken ;;
121
+ *"404"*) echo free ;;
122
+ *) echo unreachable ;;
123
+ esac
124
+ }
125
+ state="$(id_state "$NODE_ID")"
126
+ if [ "$state" = unreachable ]; then
127
+ echo "[node-join] FATAL: cannot reach $DEVNET_API to ask whether '$NODE_ID' is already"
128
+ echo "[node-join] claimed. Refusing to claim blind: if that name is already bound to keys"
129
+ echo "[node-join] this deployment does not hold, claiming it again destroys the name."
130
+ exit 1
131
+ fi
132
+ if [ "$state" = taken ]; then
133
+ echo "[node-join] FATAL: identity '$NODE_ID' already exists on chain, and this"
134
+ echo "[node-join] deployment holds no keys for it ($CLAIM is absent). A previous"
135
+ echo "[node-join] claim committed after its confirmation timed out, and its keys"
136
+ echo "[node-join] were never persisted — nothing here can authenticate as that name."
137
+ echo "[node-join] Set APP_NODE_ID to an unused name (an identity name cannot be reused)."
138
+ exit 1
139
+ fi
140
+ echo "[node-join] first boot: claiming identity '$NODE_ID' via $DEVNET_API"
141
+ java -cp "/opt/evolver/lib/*" ce.evolver.tools.IdentityClaimClient \
142
+ --target="$DEVNET_API" --node-id="$NODE_ID" --out="$CLAIM" \
143
+ --timeout="${CLAIM_TIMEOUT:-180}" || CLAIM_RC=$?
144
+ if [ ! -f "$CLAIM" ]; then
145
+ # OUR attempt's outcome is now the unknown one, and asking ONCE is not enough: a
146
+ # `504 confirmation_timeout` means the transaction was submitted, and it can commit
147
+ # minutes later (its TTL is +50 rounds, and on a quiescent chain rounds are slow).
148
+ # Asking immediately and printing "the name is still free" was wrong while the claim
149
+ # was in flight — and with `restart: unless-stopped` the next boot would claim the
150
+ # same name AGAIN with fresh keys, which is how a name gets bound to keys nobody
151
+ # holds. So watch it for a bounded window before saying anything.
152
+ echo "[node-join] the claim for '$NODE_ID' did not confirm (exit ${CLAIM_RC:-?}) —"
153
+ echo "[node-join] outcome UNKNOWN. Watching for up to ${CLAIM_WATCH_SECS:-300}s to see"
154
+ echo "[node-join] whether it commits; nothing is resent."
155
+ _waited=0
156
+ _final=free
157
+ while [ "$_waited" -lt "${CLAIM_WATCH_SECS:-300}" ]; do
158
+ _s="$(id_state "$NODE_ID")"
159
+ if [ "$_s" = taken ]; then _final=taken; break; fi
160
+ sleep 10
161
+ _waited=$((_waited + 10))
162
+ done
163
+ if [ "$_final" = taken ]; then
164
+ echo "[node-join] FATAL: the claim for '$NODE_ID' COMMITTED after its confirmation"
165
+ echo "[node-join] timed out, and its keys were never persisted (evolver#86)."
166
+ echo "[node-join] Set APP_NODE_ID to an unused name and start again — an identity"
167
+ echo "[node-join] name cannot be reused."
168
+ else
169
+ echo "[node-join] '$NODE_ID' is still not on chain after ${_waited}s, so the claim"
170
+ echo "[node-join] did not commit and the name remains free; a restart may retry it."
171
+ fi
172
+ exit 1
173
+ fi
174
+ chmod 600 "$CLAIM"
175
+ echo "[node-join] claim committed — keys persisted (mode 600)"
176
+ fi
177
+
178
+ # EVOLVER_PRIVATE_KEY / EVOLVER_NODE_PUBLIC_KEY / EVOLVER_BLS_SEED
179
+ set -a
180
+ . "$CLAIM"
181
+ set +a
182
+
183
+ mkdir -p /data/chain
184
+ # Config-only directory is mounted read-only into this node. Only the shared SDK controller
185
+ # writes it. Ordinary boot NEVER merges environment/module defaults or initializes a missing file.
186
+ # SDK initialization is an explicit fresh-resource command; its marker survives an empty set.
187
+ # Already required before any identity/claim effects at entry.
188
+ # Keep keys/chain private: no agent/container writes this node's database or fragment.
189
+ chmod 700 /data/chain
190
+
191
+ # ── WHICH PEERS THIS NODE DIALS ───────────────────────────────────────────
192
+ #
193
+ # ONE list rendered as BOTH keys, because they are one decision: `preferred-peers` says
194
+ # who, `allowed-outbound` says how many, and the pin is strict only while the two agree
195
+ # (node.conf says why). Computed here rather than written in node.conf for the same
196
+ # reason the channel list is — so the two halves cannot drift apart.
197
+ #
198
+ # The upstream is always dialled. EVOLVER_DA_PEERS adds the DA members this deployment
199
+ # must reach to PULL bodies for the channels declared above; without a slot for them,
200
+ # `ChannelOverlay.peers()` is empty for those channels and nothing is ever fetched.
201
+ _peers="${EVOLVER_UPSTREAM_NODE_ID}"
202
+ # EVOLVER_DA_PEERS, comma-separated, deduplicated against the upstream. The comment above has
203
+ # promised this since the file was written and the code never did it — so a deployment that set
204
+ # it got silence, and the only way to pin a second peer was to smuggle a comma into
205
+ # EVOLVER_UPSTREAM_NODE_ID, which works here only by accident of raw substitution and breaks the
206
+ # dedupe and the count in the sibling stacks that DO implement this. Same shape as
207
+ # evolver-llm/docker-compose.devnet.yml.
208
+ for _p in $(printf '%s' "${EVOLVER_DA_PEERS:-}" | tr ',' ' '); do
209
+ if [ -n "$_p" ] && [ "$_p" != "${EVOLVER_UPSTREAM_NODE_ID}" ]; then
210
+ _peers="$_peers, $_p"
211
+ fi
212
+ done
213
+ _outbound="${EVOLVER_ALLOWED_OUTBOUND:-16}"
214
+ echo "[node-join] pinning ${_peers} as the seed; allowing ${_outbound} outbound so discovery can happen"
215
+ {
216
+ # The seed is PINNED so there is always a way back to the chain. `allowed-outbound` is
217
+ # deliberately LARGER than that one entry: the pin is strict only while the two are equal, and a
218
+ # strict pin would stop this node dialling anything it discovers. Once it has peers it learns its
219
+ # own distance to the committee and forwards transactions along that path like any other node.
220
+ printf 'evolver.peer-discovery.preferred-peers = [ %s ]
221
+ ' "$_peers"
222
+ printf 'evolver.peer-discovery.allowed-outbound = %s
223
+ ' "$_outbound"
224
+ } > /data/chain/peers.conf
225
+
226
+ 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"
228
+
@@ -0,0 +1,235 @@
1
+ # This node: a NON-GENESIS FOLLOWER on devnet. Chain-wide params come from the genesis
2
+ # spec — never set them here, they must stay byte-identical on every node. Only
3
+ # node-local knobs below.
4
+ include file("/public-genesis/genesis.conf")
5
+
6
+ # 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
7
+ # Any drift refuses to boot rather than forking quietly.
8
+ ce.evolver.genesis-file = "/public-genesis/genesis.conf"
9
+
10
+ # This node's OWN identity, from the first-boot faucet claim (node-join.sh). REQUIRED
11
+ # substitutions (no `?`): a node booting with missing keys must fail fast, not later and
12
+ # obscurely at the first TLS handshake.
13
+ ce.evolver.node {
14
+ private-key = ${EVOLVER_PRIVATE_KEY} # Ed25519 PKCS#8 base64
15
+ public-key = ${EVOLVER_NODE_PUBLIC_KEY} # Ed25519 X.509 base64
16
+ bls-secret = ${EVOLVER_BLS_SEED} # BLS IKM base64
17
+ port = ${EVOLVER_NODE_PORT} # p2p transport (raw TCP, mTLS)
18
+ }
19
+
20
+ evolver.peer-discovery {
21
+ # THIS NODE ADVERTISES NOTHING, AND MUST NOT. `preferred-url` is the address this node
22
+ # gossips as its OWN (`""` = feature off), and a joined app node behind Docker HAS no
23
+ # routable p2p address: its 7000 is container-local and deliberately unpublished. So
24
+ # advertising one means every node on the chain records `<us> -> https://127.0.0.1:7000`
25
+ # and then dials its own loopback.
26
+ #
27
+ # Observed on devnet, and it is worse than noise: this node CONNECTED TO ITSELF, logged
28
+ # `Peer connected: <self>`, sent its own SyncManager `BlockRangeRequest(4841,4845)`, and
29
+ # TipGossip then reported `rotating to <self> (failed=devnet1-node0)` followed by
30
+ # `stalled (upstream=none)`. After that its submits were never forwarded and every write
31
+ # timed out. The table also keeps the dead: `evolvertemplateappnode965813 at
32
+ # 127.0.0.1:7000` from an earlier deployment was still being dialled hours later.
33
+ # ADVERTISED AGAIN, and ⚠️ ONLY BECAUSE IT IS NOW REAL. This was `""` because the node
34
+ # had been advertising `https://127.0.0.1:7000`: every peer that read the discovery
35
+ # table dialled its OWN loopback, so this node connected to itself, sent its own
36
+ # SyncManager block requests, reported `candidates=<self>` / `stalled (upstream=none)`
37
+ # and stopped forwarding submits. An unroutable value reinstates that exactly — which
38
+ # is why `EVOLVER_TRANSPORT_ADDRESS` is REQUIRED here rather than defaulted, and why the
39
+ # command suite refuses a loopback value.
40
+ # ⚠️ EMPTY, DELIBERATELY. This node is a READER behind NAT: it has no routable p2p address.
41
+ # Advertising one is not merely useless, it is harmful — devnet recorded a node that advertised
42
+ # an unroutable address CONNECTING TO ITSELF, sending its own sync requests, reporting
43
+ # `stalled (upstream=none)`, and leaving dead entries other nodes dialled for hours.
44
+ #
45
+ # It costs this node nothing: advertising is how others DIAL YOU, and nothing needs to. Writing
46
+ # still works — this node dials OUT, discovers the network, learns its distance to the committee
47
+ # and forwards transactions along that path. Being undialable only stops bodies being flooded TO
48
+ # it, which is a DA member's concern and not a reader's.
49
+ transport-address = ""
50
+ peer-url = ""
51
+ gossip-interval-seconds = 60
52
+
53
+ # 16 INBOUND, not 0. The 141 leaf recipe (`in=0`) is right for an app node that only
54
+ # talks upstream — and wrong here: the registry's channel requires announcers' nodes to
55
+ # reach THIS node so their flooded bodies can be attested (research D2). Each announcer
56
+ # holds one inbound slot, so 16 is the number of concurrent announcer nodes supported.
57
+ # Generous on both sides so DISCOVERY actually happens. The seed below is pinned so the node
58
+ # always has a way back to the chain, but `allowed-outbound` is deliberately LARGER than the
59
+ # pinned list — the pin is strict only while the two are equal, and a strict pin here would mean
60
+ # this node never dials anything it discovers.
61
+ allowed-inbound = 16
62
+ # `allowed-outbound` and `preferred-peers` are DELIBERATELY ABSENT HERE — node-join.sh renders
63
+ # both into peers.conf so the two cannot drift apart in two files.
64
+ #
65
+ # ⚠️ THIS COMMENT USED TO CLAIM THE PIN IS STRICT. It is NOT, and the difference matters to
66
+ # anyone reasoning about what this node will dial. It said `|preferred| == allowed-outbound`
67
+ # holds "by construction"; node-join.sh renders ONE pinned seed and an outbound budget of 16,
68
+ # so 1 != 16 and the pin is deliberately LOOSE. That is the intended design — a follower has to
69
+ # dial what it discovers or it is a leaf — but the old wording described the opposite property
70
+ # and named the incidents (a dead address from a torn-down deployment still being dialled, this
71
+ # node dialling its own loopback) that a strict pin was supposed to prevent. Those are the COST
72
+ # of the loose pin, not evidence against it: the seed guarantees a way back to the chain, and
73
+ # discovery does the rest. Which is what keeps a shared chain's discovery table out of the
74
+ # dial queue — it keeps the dead (an address from a torn-down deployment was still
75
+ # being dialled hours later) and once made this node dial its own loopback. Raising
76
+ # the budget without naming a peer would silently unpin it; naming a peer without
77
+ # raising the budget could never reach it. One list, both keys, no drift.
78
+ #
79
+ # ⚠️ THIS USED TO BE `out=1`, copied verbatim from the 141 LEAF recipe, and it decided
80
+ # WHICH CHANNELS THIS NODE COULD READ — silently. `ChannelOverlay.peers(ledgerId)` is
81
+ # the channel's DA group ∩ connected, so with the one slot spent on the upstream every
82
+ # channel whose DA member is somebody else resolved to an EMPTY pull set: the node
83
+ # records the miss, sends no request, and says so at DEBUG only (evolver#89). A node
84
+ # reading a channel it does not itself serve needs the upstream AND one reachable DA
85
+ # member per such channel, so a fixed 1 is only ever right for a node that reads
86
+ # nothing but its own.
87
+ }
88
+
89
+ ce.evolver.consensus {
90
+ validate-chain-on-startup = false
91
+ wake-on-work { forward-watermark = 16 }
92
+ }
93
+
94
+ # PROCESSING MODE — off by default, and THE REASON THIS DEPLOYMENT EXISTS once your app
95
+ # reads a channel.
96
+ #
97
+ # `/api/channel/next` is a SINGLE-CONSUMER, FORWARD-ONLY cursor: two readers do not
98
+ # error, they silently split the stream and the message is unrecoverable. So an app that
99
+ # consumes an ordered channel must drain a node NOBODY ELSE drains — which means a node
100
+ # of its own. It cannot share a public one, and a single-node chain (devnet today) IS the
101
+ # committee, so that node is not a processing node and serves no stream at all.
102
+ #
103
+ # And the committee's node must never be put in this mode: processing mode DEFERS apply
104
+ # and paces it from the app's polling, so a committee node in processing mode would
105
+ # couple consensus progress to an HTTP poller. Ordering is its job; give the app its own.
106
+ #
107
+ # Turning it on changes what committed reads MEAN here — they answer "as of MY cursor".
108
+ # Measured in this family (evolver-scheduler): right after a successful faucet claim a
109
+ # quorum node reported `{"balance":5000}` for an identity while a processing node
110
+ # reported `{"error":"not_found"}` for the same one. An app in that mode needs to know
111
+ # which of its questions are "has the chain agreed?" (ask a quorum node) and which are
112
+ # "what have I processed?" (ask this one).
113
+ #
114
+ # ON, because this app owns a channel (`announcements`) and reads its ordered stream —
115
+ # steps 5-7 of src/app.ts. BOTH lines are needed and neither works alone: a node in
116
+ # processing mode with no channels declared has nothing to offer, and a node that
117
+ # declares channels without execution stores bodies it never streams.
118
+ #
119
+ # The channel must be named HERE as well as claimed on chain. A processing node stores
120
+ # bodies only for the channels it lists, so an undeclared channel's message is applied
121
+ # in the slice and never offered — the write looks fine and the stream stays silent,
122
+ # which is the failure the app's step 7 names explicitly.
123
+ # Processing is enabled only by the paired managed composition with its persistent zero-module
124
+ # shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
125
+ ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
126
+ # Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
127
+ # Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
128
+ include required(file("/subscriptions/channels.conf"))
129
+ # `required` for the same reason: an absent optional include is IGNORED, and a node
130
+ # that silently fell back to the 64/64 defaults would dial the discovery table.
131
+ include required(file("/data/chain/peers.conf"))
132
+
133
+ # ── THE LOCAL ROUTES THIS DEPLOYMENT OPENS TO ITS OWN CONTAINERS ────────────
134
+ #
135
+ # The agent and the registry run BESIDE this node, each in its own container on this compose
136
+ # project's network. These routes carry no token and no client certificate — reaching the address
137
+ # IS the authorization — so the node binds them to loopback unless told otherwise, and loopback
138
+ # inside this container is reachable by nothing else in the stack.
139
+ #
140
+ # `bind-host = 0.0.0.0` here means: THIS COMPOSE NETWORK IS THE BOUNDARY. That claim holds only
141
+ # because none of these ports appear under `ports:` in docker-compose.yml and the stack does not
142
+ # use host networking. Publish one, or run with `--network host`, and the claim is simply false.
143
+ #
144
+ # ⚠️ `ce.evolver.login` is deliberately NOT here and cannot be: that route signs as any identity
145
+ # this host holds a signer for, which on this node is the node's own. Setting `bind-host` under
146
+ # that namespace fails the boot rather than being ignored.
147
+
148
+ # 145 — the agent sends and drains application messages. `/api/p2p/send` relays a message the
149
+ # agent signed ITSELF (its own identity, verified at the far end); `/api/p2p/sign-and-send` makes
150
+ # this node sign as itself. One listener, so opening it grants both.
151
+ ce.evolver.p2p {
152
+ http-port = 8460
153
+ bind-host = "0.0.0.0"
154
+ }
155
+
156
+ # 158 — the agent may ASK an enrolled phone to authorise a transaction. It cannot sign: the device
157
+ # holder approves every one, and that gate does not move when the bind does. Inert until a
158
+ # `mobile-signer` block exists, and the node says so at boot rather than failing.
159
+ ce.evolver.mobile {
160
+ http-port = 8470
161
+ bind-host = "0.0.0.0"
162
+ }
163
+
164
+ # 139 — CONTENT, so the agent can publish a document and the registry can read one back.
165
+ #
166
+ # `POST /content` writes to this node's own store, keyed by the bytes' own digest, and talks to
167
+ # no peer at all (FR-006). `bind-host` below is what lets the agent and the registry reach that
168
+ # route from their own containers. Fetching a document another node holds is the part that uses
169
+ # the overlay, and that is what the TLS listener underneath is for.
170
+ ce.evolver.content {
171
+ enabled = true
172
+ http-port = 8450
173
+ bind-host = "0.0.0.0"
174
+ }
175
+
176
+ data.base = "/data/chain"
177
+ state.snapshots = "snapshots"
178
+ # BOTH LISTENERS, and the pair is the point.
179
+ #
180
+ # 8440 PLAIN is what the agent and the registry talk to. They are ordinary HTTP clients in their
181
+ # own containers, and the alternative is not "use TLS" — this node's HTTPS listener presents its
182
+ # own IDENTITY certificate, self-signed, CN=<nodeId>, ~13 minutes old. It rotates, so it cannot
183
+ # be pinned as a CA, so a client that verifies it must turn verification off process-wide. For
184
+ # the agent that would also stop it verifying GoToSocial, which is not a trade worth making for
185
+ # a hop between two containers on one network.
186
+ #
187
+ # 8441 TLS is what PEERS use, and it exists so `ce.evolver.content` above can: the content peer
188
+ # route identifies its requester by client certificate, and refuses to start without somewhere
189
+ # mTLS to live. NOT PUBLISHED in docker-compose.yml — this node is a reader behind NAT that
190
+ # fetches outbound and serves nobody, so nothing needs to dial it.
191
+ #
192
+ # ⚠️ IF THIS NODE EVER SERVES CONTENT, `evolver.peer-discovery.api-url` must name 8441 and that
193
+ # port must be publicly reachable. It is empty here (see above): a node with no routable address
194
+ # advertises nothing and can only fetch.
195
+ http.port = 8440
196
+ # Only this private Compose network may reach the unsigned processing API.
197
+ http.bind-host = "0.0.0.0"
198
+ # ⚠️ REQUIRED SINCE 131 (#131), and without it this stack breaks. `http.bind-host` now defaults
199
+ # to 127.0.0.1 — the plain listener joined the bind-is-authorization set the four local listeners
200
+ # above already belong to, because it carries the whole API with no token and no client
201
+ # certificate, including (this node runs `ce.evolver.execution.enabled`) the SINGLE-CONSUMER
202
+ # cursor `GET /api/channel/next`, which a stranger can step past the registry's own deliveries.
203
+ #
204
+ # The claim being made here is the same one `ce.evolver.content.bind-host` and the p2p/login ones
205
+ # above make: THIS COMPOSE NETWORK IS THE BOUNDARY. It holds because 8440 is NOT published in
206
+ # docker-compose.yml and nothing uses host networking — the agent and the registry reach this
207
+ # node as `node:8440`, a service name on that network. Publish the port, or run with host
208
+ # networking, and the claim stops being true. The node cannot tell, so it logs a WARN naming it.
209
+ http.bind-host = "0.0.0.0"
210
+ https.port = 8441
211
+ evolver.grpc.port = 9000
212
+
213
+ # REQUIRED, and there is no default: `NodeBootstrap.initChainDb` reads this block
214
+ # directly, so a node without it dies with `No configuration setting found for key
215
+ # 'chainDb'` after the migrations have already run — which is exactly how this omission
216
+ # was found. Same values as the bundled deployment's node.
217
+ chainDb {
218
+ datasource {
219
+ driver = "org.hsqldb.jdbc.JDBCDriver"
220
+ user = "sa"
221
+ pass = ""
222
+ maxPoolSize = 5
223
+ connectionProperties = ""
224
+ cachePrepStmts = true
225
+ prepStmtCacheSize = 250
226
+ prepStmtCacheSqlLimit = 2048
227
+ connectionTimeout = 5000
228
+ useServerPrepStmts = false
229
+ transactionIsolationLevel = "TRANSACTION_READ_COMMITTED"
230
+ }
231
+ viewCachesSize = 50
232
+ useShutdownHook = false
233
+ freeBlobsEarly = false
234
+ }
235
+
@@ -0,0 +1 @@
1
+ {"operatorIdentity":"evolvermeerkatA","sdk":"0.22.1","bootstrap":"0.15.0"}
@@ -0,0 +1,15 @@
1
+ // Explicit offline operator action. Never imported/called by ordinary startup.
2
+ import { pathToFileURL } from "node:url";
3
+ import { recoverProcessorOwnerLock } from "@mcsherrylabs/evolver-client/processor";
4
+ import { currentTarget } from "../src/env.js";
5
+ import { processorStatePath } from "../src/runtime.js";
6
+ export function recoverMeerkatOwner(args, target, say = console.error) {
7
+ if (args.length !== 1 || args[0] !== "--force")
8
+ throw new Error("usage: recover-owner --force (only after stopping ALL collectors and disabling automatic restarts)");
9
+ say("WARNING: --force acknowledges ALL collectors are stopped and automatic restarts disabled; this command cannot verify that.");
10
+ const result = recoverProcessorOwnerLock({ path: processorStatePath(target), force: true });
11
+ say(`${result.removed ? "Removed owner lock" : "No owner lock present"}: ${result.marker}. Database/WAL, progress, subscriptions and keys unchanged. Start ONE collector normally with the SAME target/state environment to acquire a new lock.`);
12
+ return result;
13
+ }
14
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
15
+ recoverMeerkatOwner(process.argv.slice(2), currentTarget());
@@ -0,0 +1,35 @@
1
+ // Explicit fresh-resource operation; NEVER imported/called by ordinary startup or upgrade.
2
+ import { chown, chmod, mkdir, readdir } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { initializeSubscriptions } from "@mcsherrylabs/evolver-client/processor";
5
+ import { currentTarget, ROOT } from "../src/env.js";
6
+ import { processorConfigPath } from "../src/runtime.js";
7
+ const raw = process.env.EVOLVER_INITIAL_CHANNELS;
8
+ if (raw === undefined)
9
+ throw new Error("set EVOLVER_INITIAL_CHANNELS to an explicit JSON array (including []); initialization is fresh-only");
10
+ const channels = JSON.parse(raw);
11
+ if (!Array.isArray(channels) || channels.some(id => typeof id !== "string"))
12
+ throw new Error("EVOLVER_INITIAL_CHANNELS must be a JSON string array");
13
+ const path = processorConfigPath(currentTarget());
14
+ const directory = dirname(path);
15
+ if (process.getuid?.() === 0) {
16
+ const uid = Number(process.env.EVOLVER_OPERATOR_UID), gid = Number(process.env.EVOLVER_OPERATOR_GID);
17
+ if (![uid, gid].every(id => Number.isSafeInteger(id) && id >= 1))
18
+ throw new Error("root fresh initializer requires non-root operator uid/gid");
19
+ for (const dir of [directory, join(ROOT, "state")]) {
20
+ await mkdir(dir, { recursive: true, mode: 0o750 });
21
+ if ((await readdir(dir)).length !== 0)
22
+ throw new Error("fresh initialization refuses nonempty managed volumes; no valued migration/reseed");
23
+ }
24
+ for (const dir of [directory, join(ROOT, "state")]) {
25
+ await chown(dir, uid, gid);
26
+ await chmod(dir, 0o750);
27
+ }
28
+ process.setgroups([]);
29
+ process.setgid(gid);
30
+ process.setuid(uid);
31
+ }
32
+ else
33
+ await mkdir(directory, { recursive: true, mode: 0o750 });
34
+ await initializeSubscriptions(path, channels);
35
+ console.log(`initialized subscription declaration ${path}; ordinary startup will never reseed it`);
@@ -0,0 +1,86 @@
1
+ // Read .env — the single source of truth for versions and image names.
2
+ //
3
+ // Deliberately hand-parsed rather than pulling in dotenv: this is a template, and a
4
+ // dependency whose whole job is reading six lines of KEY=value is a dependency a reader has
5
+ // to evaluate. docker compose reads the same file natively, so there is one file and two
6
+ // consumers, not two sources.
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join, resolve } from "node:path";
10
+ // fileURLToPath, not url.pathname — see the note in src/env.ts.
11
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+ export const ENV_PATH = join(ROOT, ".env");
13
+ export function readEnv() {
14
+ const out = {};
15
+ for (const line of readFileSync(ENV_PATH, "utf8").split("\n")) {
16
+ const t = line.trim();
17
+ if (!t || t.startsWith("#"))
18
+ continue;
19
+ const i = t.indexOf("=");
20
+ if (i < 0)
21
+ continue;
22
+ // Match docker compose's grammar, because it reads this same file: strip an inline
23
+ // comment and surrounding quotes, and tolerate CRLF. Diverging here is worse than not
24
+ // parsing at all — `npm run image` would tag one thing while `docker compose up` ran
25
+ // another, and nothing would report a conflict.
26
+ let v = t.slice(i + 1).replace(/\r$/, "").trim();
27
+ v = v.replace(/\s+#.*$/, "").trim();
28
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
29
+ v = v.slice(1, -1);
30
+ }
31
+ if (!v)
32
+ continue; // empty ⇒ treat as missing, so the required-keys check reports it
33
+ out[t.slice(0, i).trim()] = v;
34
+ }
35
+ for (const k of ["EVOLVER_REGISTRY", "REGISTRY", "EVOLVER_VERSION", "APP_NAME", "APP_VERSION"]) {
36
+ if (!out[k])
37
+ throw new Error(`${ENV_PATH}: ${k} is missing — it is required, see the comments in that file`);
38
+ }
39
+ return out;
40
+ }
41
+ /** Update keys in .env IN PLACE, preserving comments and order — the comments are the
42
+ * documentation, so rewriting the file from an object would delete the explanation. */
43
+ export function updateEnv(updates) {
44
+ const lines = readFileSync(ENV_PATH, "utf8").split("\n");
45
+ const seen = new Set();
46
+ const out = lines.map((line) => {
47
+ const t = line.trim();
48
+ if (!t || t.startsWith("#"))
49
+ return line;
50
+ const i = t.indexOf("=");
51
+ if (i < 0)
52
+ return line;
53
+ const key = t.slice(0, i).trim();
54
+ if (!(key in updates))
55
+ return line;
56
+ seen.add(key);
57
+ return `${key}=${updates[key]}`;
58
+ });
59
+ for (const [k, v] of Object.entries(updates))
60
+ if (!seen.has(k))
61
+ out.push(`${k}=${v}`);
62
+ writeFileSync(ENV_PATH, out.join("\n"));
63
+ }
64
+ export const appImage = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}:${e.APP_VERSION}`;
65
+ export const deployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy:${e.APP_VERSION}`;
66
+ /** The app-ALONE deployment, pointed at a chain that already exists — from
67
+ * `docker-compose.remote.yml`. */
68
+ export const remoteDeployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy-remote:${e.APP_VERSION}`;
69
+ /** The app PLUS ITS OWN NODE, that node joining devnet — from `docker-compose.devnet.yml`.
70
+ *
71
+ * Three refs because they are three different things to hand someone, and the difference is
72
+ * not cosmetic: one carries a throwaway chain, one borrows somebody else's node on a real
73
+ * chain, and one brings a node of its own onto that real chain (which is the only shape that
74
+ * can read an ordered channel stream or join a DA overlay). */
75
+ export const devnetDeployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy-devnet:${e.APP_VERSION}`;
76
+ /** Identity names are `^[A-Za-z0-9]{1,50}$` — no hyphens, dots or underscores — so an app
77
+ * name cannot be used verbatim. Strip it to what a node id may contain and bound it.
78
+ *
79
+ * This exists because the identity names used to be hardcoded `tmplA`/`tmplB`: rename your
80
+ * app to anything you like and it still claimed identities named after the template, which
81
+ * on a SHARED chain like devnet is the one place provenance matters. Empty after stripping
82
+ * (a name of only separators) falls back to `app` rather than producing `A`/`B`. */
83
+ export function identityPrefix(appName) {
84
+ const stripped = String(appName ?? "").replace(/[^A-Za-z0-9]/g, "").slice(0, 40);
85
+ return stripped || "app";
86
+ }