@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
@@ -1,235 +1,8 @@
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")
1
+ # DevNet wrapper: selects the devnet genesis, then the follower body.
2
+ include file("/config/genesis.devnet.conf")
5
3
 
6
4
  # 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
7
5
  # 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
- }
6
+ ce.evolver.genesis-file = "/config/genesis.devnet.conf"
235
7
 
8
+ include required(file("/config/node-follower.conf"))
@@ -0,0 +1,8 @@
1
+ # Local-chain wrapper: selects the committee-exported public genesis, then the follower body.
2
+ include file("/public-genesis/genesis.conf")
3
+
4
+ # 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
5
+ # Any drift refuses to boot rather than forking quietly.
6
+ ce.evolver.genesis-file = "/public-genesis/genesis.conf"
7
+
8
+ include required(file("/config/node-follower.conf"))
@@ -1 +1 @@
1
- {"operatorIdentity":"evolvermeerkatA","sdk":"0.22.1","bootstrap":"0.15.0"}
1
+ {"operatorIdentity":"evolvermeerkatA","devnet":{"origin":"https://devnet.mcsherrylabs.com","chainId":"c8925985ae7948d2e608efcf863928d15476c4b561ad773fdca6f0d53d4fea05","channel":"ce.meerkat.announcements","operator":"evolvermeerkatA483344","upstreamNodeId":"evolvermeerkatnode920080","daPeers":"schednode2","allowedOutbound":"2","genesisSha256":"7f6a95cd0fcbe2306d89b2426a9473b1c6537427a82feaffff4783d9cc5dc6b9"},"sdk":"0.24.0","bootstrap":"0.17.0"}
@@ -1,15 +1,13 @@
1
1
  // Explicit offline operator action. Never imported/called by ordinary startup.
2
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";
3
+ import { recoverManagedOwner } from "@mcsherrylabs/evolver-client/processor";
4
+ import { currentTarget, registryStatePath } from "../src/env.js";
5
+ /** Offline forced owner-lock recovery for the registry. Delegates to the SDK's
6
+ * `recoverManagedOwner`, which owns the usage error, the `[registry] ` prefix and the
7
+ * explicit-acknowledgement warning; only the state path is Meerkat's. The exported name is
8
+ * kept for evolver-mcp's fault fixture. */
6
9
  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;
10
+ return recoverManagedOwner(args, { statePath: registryStatePath(target), service: "registry", say });
13
11
  }
14
12
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
15
13
  recoverMeerkatOwner(process.argv.slice(2), currentTarget());
@@ -1,16 +1,15 @@
1
1
  // Explicit fresh-resource operation; NEVER imported/called by ordinary startup or upgrade.
2
2
  import { chown, chmod, mkdir, readdir } from "node:fs/promises";
3
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";
4
+ import { initializeSubscriptions, MANAGED_PROCESSOR_DEFAULTS } from "@mcsherrylabs/evolver-client/processor";
5
+ import { ROOT } from "../src/env.js";
7
6
  const raw = process.env.EVOLVER_INITIAL_CHANNELS;
8
7
  if (raw === undefined)
9
8
  throw new Error("set EVOLVER_INITIAL_CHANNELS to an explicit JSON array (including []); initialization is fresh-only");
10
9
  const channels = JSON.parse(raw);
11
10
  if (!Array.isArray(channels) || channels.some(id => typeof id !== "string"))
12
11
  throw new Error("EVOLVER_INITIAL_CHANNELS must be a JSON string array");
13
- const path = processorConfigPath(currentTarget());
12
+ const path = MANAGED_PROCESSOR_DEFAULTS.paths.subscriptions;
14
13
  const directory = dirname(path);
15
14
  if (process.getuid?.() === 0) {
16
15
  const uid = Number(process.env.EVOLVER_OPERATOR_UID), gid = Number(process.env.EVOLVER_OPERATOR_GID);
@@ -32,7 +32,7 @@ export function readEnv() {
32
32
  continue; // empty ⇒ treat as missing, so the required-keys check reports it
33
33
  out[t.slice(0, i).trim()] = v;
34
34
  }
35
- for (const k of ["EVOLVER_REGISTRY", "REGISTRY", "EVOLVER_VERSION", "APP_NAME", "APP_VERSION"]) {
35
+ for (const k of ["EVOLVER_REGISTRY", "REGISTRY", "EVOLVER_VERSION", "APP_NAME", "APP_VERSION", "EVOLVER_MCP_VERSION"]) {
36
36
  if (!out[k])
37
37
  throw new Error(`${ENV_PATH}: ${k} is missing — it is required, see the comments in that file`);
38
38
  }
@@ -63,15 +63,10 @@ export function updateEnv(updates) {
63
63
  }
64
64
  export const appImage = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}:${e.APP_VERSION}`;
65
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). */
66
+ /** The evolver-mcp-node image the managed follower runs — a separate variable because it is the
67
+ * evolver-mcp project's registry/version, not this app's node image. */
68
+ export const mcpNodeImage = (e = readEnv()) => `${e.EVOLVER_REGISTRY}/evolver-mcp-node:${e.EVOLVER_MCP_VERSION}`;
69
+ /** The app PLUS ITS OWN NODE, that node joining devnet — from `docker-compose.devnet.yml`. */
75
70
  export const devnetDeployRef = (e = readEnv()) => `${e.REGISTRY}/${e.APP_NAME}-deploy-devnet:${e.APP_VERSION}`;
76
71
  /** Identity names are `^[A-Za-z0-9]{1,50}$` — no hyphens, dots or underscores — so an app
77
72
  * name cannot be used verbatim. Strip it to what a node id may contain and bound it.
@@ -30,7 +30,7 @@
30
30
  // stores and floods the body it will later serve. This is why the group-chat deployment
31
31
  // has a "DA group = the app nodes" step, and why an app cannot simply write to a channel
32
32
  // it claimed with defaults.
33
- import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody, fString, fVarint, fMessage, concat, LEDGER, } from "@mcsherrylabs/evolver-client";
33
+ import { fString, fVarint, fMessage, concat } from "@mcsherrylabs/evolver-client";
34
34
  /**
35
35
  * The channel this app owns — ONE variable, read by the app AND by the node's
36
36
  * `subscribed-channels` declaration in docker-compose.devnet.yml, because the two must agree
@@ -47,23 +47,6 @@ import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody,
47
47
  * rather than a literal.
48
48
  */
49
49
  export const CHANNEL = process.env.EVOLVER_CHANNEL?.trim() || "announcements1";
50
- /**
51
- * The DA threshold. ZERO — best-effort — because this app's DA group has ONE member.
52
- *
53
- * The scheduler's own validation states the rule and I had it wrong: at one DA node a
54
- * threshold of 1 can never be satisfied, because a node is never its own flood target and
55
- * does not self-attest, so a write needing one attestation waits for one that will not come
56
- * and ends in `503 da_unavailable`.
57
- *
58
- * ⚠ And best-effort means what it says: one member is ONE VOLUME. Lose it and every message
59
- * that member alone held is unrecoverable — and any future processing node declaring this
60
- * channel WEDGES on the first such message, permanently, because apply is forward-only. That
61
- * is not hypothetical: it is what happened to `announcements`, and it is why this app is on
62
- * `announcements1` (evolver#83). Two members on separate volumes is the answer when the
63
- * messages matter.
64
- */
65
- export const DA_THRESHOLD_M = Number(process.env.EVOLVER_DA_THRESHOLD_M?.trim() || 0);
66
- const utf8 = (s) => new TextEncoder().encode(s);
67
50
  /** The channel's terms as a NAMED node sees them — pass the devnet url to ask the chain, or
68
51
  * this app's node to ask its own cursor. The difference is the point (fact 2). */
69
52
  export async function channelTerms(baseUrl, ledgerId = CHANNEL) {
@@ -111,27 +94,6 @@ export async function groupMembers(baseUrl, group) {
111
94
  return { known: false };
112
95
  }
113
96
  }
114
- /**
115
- * This node's OWN on-chain identity, which the app needs because the DA group's member is the
116
- * NODE, not the app.
117
- *
118
- * Read from `/status`, which is an HTML page — the honest state of things: a node reports its
119
- * own identity to a human and has no machine-readable equivalent. A JSON status route would
120
- * make this a question instead of a parse (see evolver#82, which asks for the same thing about
121
- * readiness).
122
- */
123
- export async function nodeSelfId(baseUrl) {
124
- try {
125
- const res = await fetch(`${baseUrl}/status`, { signal: AbortSignal.timeout(10_000) });
126
- if (!res.ok)
127
- return null;
128
- const m = /<h1>\s*Node:\s*([A-Za-z0-9]+)\s*<\/h1>/.exec(await res.text());
129
- return m ? m[1] : null;
130
- }
131
- catch {
132
- return null;
133
- }
134
- }
135
97
  /**
136
98
  * Submit a write and then CONFIRM IT BY READING — the only reliable shape on a node whose
137
99
  * apply this app paces (fact 3).
@@ -176,61 +138,6 @@ export async function submitAndConfirm(node, stx, label, confirm, say, opts) {
176
138
  }
177
139
  throw new Error(`${label}: not visible on chain within the deadline — outcome UNKNOWN, nothing resent`);
178
140
  }
179
- /**
180
- * Create the DA group and put this app's NODE in it.
181
- *
182
- * Both steps are best-effort and SAY what happened, because neither is the gate: from run two
183
- * onward "already exists" is the normal answer, and on a paced node a submit routinely returns
184
- * `timeout` and commits anyway. The real gate is the CLAIM below — it cannot succeed unless the
185
- * group exists with this node in it, so a silent failure here surfaces there rather than
186
- * passing unnoticed.
187
- */
188
- export async function ensureDaGroup(node, baseUrl, owner, signer, group, memberNodeId, say, pace) {
189
- say(` DA group '${group}', member '${memberNodeId}' — the NODE's identity, not the app's`);
190
- // READ FIRST, AND SUBMIT ONLY WHAT IS MISSING.
191
- //
192
- // This used to submit CreateGroup and AddMember unconditionally on every run. A CreateGroup
193
- // for a name that already exists is not simply refused: the node ACCEPTS it, then rejects it
194
- // PRE-COMMIT every round ("name 'x' taken by group") and RETAINS it, re-proposing until its
195
- // TTL. On the live devnet that did two things — three rejection warnings per round in the
196
- // leader's log, and, far worse, it kept the tx queue permanently non-empty, which SUPPRESSES
197
- // wake-on-work for the whole chain (evolver#85: the wake fires only on the queue's empty →
198
- // non-empty edge). Every submitter, ours and everyone else's, then waited up to the
199
- // five-minute idle heartbeat — which is also what burned an identity name through
200
- // evolver#86. A pointless resubmission is not free, and on a shared chain it is not local.
201
- const before = await groupMembers(baseUrl, group);
202
- if (before.known && "members" in before && before.members.includes(memberNodeId)) {
203
- say(` group '${group}' exists with '${memberNodeId}' as a member — nothing to submit`);
204
- return;
205
- }
206
- if (!before.known) {
207
- throw new Error(`could not read group '${group}' from ${baseUrl} — refusing to guess: ` +
208
- `submitting a CreateGroup for a name that already exists poisons the ` +
209
- `chain's tx queue (evolver#85), so this needs an answer, not an attempt.`);
210
- }
211
- const steps = [];
212
- if ("absent" in before) {
213
- steps.push([`create group '${group}'`, createGroupBody(group),
214
- async () => { const g = await groupMembers(baseUrl, group); return g.known && !("absent" in g); }]);
215
- }
216
- else {
217
- say(` group '${group}' already exists — adding the member only`);
218
- }
219
- steps.push([`add '${memberNodeId}' to '${group}'`, addMemberBody(group, memberNodeId),
220
- async () => {
221
- const g = await groupMembers(baseUrl, group);
222
- return g.known && "members" in g && g.members.includes(memberNodeId);
223
- }]);
224
- for (const [label, body, confirm] of steps) {
225
- const stx = buildSignedTx({
226
- ledgerId: LEDGER.groups, senderNodeId: owner.nodeId, body,
227
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
228
- });
229
- // Confirmed by READING, like every other write here: a submit that times out has an
230
- // unknown outcome, and on a quiescent chain the block is minutes away.
231
- await submitAndConfirm(node, stx, label, confirm, say, { pace });
232
- }
233
- }
234
141
  /**
235
142
  * `UpdateChannelDaTxBody` (097) — OwnersTxBody variant 4.
236
143
  *
@@ -251,198 +158,3 @@ export function updateChannelDaBody(args) {
251
158
  parts.push(...fVarint(3, args.daThresholdM));
252
159
  return concat(fMessage(4, concat(parts)));
253
160
  }
254
- /**
255
- * Make the channel exist, owned by us, WITH a DA group — the only shape that accepts a
256
- * body-form write (fact 4).
257
- *
258
- * Idempotent, and the three cases are genuinely different: nothing claimed (claim it, carrying
259
- * the DA policy), claimed by us with the WRONG DA policy (converge it in place with
260
- * `UpdateChannelDa`), claimed by somebody else (stop: that is a real conflict on a shared
261
- * chain).
262
- */
263
- export async function ensureChannelWithDa(node, chainUrl, owner, signer, group, say, pace) {
264
- const ask = () => channelTerms(chainUrl);
265
- const read = await ask();
266
- if (!read.ok)
267
- throw new Error(`could not read the terms of '${CHANNEL}' from ${chainUrl}: ${read.why}\n` +
268
- ` Refusing to guess: an unreadable answer used to mean "unclaimed", and acting on ` +
269
- `that submits a Claim for a channel someone else may own — accepted, then rejected ` +
270
- `every round until its TTL (evolver#85).`);
271
- const terms = read.terms;
272
- const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
273
- const someoneElse = terms.owners.length > 0 && !ownedByUs;
274
- // A CHANNEL NAME IS GLOBAL ON A SHARED CHAIN, and whoever claims it owns it. A second
275
- // deployment of this app — new identities, same channel name — is therefore a READER, not a
276
- // broken installation: it can declare the channel, subscribe, and fold the ordered stream,
277
- // which is most of what an announcements channel is for. Only the owner may write (the
278
- // default write policy is owner-or-group; 156 lets an owner open it to any writer).
279
- //
280
- // Dying here instead would be wrong in the ordinary case and would make the name a
281
- // first-come landmine.
282
- if (someoneElse) {
283
- say(` '${CHANNEL}' is owned by ${terms.owners.map((o) => o.name).join(", ")} — this app will READ it, not write`);
284
- return "reader";
285
- }
286
- // THE POLICY MUST MATCH, not merely exist — and "wrong" includes a threshold this app now
287
- // knows is unsatisfiable. A mismatch is converged with `UpdateChannelDa`, in place; a second
288
- // Claim would not do it (on an already-claimed ledger a Claim is a committed NO-OP).
289
- //
290
- // Measured on the deployment: `announcements1` was claimed at m=1 with a single-member
291
- // group, this function said "already claimed by us with a DA group — reusing", and the very
292
- // next write died with `fetch failed` — the attestation that can never arrive, waited out
293
- // until the connection dropped. Checking only for PRESENCE is what let that through twice.
294
- const daOk = !!terms.da && terms.da.group === group && terms.da.thresholdM === DA_THRESHOLD_M;
295
- if (ownedByUs && daOk) {
296
- say(` '${CHANNEL}' already claimed by us with DA group '${group}' (m=${DA_THRESHOLD_M}) — reusing`);
297
- return "owner";
298
- }
299
- if (ownedByUs && !daOk) {
300
- const had = terms.da ? `DA group '${terms.da.group}' (m=${terms.da.thresholdM})` : "NO DA group";
301
- say(` '${CHANNEL}' is claimed by us with ${had}, but this app needs '${group}' (m=${DA_THRESHOLD_M})`);
302
- // ⚠️ A TARGETED UPDATE, NOT UNCLAIM-THEN-RECLAIM.
303
- //
304
- // This used to drop the claim and re-claim, on the belief that a DA policy is set at claim
305
- // time and cannot be changed. That belief is wrong — `UpdateChannelDa` (097) exists and is
306
- // owner-gated (OwnersLedgerOrchestrator validates it and sets the policy in place) — and
307
- // the belief made the app do something irreversible to fix something reversible:
308
- // `clearChannelPolicies` drops all three policy rows, and BETWEEN the unclaim and the
309
- // re-claim the channel name is UNOWNED, so on a shared chain anyone can claim it and the
310
- // name is lost for good. A second Claim would not even have converged it: on an
311
- // already-claimed ledger a Claim is a committed NO-OP, which is why the unclaim was there
312
- // at all. evolver-scheduler's provisioner had this right first.
313
- say(` converging in place with UpdateChannelDa — an unclaim would expose the name to being taken`);
314
- const upd = buildSignedTx({
315
- ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
316
- body: updateChannelDaBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
317
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
318
- });
319
- await submitAndConfirm(node, upd, `update '${CHANNEL}' DA policy to '${group}' (m=${DA_THRESHOLD_M})`, async () => {
320
- const r = await ask();
321
- return r.ok && !!r.terms.da && r.terms.da.group === group &&
322
- r.terms.da.thresholdM === DA_THRESHOLD_M;
323
- }, say, { pace });
324
- return "owner";
325
- }
326
- // Nothing owns it: claim it, carrying the DA policy in the claim itself.
327
- const claim = buildSignedTx({
328
- ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
329
- body: claimBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
330
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
331
- });
332
- await submitAndConfirm(node, claim, `claim '${CHANNEL}' with DA group '${group}' (m=${DA_THRESHOLD_M})`, async () => {
333
- const r = await ask();
334
- return r.ok && !!r.terms.da && r.terms.da.group === group &&
335
- r.terms.da.thresholdM === DA_THRESHOLD_M &&
336
- r.terms.owners.some((o) => o.name === owner.nodeId);
337
- }, say, { pace });
338
- return "owner";
339
- }
340
- /** Write one announcement. Plain UTF-8: this channel is public by intent. */
341
- export async function announce(node, from, signer, text, say) {
342
- const stx = buildSignedTx({
343
- ledgerId: CHANNEL, senderNodeId: from.nodeId,
344
- body: channelBody(utf8(text)), ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
345
- });
346
- // submitTxWithDa: the node collects the channel's DA attestations over the overlay and
347
- // assembles the transaction. A client cannot do that for itself — it has no overlay.
348
- const r = await node.submitTxWithDa(stx);
349
- if (r.kind === "committed") {
350
- say(` announced: "${text}"`);
351
- return;
352
- }
353
- if (r.kind === "timeout") {
354
- say(` announce: submit timed out — the stream below is the confirmation`);
355
- return;
356
- }
357
- // A duplicate is not a failure of this app: the txId is a function of the body, the sender
358
- // and the TTL, so an identical announcement that already committed answers `duplicate` —
359
- // which means the message IS on the channel, which is exactly what the stream below checks.
360
- if (r.kind === "failed" && r.reason === "duplicate") {
361
- say(` announce: this exact message is already on the chain — the stream below is the confirmation`);
362
- return;
363
- }
364
- throw new Error(`the announcement was refused: ${JSON.stringify(r)}\n` +
365
- ` insufficient_da means the DA group could not be mustered — is this app's node a member?\n` +
366
- ` out-of-band means the channel has no DA group at all (see ensureChannelWithDa).`);
367
- }
368
- // The demo reads the shared collector's durable inbox. There is no local handler map,
369
- // volatile receive buffer, cursor probe or second consumer of the channel route.
370
- export async function retainedMessages(store, channel) {
371
- const messages = [];
372
- let after;
373
- do {
374
- const page = await store.inboxPage(channel, { limit: 100, after });
375
- for (const entry of page.entries)
376
- if (entry.body !== null)
377
- messages.push(new TextDecoder().decode(entry.body));
378
- if (!page.more)
379
- return messages;
380
- if (!page.next || (after && (BigInt(page.next.round) < BigInt(after.round) ||
381
- (page.next.round === after.round && BigInt(page.next.index) <= BigInt(after.index)))))
382
- throw new Error("inbox readback did not advance its continuation");
383
- after = page.next;
384
- } while (true);
385
- }
386
- /** Pace the processor forward until this node stops making progress — which is how a processing
387
- * node applies blocks at all (fact 2). Anything of ours it passes on the way is KEPT. */
388
- export async function paceApply(pace, say) {
389
- const MAX = 200;
390
- // A walk stops at its budget whether or not the cursor reached the tip, so the report
391
- // distinguishes the two. Claiming "to its tip" after a truncated walk is a statement the code
392
- // cannot make — a backlog longer than MAX leaves apply behind, and the reads that follow would
393
- // silently answer as of an earlier round.
394
- let walked = 0;
395
- let caughtUp = false;
396
- for (let attempts = 0; attempts < 10 && walked < MAX; attempts++) {
397
- const r = await pace();
398
- walked += r.inboxed + r.missed + r.duplicates;
399
- if (r.stoppedBecause === "body-pending")
400
- throw new Error("node is waiting for a channel body; nothing skipped");
401
- if (r.stoppedBecause === "caught-up") {
402
- caughtUp = true;
403
- break;
404
- }
405
- }
406
- if (!caughtUp)
407
- say(` paced this node's apply, stopping at ${walked} delivery(ies) — the backlog is longer, so apply may still be behind`);
408
- else
409
- say(` paced this node's apply to its tip; original deliveries are retained in the inbox`);
410
- }
411
- /** Pacing with no narration, for use inside a wait loop. Keeps what it walks past, as above. */
412
- export async function paceQuietly(pace) {
413
- await pace();
414
- }
415
- /**
416
- * Follow the stream until our own message arrives.
417
- *
418
- * The delivery is the app's write coming back THROUGH CONSENSUS, which is the only evidence
419
- * that matters: a submit says the transaction was ordered; a delivery says the app can read
420
- * what was ordered, in order, from its own node.
421
- *
422
- * A submit that timed out has an UNKNOWN outcome and the block carrying it can be minutes away
423
- * on a quiescent chain, so this waits to a DEADLINE rather than for a fixed number of idle polls
424
- * (20s of idle was the old bound, and it expired before the block existed). Nothing is ever
425
- * resent — the transaction id is fixed, so waiting is the only correct move.
426
- */
427
- export async function readBack(pace, want, say, opts) {
428
- // ⚠️ A CALLER THAT CANNOT MATCH MUST PASS ITS OWN DEADLINE. This waits for a specific
429
- // message, so with a sentinel that never matches it waits out the whole budget — five
430
- // minutes of "following the stream" for the reader-mode caller, which only wants to watch
431
- // briefly. That regression arrived with the deadline itself, from changing this function's
432
- // contract without revisiting its second call site.
433
- const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
434
- let received = [...await opts.messages()];
435
- while (!received.some((t) => t.includes(want)) && Date.now() < deadline) {
436
- // Pending/quiet is an outcome; storage, module and transport failures are errors. Never
437
- // disguise one as an empty read or a successful wait. The app's finally releases its owner.
438
- const r = await pace();
439
- received = [...await opts.messages()];
440
- if ((r.stoppedBecause === "caught-up" || r.stoppedBecause === "body-pending") && !received.some(t => t.includes(want)))
441
- await new Promise(res => setTimeout(res, 500));
442
- }
443
- const found = received.some((t) => t.includes(want));
444
- say(` retained ${received.length} message(s) on '${CHANNEL}'`);
445
- return { seen: [...received], found };
446
- }
447
- // Only the node's body-unavailable outcome permits advancement. No application timer
448
- // converts pending/unreadable outcomes into absence; the shared collector owns that rule.