@mcsherrylabs/pi-meerkat 0.7.0 → 0.10.1

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 (63) hide show
  1. package/README.md +109 -33
  2. package/dist/installer/committee-init.sh +117 -0
  3. package/dist/installer/committee.conf +102 -0
  4. package/dist/installer/genesis.devnet.conf +92 -0
  5. package/dist/installer/node-join.sh +235 -0
  6. package/dist/installer/node.conf +238 -0
  7. package/dist/installer/node.devnet.conf +238 -0
  8. package/dist/installer/runtime.json +1 -0
  9. package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
  10. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
  11. package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
  12. package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
  13. package/dist/installer-runtime/dist/src/announcements.js +448 -0
  14. package/dist/installer-runtime/dist/src/app.js +552 -0
  15. package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
  16. package/dist/installer-runtime/dist/src/env.js +437 -0
  17. package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
  18. package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
  19. package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
  20. package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
  21. package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
  22. package/dist/installer-runtime/dist/src/registry/http.js +194 -0
  23. package/dist/installer-runtime/dist/src/registry/index.js +61 -0
  24. package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
  25. package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
  26. package/dist/installer-runtime/dist/src/registry/provision.js +225 -0
  27. package/dist/installer-runtime/dist/src/registry/store.js +242 -0
  28. package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
  29. package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
  30. package/dist/installer-runtime/dist/src/runtime.js +192 -0
  31. package/dist/installer-runtime/dist/src/store.js +138 -0
  32. package/dist/installer-runtime/package.json +1 -0
  33. package/dist/installer.d.ts +401 -0
  34. package/dist/installer.d.ts.map +1 -0
  35. package/dist/installer.js +446 -0
  36. package/dist/installer.js.map +1 -0
  37. package/dist/mcp-announce.d.ts +18 -0
  38. package/dist/mcp-announce.d.ts.map +1 -1
  39. package/dist/mcp-announce.js +18 -4
  40. package/dist/mcp-announce.js.map +1 -1
  41. package/dist/mcp-cli.d.ts.map +1 -1
  42. package/dist/mcp-cli.js +41 -5
  43. package/dist/mcp-cli.js.map +1 -1
  44. package/dist/mcp-config.d.ts +1 -0
  45. package/dist/mcp-config.d.ts.map +1 -1
  46. package/dist/mcp-config.js +2 -1
  47. package/dist/mcp-config.js.map +1 -1
  48. package/dist/mcp-upstream.d.ts +1 -1
  49. package/dist/mcp-upstream.d.ts.map +1 -1
  50. package/dist/mcp-upstream.js +7 -2
  51. package/dist/mcp-upstream.js.map +1 -1
  52. package/dist/registry.d.ts.map +1 -1
  53. package/dist/registry.js +8 -4
  54. package/dist/registry.js.map +1 -1
  55. package/dist/tools.js +4 -4
  56. package/dist/tools.js.map +1 -1
  57. package/installer/Committee.Dockerfile +6 -0
  58. package/installer/Node.Dockerfile +10 -0
  59. package/installer/Registry.Dockerfile +13 -0
  60. package/installer/runtime-control.mjs +335 -0
  61. package/installer/session-files.mjs +16 -0
  62. package/package.json +8 -7
  63. package/skills/channel-subscription/SKILL.md +3 -3
@@ -0,0 +1,225 @@
1
+ // Make the registry's channel exist with the terms this feature needs, and prove it by reading.
2
+ //
3
+ // ONE term now, and it is load-bearing for everything:
4
+ //
5
+ // writePolicy = open so third parties may announce, AND so any holder will serve a body
6
+ //
7
+ // ⚠️ THERE USED TO BE THREE, and the other two were a DA group of { this node } at m=1. The
8
+ // argument was FR-007 satisfied structurally: `InsufficientDA` is enforced at admit and at
9
+ // pre-commit, so an announcement that committed could not have committed without this node
10
+ // holding the body. That much was true. What it left out is who paid for it — a third party's
11
+ // write needed an attestation from this one node, so announcing required a live p2p path to
12
+ // the registry and failed `503 da_unavailable` with nothing ordered when it was down. An index
13
+ // being unavailable stopped people writing the thing being indexed. And the guarantee bought
14
+ // with that was one volume, not replication: lose it and the message was gone anyway.
15
+ //
16
+ // So the DA policy is REMOVED — permanently, and this path converges an existing channel to
17
+ // that shape rather than leaving two designs in the estate. Bodies are best-effort, `misses`
18
+ // is the honest metric, and the drainer already treated an unfetchable body as a bounded miss
19
+ // rather than a stall, so nothing downstream was relying on the promise.
20
+ //
21
+ // ⚠️ WHAT SURVIVES UNCHANGED: THE OPERATOR ANNOUNCES THROUGH ITS OWN NODE. The first version of
22
+ // this file routed the operator's own announcements through a foreign gateway, which left its
23
+ // entries — including the base instruction document the agent skill trusts most — reachable
24
+ // only by a single best-effort flood. Submitted through THIS node, `channelOriginate` stores
25
+ // the body LOCALLY before the id-only form is ordered (NodeBootstrap.scala:1339-1341 →
26
+ // ChannelOverlay `onOriginate`). With no DA group that local store is now the ONLY thing
27
+ // putting the operator's own bodies anywhere, so it matters more than it did, not less. See
28
+ // announce.ts and research D3.
29
+ import { buildSignedTx, claimBody, updateChannelWritePolicyBody, LEDGER } from "@mcsherrylabs/evolver-client";
30
+ import { channelTerms, submitAndConfirm, updateChannelDaBody, } from "../announcements.js";
31
+ export function decideRegistryRole(args) {
32
+ const { terms, self, operator, readOnly } = args;
33
+ // READ-ONLY: the installer's registry never claims or signs with any identity. `self` is the
34
+ // PINNED operator name, not this deployment's (nonexistent) identity, and it must NEVER yield
35
+ // `owner` — a read-only indexer submitting a Claim for a channel someone else owns poisons the
36
+ // chain's tx queue (evolver#85). Decision folds entirely onto the pinned operator.
37
+ if (readOnly) {
38
+ if (terms.owners.length === 0) {
39
+ return { kind: "waiting", why: `'${terms.ledgerId}' is UNCLAIMED and this read-only registry will not claim it (owned ` +
40
+ `by '${operator ?? "(no operator pinned)"}', not by this deployment). Draining the cursor ` +
41
+ `and indexing nothing until the operator provisions it.` };
42
+ }
43
+ const holder = terms.owners.map((o) => o.name).join(", ");
44
+ if (operator && !terms.owners.some((o) => o.name === operator))
45
+ return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder}, but REGISTRY_CHANNEL_OPERATOR pins ` +
46
+ `'${operator}'. Either the operator changed — update the pin — or this name was taken by ` +
47
+ `somebody else. Refusing to index a channel whose terms are set by a party this deployment ` +
48
+ `was not told to trust.` };
49
+ if (terms.writePolicy !== "open")
50
+ return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder} and its write policy is ` +
51
+ `'${terms.writePolicy}', not 'open'. Every announcement would be a permanent miss.` };
52
+ return { kind: "reader", holder };
53
+ }
54
+ // ALREADY OURS — see "keeping is not acquiring" above. Note this is the IDENTITY name,
55
+ // matching what the claim/converge path below can actually sign for: a channel owned by a
56
+ // group we happen to belong to is deliberately NOT treated as ours, because this app holds
57
+ // one identity and submits as it.
58
+ if (terms.owners.some((o) => o.name === self))
59
+ return { kind: "owner" };
60
+ // UNCLAIMED. The route answers 200 with `owners: []` for a channel that has never existed
61
+ // (research A2), so this is also what a typo'd REGISTRY_CHANNEL looks like — one more reason
62
+ // not to answer it by claiming.
63
+ if (terms.owners.length === 0) {
64
+ if (operator && operator === self)
65
+ return { kind: "owner" };
66
+ return { kind: "waiting", why: operator
67
+ ? `'${terms.ledgerId}' is UNCLAIMED, and this deployment ('${self}') is not its operator — ` +
68
+ `REGISTRY_CHANNEL_OPERATOR names '${operator}'. Draining the cursor and indexing nothing ` +
69
+ `until '${operator}' provisions it. Nothing will be claimed here.`
70
+ : `'${terms.ledgerId}' is UNCLAIMED and no REGISTRY_CHANNEL_OPERATOR is set, so this ` +
71
+ `deployment will not claim it — a channel name is permanent and cannot be reused, and ` +
72
+ `boot order is not a mandate. Draining the cursor and indexing nothing. To make THIS ` +
73
+ `deployment the operator, set REGISTRY_CHANNEL_OPERATOR=${self} and restart.` };
74
+ }
75
+ const holder = terms.owners.map((o) => o.name).join(", ");
76
+ // OWNED, BUT NOT BY THE NAMED OPERATOR. Unusable rather than reader: the terms are set by
77
+ // somebody this deployment was not told to trust, and quietly indexing them is how a squatted
78
+ // channel becomes an index nobody audits.
79
+ if (operator && !terms.owners.some((o) => o.name === operator))
80
+ return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder}, but REGISTRY_CHANNEL_OPERATOR names ` +
81
+ `'${operator}'. Either the operator changed — update the key — or this name was taken by ` +
82
+ `somebody else, in which case choose another REGISTRY_CHANNEL. Refusing to index a ` +
83
+ `channel whose terms are set by a party this deployment was not told to trust.` };
84
+ // ⚠️ NO DA POLICY IS THE EXPECTED SHAPE, and this used to be the refusal that made reader
85
+ // mode pointless: "nothing is OBLIGED to hold a body, so a reader cannot index what nobody
86
+ // must keep." Bodies are best-effort by design now (see [[ProvisionResult]]), a miss is a
87
+ // counted outcome rather than a broken premise, and refusing here would refuse the registry's
88
+ // own channel. A channel that still HAS a DA policy is read just the same — it is a legacy
89
+ // shape the owner has not converged yet, not an error.
90
+ if (terms.writePolicy !== "open")
91
+ return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder} and its write policy is ` +
92
+ `'${terms.writePolicy}', not 'open'. With no DA group there is no membership that would ` +
93
+ `let a holder serve us anyway, so an open write policy is the ONLY thing that makes a ` +
94
+ `body obtainable — every announcement would be a permanent miss. Ask ${holder} to open ` +
95
+ `the channel, or choose another REGISTRY_CHANNEL.` };
96
+ return { kind: "reader", holder };
97
+ }
98
+ export async function provisionRegistryChannel(args) {
99
+ const { node, chainUrl, chainAgreedUrl, channel, owner, signer, nodeIdentity, operator, say, pace } = args;
100
+ let writes = 0;
101
+ // ⚠️ READ AND DECIDE BEFORE WRITING ANYTHING.
102
+ //
103
+ // A DA group used to be created here, ahead of the role decision, so a deployment that turned
104
+ // out to be a mere READER had already created and paid for a group it could never be asked to
105
+ // attest with. No group is created at all now, which retires that hazard rather than ordering
106
+ // around it — but the rule it taught still holds: decide first, so `writes === 0` is a fact
107
+ // about a reader rather than a hope.
108
+ const ask = () => channelTerms(chainUrl, channel); // "has MY node applied it?"
109
+ const read = await channelTerms(chainAgreedUrl, channel); // "what has the CHAIN agreed?"
110
+ // Refusing to guess, for the reason the group read refuses: acting on an unreadable answer
111
+ // means submitting a Claim for a channel somebody else may own, which the node accepts and
112
+ // then rejects every round until its TTL, poisoning the queue for the whole chain
113
+ // (evolver#85).
114
+ if (!read.ok)
115
+ throw new Error(`could not read the terms of '${channel}' from ${chainAgreedUrl}: ${read.why}\n` +
116
+ ` Refusing to guess — a Claim for a channel someone else owns is retained ` +
117
+ `and re-proposed until its TTL, which suppresses wake-on-work chain-wide.`);
118
+ const terms = read.terms;
119
+ const role = decideRegistryRole({ terms, self: owner.nodeId, operator });
120
+ if (role.kind === "unusable")
121
+ throw new Error(role.why);
122
+ if (role.kind === "waiting") {
123
+ // NOT a throw, and the distinction is the node's liveness. This process is what reads
124
+ // `/api/channel/next`; kill it and the node stops applying, so a deployment that merely
125
+ // has nothing to index would take its own node's core state down with it.
126
+ say(` ⚠ ${role.why}`);
127
+ return { role: "waiting", channel, member: nodeIdentity, writes: 0 };
128
+ }
129
+ if (role.kind === "reader") {
130
+ // A channel's terms are its owner's alone to set — so we set none. Indexing never
131
+ // needed ownership: the cursor, the walk, the store and the read surface are all
132
+ // node-local, and the only thing the chain has to grant us is the ABILITY TO FETCH A
133
+ // BODY, which `writePolicy = open` is exactly what grants (see [[decideRegistryRole]]).
134
+ say(` '${channel}' is owned by ${role.holder}, not by '${owner.nodeId}' — running as a READER`);
135
+ say(` writes are open, so any node holding a body will serve it to us on request.`);
136
+ say(` Nothing will be claimed or provisioned here.`);
137
+ say(` ⚠ availability is BEST EFFORT — for the owner too, since this channel carries no DA`);
138
+ say(` policy. A body nobody kept is reported as a miss; see /summary.`);
139
+ return { role: "reader", channel, member: nodeIdentity, writes: 0 };
140
+ }
141
+ // ── from here down this deployment OWNS the channel (or it is unclaimed and named for us) ──
142
+ say(` channel '${channel}' — no DA policy by design; writes open so any holder will serve`);
143
+ const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
144
+ // The ONLY acceptable DA state is none at all. `terms.da` present means a channel claimed by
145
+ // an older build, and converging it is how the estate ends up with one design rather than two.
146
+ const daOk = !terms.da;
147
+ const openOk = terms.writePolicy === "open";
148
+ if (!ownedByUs) {
149
+ // One transaction sets every term, so there is no window in which the channel is claimed
150
+ // but not yet open. `claimBody` with no daGroup/daThresholdM is not DA-managed.
151
+ const claim = buildSignedTx({
152
+ ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
153
+ body: claimBody({ ledgerId: channel, writePolicy: "open" }),
154
+ ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
155
+ });
156
+ await submitAndConfirm(node, claim, `claim '${channel}' (no DA, open)`, async () => {
157
+ const r = await ask();
158
+ return r.ok && r.terms.owners.some((o) => o.name === owner.nodeId) &&
159
+ !r.terms.da && r.terms.writePolicy === "open";
160
+ }, say, { pace });
161
+ return { role: "owner", channel, member: nodeIdentity, writes: writes + 1 };
162
+ }
163
+ // OURS ALREADY, but a term has drifted. Converge IN PLACE — never by unclaiming: a full
164
+ // unclaim drops all three policy rows and leaves the name UNOWNED between the two
165
+ // transactions, so on a shared chain anyone can take it. A second Claim is a committed no-op
166
+ // on an already-claimed ledger, so the targeted updates are the only thing that converges.
167
+ //
168
+ // ⚠️ THIS IS THE MIGRATION, and it runs itself. A channel claimed by an older build still
169
+ // carries a DA group; an empty `daGroup` in UpdateChannelDa turns the policy OFF
170
+ // (owners.proto: "da_group empty = turn DA off"), so the first boot of this build as owner
171
+ // removes it. No hand-signed transaction, and nothing to remember to do.
172
+ if (!daOk) {
173
+ say(` DA policy is '${terms.da.group}' (m=${terms.da.thresholdM}); this registry wants NONE — removing in place`);
174
+ const upd = buildSignedTx({
175
+ ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
176
+ body: updateChannelDaBody({ ledgerId: channel, daGroup: "", daThresholdM: 0 }),
177
+ ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
178
+ });
179
+ await submitAndConfirm(node, upd, `remove '${channel}' DA policy`, async () => {
180
+ const r = await ask();
181
+ return r.ok && !r.terms.da;
182
+ }, say, { pace });
183
+ writes += 1;
184
+ }
185
+ if (!openOk) {
186
+ say(` write policy is '${terms.writePolicy}', this registry needs 'open' — updating in place`);
187
+ const upd = buildSignedTx({
188
+ ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
189
+ body: updateChannelWritePolicyBody({ ledgerId: channel, writePolicy: "open" }),
190
+ ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
191
+ });
192
+ await submitAndConfirm(node, upd, `open '${channel}' to any writer`, async () => {
193
+ const r = await ask();
194
+ return r.ok && r.terms.writePolicy === "open";
195
+ }, say, { pace });
196
+ writes += 1;
197
+ }
198
+ if (writes === 0)
199
+ say(` '${channel}' already has the terms this registry needs — nothing to submit`);
200
+ return { role: "owner", channel, member: nodeIdentity, writes };
201
+ }
202
+ /**
203
+ * READ-ONLY provisioning: decide the role from the chain-agreed terms and NEVER submit, claim or
204
+ * sign. Used only by the installer's registry (`REGISTRY_READ_ONLY=1`), where no identity exists to
205
+ * sign with and the pinned operator (`REGISTRY_CHANNEL_OPERATOR`) is the only authority trusted.
206
+ */
207
+ export async function provisionRegistryChannelReadOnly(args) {
208
+ const { chainAgreedUrl, channel, nodeIdentity, operator, say } = args;
209
+ const read = await channelTerms(chainAgreedUrl, channel);
210
+ if (!read.ok)
211
+ throw new Error(`could not read the terms of '${channel}' from ${chainAgreedUrl}: ${read.why}\n` +
212
+ ` Refusing to guess — an unreadable answer is "not yet", never a licence to index unverified terms.`);
213
+ const role = decideRegistryRole({ terms: read.terms, self: operator ?? "", operator, readOnly: true });
214
+ if (role.kind === "unusable")
215
+ throw new Error(role.why);
216
+ if (role.kind === "waiting") {
217
+ say(` ⚠ ${role.why}`);
218
+ return { role: "waiting", channel, member: nodeIdentity, writes: 0 };
219
+ }
220
+ if (role.kind !== "reader")
221
+ throw new Error(`read-only registry resolved an impossible owner role — refusing to index`);
222
+ say(` '${channel}' is owned by ${role.holder} — running as a READ-ONLY reader`);
223
+ say(` nothing will be claimed, provisioned or submitted here`);
224
+ return { role: "reader", channel, member: nodeIdentity, writes: 0 };
225
+ }
@@ -0,0 +1,242 @@
1
+ import { BindingError } from "@mcsherrylabs/evolver-client/processor";
2
+ const SCHEMA = `
3
+ CREATE TABLE IF NOT EXISTS entries (
4
+ ledger_id TEXT PRIMARY KEY, announcer TEXT NOT NULL, provenance TEXT NOT NULL,
5
+ seq INTEGER NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL,
6
+ keywords TEXT NOT NULL,
7
+ detail_key TEXT, detail_sha256 TEXT, detail_bytes INTEGER,
8
+ detail_state TEXT NOT NULL DEFAULT 'ok', detail_reason TEXT,
9
+ announced_at_round INTEGER NOT NULL, verified_at_round INTEGER NOT NULL,
10
+ requested_expiry_round INTEGER NOT NULL, granted_expiry_round INTEGER NOT NULL,
11
+ state TEXT NOT NULL, tx_id TEXT NOT NULL
12
+ );
13
+ CREATE INDEX IF NOT EXISTS entries_state ON entries(state);
14
+ CREATE INDEX IF NOT EXISTS entries_kind ON entries(kind);
15
+ CREATE INDEX IF NOT EXISTS entries_announced ON entries(announced_at_round);
16
+ CREATE TABLE IF NOT EXISTS rejections (
17
+ tx_id TEXT PRIMARY KEY, round INTEGER NOT NULL, sender TEXT NOT NULL,
18
+ ledger_id TEXT NOT NULL, reason TEXT NOT NULL, detail TEXT NOT NULL
19
+ );
20
+ CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
21
+ -- The VERIFIED detail document, kept here because the registry is the only thing an agent can
22
+ -- reach. Bounded by MAX_DETAIL_BYTES before it is ever stored, and keyed by the content key so
23
+ -- two channels announcing the same document hold one copy.
24
+ CREATE TABLE IF NOT EXISTS detail_docs (
25
+ detail_key TEXT PRIMARY KEY, sha256 TEXT NOT NULL, bytes BLOB NOT NULL, fetched_at INTEGER NOT NULL
26
+ );
27
+ -- A delivery whose control check could not be DECIDED, held with its body so the answer can be
28
+ -- sought again on a later walk. See defer() below for why this table has to exist.
29
+ CREATE TABLE IF NOT EXISTS deferred (
30
+ tx_id TEXT PRIMARY KEY, round INTEGER NOT NULL, idx INTEGER NOT NULL,
31
+ sender TEXT NOT NULL, ledger_id TEXT NOT NULL, body BLOB NOT NULL,
32
+ first_seen INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_why TEXT NOT NULL DEFAULT ''
33
+ );
34
+ `;
35
+ export class RegistryStore {
36
+ #db;
37
+ #channel;
38
+ #readMisses;
39
+ /** All collaborators run on the supplied database owner's serialized execution domain.
40
+ * `readMisses` concerns THIS index's channel and must propagate failures, never return a
41
+ * fabricated zero. Keeping it explicit prevents dependence on generic storage table names. */
42
+ constructor(db, channel, readMisses) {
43
+ this.#db = db;
44
+ this.#channel = channel;
45
+ this.#readMisses = readMisses;
46
+ this.#db.exec(SCHEMA);
47
+ }
48
+ /**
49
+ * Bind this index to its channel, or REFUSE. The shared owner validates the chain/node binding
50
+ * before installing the module; this store owns only the registry-channel half.
51
+ *
52
+ * Every row here describes an announcement on ONE channel, verified against that channel's
53
+ * control. Reading them as the index of a different channel would serve assertions nobody
54
+ * made there. A first bind records; a mismatch throws.
55
+ */
56
+ bindChannel() {
57
+ const chan = this.#meta("request_channel");
58
+ if (chan === null) {
59
+ this.#setMeta("request_channel", this.#channel);
60
+ return;
61
+ }
62
+ if (chan !== this.#channel)
63
+ throw new BindingError(`this store's index was built while draining '${chan}', but the configuration says ` +
64
+ `'${this.#channel}'. Its entries describe the other channel; serving them as this one's ` +
65
+ `would be wrong. Point PROCESSOR_DB at the right store, or set REGISTRY_CHANNEL back.`);
66
+ }
67
+ #meta(k) {
68
+ const r = this.#db.prepare("SELECT v FROM meta WHERE k = ?").get(k);
69
+ return r?.v ?? null;
70
+ }
71
+ #setMeta(k, v) {
72
+ this.#db.prepare("INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(k, v);
73
+ }
74
+ /** Replace any live entry for this channel (FR-008, FR-013). One statement, so a refresh
75
+ * cannot briefly leave two rows or none. */
76
+ put(e) {
77
+ this.#db.prepare(`
78
+ INSERT INTO entries (ledger_id, announcer, provenance, seq, kind, title, summary, keywords,
79
+ detail_key, detail_sha256, detail_bytes, detail_state, detail_reason,
80
+ announced_at_round, verified_at_round, requested_expiry_round, granted_expiry_round, state, tx_id)
81
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
82
+ ON CONFLICT(ledger_id) DO UPDATE SET
83
+ announcer=excluded.announcer, provenance=excluded.provenance, seq=excluded.seq,
84
+ kind=excluded.kind, title=excluded.title, summary=excluded.summary, keywords=excluded.keywords,
85
+ detail_key=excluded.detail_key, detail_sha256=excluded.detail_sha256, detail_bytes=excluded.detail_bytes,
86
+ detail_state=excluded.detail_state, detail_reason=excluded.detail_reason,
87
+ announced_at_round=excluded.announced_at_round, verified_at_round=excluded.verified_at_round,
88
+ requested_expiry_round=excluded.requested_expiry_round, granted_expiry_round=excluded.granted_expiry_round,
89
+ state=excluded.state, tx_id=excluded.tx_id`).run(e.ledgerId, e.announcer, e.provenance, e.seq, e.kind, e.title, e.summary, JSON.stringify(e.keywords), e.detail?.key ?? null, e.detail?.sha256 ?? null, e.detail?.bytes ?? null, e.detail?.state ?? "ok", e.detail?.reason ?? null, Number(e.announcedAtRound), Number(e.verifiedAtRound), Number(e.requestedExpiryRound), Number(e.grantedExpiryRound), e.state, e.txId);
90
+ }
91
+ get(ledgerId, deny = new Set()) {
92
+ const r = this.#db.prepare("SELECT * FROM entries WHERE ledger_id = ?").get(ledgerId);
93
+ if (!r)
94
+ return null;
95
+ const e = row(r);
96
+ return deny.has(e.announcer) ? null : e;
97
+ }
98
+ /**
99
+ * Search. Ordering is `announced_at_round` ASC — deterministic, and not influenced by
100
+ * anything an announcer supplies (FR-022): the only way to rank higher is to have been there
101
+ * longer, which cannot be bought with keywords.
102
+ */
103
+ search(q, deny = new Set()) {
104
+ const all = this.#db.prepare("SELECT * FROM entries ORDER BY announced_at_round ASC, ledger_id ASC")
105
+ .all().map(row).filter((e) => !deny.has(e.announcer));
106
+ const terms = (q.terms ?? []).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
107
+ const match = (e) => (q.kind === undefined || e.kind === q.kind) &&
108
+ (terms.length === 0 || terms.some((t) => e.keywords.includes(t) || e.title.toLowerCase().includes(t) ||
109
+ e.summary.toLowerCase().includes(t) || e.ledgerId.toLowerCase().includes(t)));
110
+ const hits = all.filter(match);
111
+ return { total: hits.length, entries: hits.slice(q.offset, q.offset + q.limit) };
112
+ }
113
+ summary(deny = new Set()) {
114
+ const all = this.#db.prepare("SELECT * FROM entries").all()
115
+ .map(row).filter((e) => !deny.has(e.announcer));
116
+ const entries = { live: 0, expired: 0 };
117
+ const kinds = {};
118
+ const kw = new Map();
119
+ for (const e of all) {
120
+ entries[e.state] += 1;
121
+ if (e.state === "live") {
122
+ kinds[e.kind] = (kinds[e.kind] ?? 0) + 1;
123
+ for (const k of e.keywords)
124
+ kw.set(k, (kw.get(k) ?? 0) + 1);
125
+ }
126
+ }
127
+ const keywords = [...kw.entries()].map(([keyword, live]) => ({ keyword, live }))
128
+ .sort((a, b) => b.live - a.live || a.keyword.localeCompare(b.keyword));
129
+ return { entries, kinds, keywords, misses: this.misses(), deferred: this.deferredCount() };
130
+ }
131
+ /** Mark leases that the tip has passed (FR-012). Kept, never deleted: an agent that reads
132
+ * "expired" learns more than one that reads nothing. */
133
+ sweep(tipRound) {
134
+ const r = this.#db.prepare("UPDATE entries SET state = 'expired' WHERE state = 'live' AND granted_expiry_round < ?")
135
+ .run(Number(tipRound));
136
+ return Number(r.changes);
137
+ }
138
+ /** Keep a VERIFIED detail document. The caller has already checked its digest and its size —
139
+ * this stores bytes, it does not vouch for them. */
140
+ putDetailDoc(key, sha256, bytes) {
141
+ this.#db.prepare(`INSERT INTO detail_docs (detail_key, sha256, bytes, fetched_at) VALUES (?,?,?,?)
142
+ ON CONFLICT(detail_key) DO UPDATE SET sha256=excluded.sha256, bytes=excluded.bytes,
143
+ fetched_at=excluded.fetched_at`).run(key, sha256, bytes, Date.now());
144
+ }
145
+ detailDoc(key) {
146
+ const r = this.#db.prepare("SELECT sha256, bytes FROM detail_docs WHERE detail_key = ?").get(key);
147
+ if (!r || r.bytes === undefined || r.sha256 === undefined)
148
+ return null;
149
+ return { sha256: String(r.sha256), bytes: new Uint8Array(r.bytes) };
150
+ }
151
+ reject(r) {
152
+ this.#db.prepare(`INSERT INTO rejections (tx_id, round, sender, ledger_id, reason, detail)
153
+ VALUES (?,?,?,?,?,?) ON CONFLICT(tx_id) DO NOTHING`)
154
+ .run(r.txId, Number(r.round), r.sender, r.ledgerId, r.reason, r.detail.slice(0, 500));
155
+ }
156
+ rejections(limit = 50) {
157
+ return this.#db.prepare("SELECT * FROM rejections ORDER BY round DESC LIMIT ?").all(limit)
158
+ .map((r) => ({
159
+ txId: String(r.tx_id), round: BigInt(Number(r.round)), sender: String(r.sender),
160
+ ledgerId: String(r.ledger_id), reason: String(r.reason), detail: String(r.detail),
161
+ }));
162
+ }
163
+ /**
164
+ * HOLD a delivery whose control check could not be decided, WITH ITS BODY.
165
+ *
166
+ * ⚠️ THE ONLY UNRECOVERABLE THING IN THIS SYSTEM IS THE BODY, and the undetermined path used
167
+ * to throw one away while holding it. `/api/channel/next` is forward-only and no route fetches
168
+ * a body by txId (evolver#87), so once the walk has passed a position the body exists in
169
+ * exactly one place: this process's memory. Recording a miss and dropping it meant a chain
170
+ * read that was briefly unavailable cost an announcement its entry until its owner refreshed
171
+ * — for a reason that had nothing to do with the announcement.
172
+ *
173
+ * So it is written down instead, and retried on every later walk. The walk still never
174
+ * blocks: the single consumer moves on immediately, which is the property that mattered.
175
+ */
176
+ defer(d) {
177
+ this.#db.prepare(`INSERT INTO deferred (tx_id, round, idx, sender, ledger_id, body, first_seen, attempts, last_why)
178
+ VALUES (?,?,?,?,?,?,?,1,?)
179
+ ON CONFLICT(tx_id) DO UPDATE SET attempts = attempts + 1, last_why = excluded.last_why`)
180
+ .run(d.txId, Number(d.round), d.index, d.sender, d.ledgerId, d.body, Date.now(), d.why.slice(0, 300));
181
+ }
182
+ /** Oldest first: a delivery held longer has waited longer, and order of arrival is the only
183
+ * ranking this system recognises. */
184
+ deferredAll(limit = 100) {
185
+ return this.#db.prepare("SELECT * FROM deferred ORDER BY round ASC, idx ASC LIMIT ?")
186
+ .all(limit).map((r) => ({
187
+ txId: String(r.tx_id), round: BigInt(Number(r.round)), index: Number(r.idx),
188
+ sender: String(r.sender), ledgerId: String(r.ledger_id),
189
+ body: new Uint8Array(r.body), attempts: Number(r.attempts),
190
+ why: String(r.last_why),
191
+ }));
192
+ }
193
+ undefer(txId) {
194
+ this.#db.prepare("DELETE FROM deferred WHERE tx_id = ?").run(txId);
195
+ }
196
+ deferredCount() {
197
+ const r = this.#db.prepare("SELECT COUNT(*) AS n FROM deferred").get();
198
+ return Number(r.n);
199
+ }
200
+ /** Announcements whose bodies never arrived, not authorization rejections, failed reads,
201
+ * module errors, or eviction of a body that was already indexed. The shared owner's outcome
202
+ * projection defines these facts; this store does not know its generic inbox schema. */
203
+ misses() { return this.#readMisses(); }
204
+ }
205
+ function row(r) {
206
+ const detail = r.detail_key
207
+ ? {
208
+ key: String(r.detail_key), sha256: String(r.detail_sha256), bytes: Number(r.detail_bytes),
209
+ state: String(r.detail_state),
210
+ ...(r.detail_reason ? { reason: String(r.detail_reason) } : {}),
211
+ }
212
+ : undefined;
213
+ return {
214
+ ledgerId: String(r.ledger_id), announcer: String(r.announcer),
215
+ provenance: String(r.provenance), seq: Number(r.seq),
216
+ kind: String(r.kind), title: String(r.title), summary: String(r.summary),
217
+ keywords: JSON.parse(String(r.keywords)),
218
+ ...(detail ? { detail } : {}),
219
+ announcedAtRound: BigInt(Number(r.announced_at_round)),
220
+ verifiedAtRound: BigInt(Number(r.verified_at_round)),
221
+ requestedExpiryRound: BigInt(Number(r.requested_expiry_round)),
222
+ grantedExpiryRound: BigInt(Number(r.granted_expiry_round)),
223
+ state: String(r.state), txId: String(r.tx_id),
224
+ };
225
+ }
226
+ /** Build an entry from an accepted announcement — the one place the two shapes meet, so a new
227
+ * announcement field cannot reach the index without passing through here. */
228
+ export function entryFrom(args) {
229
+ const { a } = args;
230
+ return {
231
+ ledgerId: a.ledgerId, announcer: args.announcer, provenance: args.provenance, seq: a.seq,
232
+ kind: a.kind, title: a.title, summary: a.summary, keywords: a.keywords,
233
+ ...(a.detail ? { detail: {
234
+ ...a.detail, state: args.detailState ?? "ok",
235
+ ...(args.detailReason ? { reason: args.detailReason } : {}),
236
+ } } : {}),
237
+ announcedAtRound: args.announcedAtRound, verifiedAtRound: args.verifiedAtRound,
238
+ requestedExpiryRound: args.lease.requestedExpiryRound,
239
+ grantedExpiryRound: args.lease.grantedExpiryRound,
240
+ state: "live", txId: args.txId,
241
+ };
242
+ }
@@ -0,0 +1,9 @@
1
+ /** One pass. Separated from the timer so it can be driven directly in a test and once at boot,
2
+ * where waiting a whole interval to retire an already-stale entry would be strange. */
3
+ export function sweepOnce(store, tipRound, say) {
4
+ const n = store.sweep(tipRound);
5
+ if (n > 0)
6
+ say(` ${n} lease(s) expired as of round ${tipRound}`);
7
+ return n;
8
+ }
9
+ // Scheduled by the shared worker's serialized afterWalk hook, including quiet walks.
@@ -0,0 +1,87 @@
1
+ // Registry adapter for the shared SDK's ONE storage worker. No listener, polling, setup,
2
+ // subscription mutation, signing material, second database or independent DB timer lives here.
3
+ // The parent provisions using RuntimeModule.setup({pace,readModule}), after recovery listeners.
4
+ import { position, validateChannelId, } from "@mcsherrylabs/evolver-client/processor";
5
+ import { registryDependencies } from "./dependencies.js";
6
+ import { createRegistryHandler } from "./drainer.js";
7
+ import { registryRoutes } from "./http.js";
8
+ import { RegistryStore } from "./store.js";
9
+ import { sweepOnce } from "./sweep.js";
10
+ export const REGISTRY_MODULE_NAME = "registry";
11
+ function readRequest(request) {
12
+ if (!request || typeof request !== "object")
13
+ throw new Error("invalid registry read projection");
14
+ const r = request;
15
+ if (typeof r.path !== "string" || r.path.length > 4096 || !r.path.startsWith("/") || r.path.startsWith("//") ||
16
+ typeof r.tip !== "string" || typeof r.walked !== "string" ||
17
+ (r.deny !== undefined && (!Array.isArray(r.deny) || r.deny.length > 1024 || r.deny.some(id => typeof id !== "string"))))
18
+ throw new Error("invalid registry read projection");
19
+ position(r.tip, "0");
20
+ position(r.walked, "0");
21
+ return r;
22
+ }
23
+ /** Factory with explicit collaborators for owned tests and trusted application composition.
24
+ * Only the default factory below is selected by the production ModuleReference. */
25
+ export function createRegistryWorkerModule(options, deps, clock) {
26
+ validateChannelId(options.channel);
27
+ if (deps.channel !== options.channel)
28
+ throw new Error("registry handler channel differs from module claim");
29
+ return {
30
+ name: REGISTRY_MODULE_NAME,
31
+ install(context) {
32
+ const store = new RegistryStore(context.db, options.channel, () => {
33
+ const summary = context.outcomeSummary(options.channel);
34
+ const total = Number(summary.unavailable);
35
+ if (!/^(0|[1-9][0-9]*)$/.test(summary.unavailable) || !Number.isSafeInteger(total))
36
+ throw new Error("registry unavailable count is not an exact safe integer");
37
+ if (summary.lastRound !== null)
38
+ position(summary.lastRound, "0");
39
+ return { total, lastRound: summary.lastRound === null ? null : BigInt(summary.lastRound) };
40
+ });
41
+ store.bindChannel();
42
+ const handler = createRegistryHandler(store, deps, clock);
43
+ // Only serialized read projections set this view; never an independently scheduled task.
44
+ let view = {
45
+ tip: 0n, walked: 0n, deny: new Set(options.deny),
46
+ };
47
+ const routes = registryRoutes({
48
+ store, chain: options.chain, channel: options.channel,
49
+ tipRound: () => view.tip, walkedRound: () => view.walked, deny: () => view.deny,
50
+ });
51
+ return {
52
+ handlers: [[options.channel, async (delivery) => {
53
+ // SDK positions are canonical strings in the node Int range. The registry's domain
54
+ // logic uses bigint rounds and safe numeric indices internally, never on the wire.
55
+ await handler.handle({ ...delivery, round: BigInt(delivery.round), index: Number(delivery.index) });
56
+ }]],
57
+ afterWalk({ tip }) {
58
+ handler.takeAccepted();
59
+ // The owner invokes this hook, including quiet walks. No DB handle escapes to a timer.
60
+ sweepOnce(store, BigInt(tip), deps.say);
61
+ },
62
+ read(request) {
63
+ const r = readRequest(request);
64
+ view = { tip: BigInt(r.tip), walked: BigInt(r.walked), deny: new Set(r.deny ?? options.deny) };
65
+ const url = new URL(r.path, "http://registry.local");
66
+ const route = routes.find(candidate => candidate.prefix
67
+ ? url.pathname.startsWith(candidate.path) : url.pathname === candidate.path);
68
+ return route ? route.handle(url) : { status: 404, json: { error: "not_found" } };
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
74
+ /** Trusted local factory: its options are cloneable deployment settings, never remote plugin
75
+ * discovery or signing keys. Loading the module does not install it or touch the node. */
76
+ export default function registryWorkerModule(value) {
77
+ if (!value || typeof value !== "object")
78
+ throw new Error("registry worker options are required");
79
+ const o = value;
80
+ if (typeof o.chain !== "string" || typeof o.channel !== "string" || typeof o.operator !== "string" ||
81
+ typeof o.chainUrl !== "string" || (o.contentUrl !== null && typeof o.contentUrl !== "string") ||
82
+ !Number.isSafeInteger(o.maxLeaseRounds) || o.maxLeaseRounds < 1 ||
83
+ !Array.isArray(o.deny) || o.deny.length > 1024 || o.deny.some(id => typeof id !== "string"))
84
+ throw new Error("invalid registry worker options");
85
+ const options = o;
86
+ return createRegistryWorkerModule(options, registryDependencies(options, message => console.log(message)));
87
+ }