@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,28 @@
1
+ // Registry-specific bounded network collaborators, independent of processor lifecycle and env.
2
+ // The production shared worker uses this one composition; there is no transitional collector.
3
+ import { channelTerms, groupMembers } from "../announcements.js";
4
+ import { fetchDetail } from "./detail.js";
5
+ import { decideControl } from "./ownership.js";
6
+ export function registryDependencies(options, say) {
7
+ return {
8
+ channel: options.channel, operator: options.operator, maxLeaseRounds: options.maxLeaseRounds,
9
+ deny: new Set(options.deny), say,
10
+ decideControl: (sender, ledgerId) => decideControl({
11
+ sender, ledgerId,
12
+ readTerms: (id) => channelTerms(options.chainUrl, id),
13
+ readGroupMembers: async (group) => {
14
+ const read = await groupMembers(options.chainUrl, group);
15
+ if (!read.known)
16
+ return { known: false, why: "group membership unreadable" };
17
+ return "absent" in read ? { known: true, members: [] } : { known: true, members: read.members };
18
+ },
19
+ }),
20
+ fetchDetail: async (detail) => {
21
+ if (options.contentUrl === null)
22
+ return { ok: false, reason: "this registry has no content endpoint configured (REGISTRY_CONTENT_URL), so the " +
23
+ "document was not fetched — the description is indexed, the document is not" };
24
+ const read = await fetchDetail({ contentUrl: options.contentUrl, detail });
25
+ return read.ok ? { ok: true, bytes: read.bytes } : { ok: false, reason: read.reason };
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,87 @@
1
+ // The detail document: fetched by the registry, read by an agent.
2
+ //
3
+ // Two bounds, for two different reasons. The DIGEST, because the announcement supplies it and
4
+ // without checking it the registry would trust the announcer twice — once for the description,
5
+ // once for whatever the content store happens to return under that key. The SIZE, because an
6
+ // agent reads this document, and because it is the guard on the registry's own fetcher.
7
+ //
8
+ // ⚠️ Two hashes exist deliberately (144): above the node's chunk threshold the content key is a
9
+ // MANIFEST hash, so a single `sha256(bytes)` could not address a chunked object at all. The
10
+ // announcement carries both; this verifies the ASSEMBLED bytes against `sha256`.
11
+ import { createHash } from "node:crypto";
12
+ import { CHAIN_TIMEOUT_MS } from "../chainfetch.js";
13
+ /** 64 KiB. An agent reads this document, so the bound protects a context as much as a disk. */
14
+ export const MAX_DETAIL_BYTES = 64 * 1024;
15
+ export async function fetchDetail(args) {
16
+ const { detail } = args;
17
+ // The ANNOUNCED size is checked first, so an over-size document costs nothing to refuse. It
18
+ // is only a claim, which is why the real bytes are checked as well below.
19
+ if (detail.bytes > MAX_DETAIL_BYTES)
20
+ return { ok: false, reason: `announced ${detail.bytes} bytes, the limit is ${MAX_DETAIL_BYTES}` };
21
+ const f = args.fetchImpl ?? fetch;
22
+ let res;
23
+ try {
24
+ res = await f(`${args.contentUrl}/content/${encodeURIComponent(detail.key)}`, { signal: AbortSignal.timeout(args.timeoutMs ?? CHAIN_TIMEOUT_MS) });
25
+ }
26
+ catch (e) {
27
+ // A reason, never a throw: the drainer must be able to mark the entry and carry on, and an
28
+ // exception here would take the whole walk down over one broken document.
29
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
30
+ }
31
+ // A 403 is the read-ACL case (142): the document exists and is not readable, which makes it
32
+ // undiscoverable rather than fatal — the entry stands, marked.
33
+ if (!res.ok)
34
+ return { ok: false, reason: `HTTP ${res.status}`.trim() };
35
+ let bytes;
36
+ try {
37
+ bytes = new Uint8Array(await res.arrayBuffer());
38
+ }
39
+ catch (e) {
40
+ return { ok: false, reason: `unreadable body: ${e instanceof Error ? e.message : String(e)}` };
41
+ }
42
+ if (bytes.byteLength > MAX_DETAIL_BYTES)
43
+ return { ok: false, reason: `body is ${bytes.byteLength} bytes, the limit is ${MAX_DETAIL_BYTES} (announced ${detail.bytes})` };
44
+ const got = createHash("sha256").update(bytes).digest("hex");
45
+ if (got !== detail.sha256)
46
+ return { ok: false, reason: `digest mismatch: announced ${detail.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…` };
47
+ return { ok: true, bytes };
48
+ }
49
+ /**
50
+ * Resolve the `detail` reference an announcement will carry, uploading the document unless the
51
+ * caller named a key for one already published elsewhere.
52
+ *
53
+ * ⚠️ THE KEY COMES FROM THE NODE, NOT FROM US. Above the node's chunk threshold it is a MANIFEST
54
+ * hash, so `sha256(bytes)` could not address the object at all — which is why an announcement
55
+ * carries both, and why the old guess was only ever right for small documents.
56
+ *
57
+ * `sha256` stays OUR computation over the bytes we read. It is the announcer's commitment, and a
58
+ * registry verifies the assembled bytes against it before indexing; deriving it from anything the
59
+ * node said would turn that check into a conversation with itself.
60
+ */
61
+ export async function publishDetail(args) {
62
+ const { bytes, contentUrl, explicitKey, publisher, say } = args;
63
+ if (bytes.byteLength > MAX_DETAIL_BYTES)
64
+ throw new Error(`the document is ${bytes.byteLength} bytes; the registry refuses a detail ` +
65
+ `document over ${MAX_DETAIL_BYTES}. Shorten it, or link to it from the summary.`);
66
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
67
+ // An explicit key means "already published, elsewhere". Honoured WITHOUT uploading — uploading
68
+ // as well would leave two objects and announce the wrong one.
69
+ if (explicitKey && explicitKey.length > 0) {
70
+ say(` --detail-key given: NOT uploading. The announcement points at '${explicitKey}', ` +
71
+ `which must already be fetchable from the registry's content endpoint.`);
72
+ return { key: explicitKey, sha256, bytes: bytes.byteLength };
73
+ }
74
+ if (contentUrl === null)
75
+ throw new Error(`a detail document needs REGISTRY_CONTENT_URL: it is the node content endpoint this ` +
76
+ `command uploads the document to, and the same one the registry later reads it back ` +
77
+ `from. Without it the announcement would commit to a document that exists nowhere — ` +
78
+ `which is what this command used to do. Point it at a content-enabled node you can ` +
79
+ `reach (e.g. http://127.0.0.1:8450), or announce without a detail document.`);
80
+ const published = await publisher().postContent(bytes);
81
+ if (published.size !== bytes.byteLength)
82
+ throw new Error(`the content endpoint stored ${published.size} bytes of a ` +
83
+ `${bytes.byteLength}-byte document — refusing to announce a key over bytes ` +
84
+ `that are not the ones read`);
85
+ say(` uploaded ${bytes.byteLength} bytes to ${contentUrl} → key ${published.key.slice(0, 12)}…`);
86
+ return { key: published.key, sha256, bytes: bytes.byteLength };
87
+ }
@@ -0,0 +1,131 @@
1
+ // Registry indexing policy, invoked by the processor's ONE ordered walk (FR-029).
2
+ // This code never polls the cursor; the supplied store stays on that processor's database owner.
3
+ //
4
+ // ⚠️ Two platform facts shape every decision here, and both were verified in the node source:
5
+ //
6
+ // The cursor is SINGLE-CONSUMER and FORWARD-ONLY, and no route fetches a body by txId
7
+ // (evolver#87). So a position walked past is unrecoverable — which makes a REJECTION
8
+ // terminal in the strongest sense, and makes "reject on a failed read" a way of losing
9
+ // announcements permanently for a transient 503.
10
+ //
11
+ // One cursor serves EVERY channel the node declares, and consumers filter by ledger id
12
+ // afterwards. So this drainer sees other channels' traffic and must ignore it, and no other
13
+ // component in this process may poll (app.ts chooses between the registry and the demo).
14
+ import { parseAnnouncement } from "./announcement.js";
15
+ import { grantLease } from "./lease.js";
16
+ import { entryFrom } from "./store.js";
17
+ /**
18
+ * Process one delivery. Pure of transport: the caller owns the cursor and hands deliveries in,
19
+ * which is what lets `test/drainer-checks.ts` arrange a missing body or an unreadable ownership
20
+ * check without a chain.
21
+ */
22
+ export async function processDelivery(store, d, deps) {
23
+ if (d.ledgerId !== deps.channel)
24
+ return { outcome: "ignored" };
25
+ const reject = (reason, detail) => {
26
+ store.reject({ txId: d.txId, round: d.round, sender: d.sender, ledgerId: d.ledgerId, reason, detail });
27
+ deps.say(` refused ${d.txId.slice(0, 12)}… from ${d.sender}: ${reason} — ${detail}`);
28
+ return { outcome: "rejected", reason };
29
+ };
30
+ // Cheapest and most terminal first, so nothing expensive runs for an announcement that
31
+ // cannot be accepted (data-model.md's validation order).
32
+ const parsed = parseAnnouncement(d.body);
33
+ if (!parsed.ok)
34
+ return reject(parsed.reason, parsed.detail);
35
+ const a = parsed.value;
36
+ if (deps.deny.has(d.sender))
37
+ return reject("deny-listed", `announcer '${d.sender}' is suppressed by the operator`);
38
+ // THE CHECK THE REGISTRY EXISTS FOR. Three answers, and the third is neither of the others.
39
+ const control = await deps.decideControl(d.sender, a.ledgerId);
40
+ if (control.kind === "undetermined") {
41
+ // Not recorded, because it is not a decision. Recording it as a rejection would make a
42
+ // transient failure look like a verdict, and the announcer would be told something untrue.
43
+ deps.say(` ${d.txId.slice(0, 12)}… from ${d.sender}: control undetermined (${control.why}) — will retry`);
44
+ return { outcome: "retry", why: control.why };
45
+ }
46
+ if (control.kind === "does-not-control")
47
+ return reject("not-owner", `'${d.sender}' does not control '${a.ledgerId}'` +
48
+ (control.owners.length ? ` (owners: ${control.owners.join(", ")})` : " (nobody does)"));
49
+ const lease = grantLease({
50
+ requestedExpiryRound: a.requestedExpiryRound,
51
+ // From the round the announcement was ORDERED, not the round it was read: a slow reader
52
+ // must not shorten somebody's lease.
53
+ receivedRound: d.round,
54
+ maxLeaseRounds: deps.maxLeaseRounds,
55
+ });
56
+ // The document is fetched AFTER control is settled — it is the expensive step, and there is
57
+ // no point paying for it on behalf of somebody who cannot announce this channel.
58
+ let detailState = "ok";
59
+ let detailReason;
60
+ if (a.detail) {
61
+ const got = await deps.fetchDetail(a.detail);
62
+ if (got.ok) {
63
+ // KEPT HERE, because this is the only endpoint an agent can reach. The node's content
64
+ // cache is off by default and its listener is loopback-INSIDE-the-node's-container when
65
+ // it is on, so "fetch the document yourself" was advice no reader could follow.
66
+ store.putDetailDoc(a.detail.key, a.detail.sha256, got.bytes);
67
+ }
68
+ else {
69
+ detailState = "unfetchable";
70
+ detailReason = got.reason;
71
+ // The entry STILL STANDS. "This service exists and its documentation is broken" is more
72
+ // useful to a reader than silence, and the reason travels with it (FR-016).
73
+ deps.say(` ${a.ledgerId}: detail document unusable (${got.reason}) — indexing without it`);
74
+ }
75
+ }
76
+ const provenance = d.sender === deps.operator ? "operator" : "third-party";
77
+ store.put(entryFrom({
78
+ a, announcer: d.sender, provenance, lease,
79
+ announcedAtRound: d.round, verifiedAtRound: d.round, txId: d.txId,
80
+ detailState, ...(detailReason ? { detailReason } : {}),
81
+ }));
82
+ deps.say(` indexed '${a.ledgerId}' from ${d.sender} (${provenance}, lease to round ${lease.grantedExpiryRound}` +
83
+ `${lease.clamped ? `, clamped from ${lease.requestedExpiryRound}` : ""})`);
84
+ return { outcome: "accepted", ledgerId: a.ledgerId };
85
+ }
86
+ // ── registry-specific walk constants ───────────────────────────────────────
87
+ /** How long an undetermined control check is retried INLINE before the delivery is written to
88
+ * the deferred table and the walk moves on. */
89
+ export const CONTROL_RETRY_MS = 60_000;
90
+ /** Registry-specific retry/defer policy, shared by the current composition and the shared SDK
91
+ * worker adapter. This is NOT a collector: it never polls or paces the node and never owns a
92
+ * database. Its store and every callback are run by the calling owner. */
93
+ export function createRegistryHandler(store, deps, clock = {}) {
94
+ const now = clock.now ?? (() => Date.now());
95
+ const sleep = clock.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
96
+ let accepted = 0;
97
+ return {
98
+ async handle(d) {
99
+ for (const held of store.deferredAll()) {
100
+ // Only a typed undetermined control outcome is retryable. Unexpected collaborator or
101
+ // SQLite errors must reject THIS handler too: the held body remains durable and the
102
+ // shared owner retains the current original without advancing to the next position.
103
+ const out = await processDelivery(store, held, deps);
104
+ if (out.outcome === "retry") {
105
+ store.defer({ ...held, why: out.why });
106
+ continue;
107
+ }
108
+ store.undefer(held.txId);
109
+ if (out.outcome === "accepted")
110
+ accepted += 1;
111
+ deps.say(` held ${held.txId.slice(0, 12)}… settled after ${held.attempts} attempt(s): ${out.outcome}`);
112
+ }
113
+ const until = now() + CONTROL_RETRY_MS;
114
+ let out = await processDelivery(store, d, deps);
115
+ while (out.outcome === "retry" && now() < until) {
116
+ await sleep(1_000);
117
+ out = await processDelivery(store, d, deps);
118
+ }
119
+ if (out.outcome === "retry") {
120
+ store.defer({
121
+ txId: d.txId || `${d.round}:${d.index}`, round: d.round, index: d.index,
122
+ sender: d.sender, ledgerId: d.ledgerId, body: d.body, why: out.why,
123
+ });
124
+ deps.say(` … control of '${d.txId.slice(0, 12)}…' still undetermined after ${CONTROL_RETRY_MS}ms (${out.why}) — HELD with its body, retried on the next walk`);
125
+ }
126
+ if (out.outcome === "accepted")
127
+ accepted += 1;
128
+ },
129
+ takeAccepted() { const n = accepted; accepted = 0; return n; },
130
+ };
131
+ }
@@ -0,0 +1,194 @@
1
+ /** Truncated deliberately — the histogram is the one part of the summary that would otherwise
2
+ * grow with the registry, and SC-001's budget is what it must not exceed. */
3
+ export const SUMMARY_KEYWORDS = 40;
4
+ /**
5
+ * TEN, and DERIVED from SC-001's budget rather than chosen.
6
+ *
7
+ * A worst-case list row — a 60-character title, a 200-character summary, eight keywords and a
8
+ * detail key with its 64-hex digest — measures about 750 bytes. Twenty of those came to
9
+ * ~15 KB against an 8 KB budget, so the DEFAULT moved rather than the budget: the budget IS
10
+ * the requirement (do not flood an agent's context) and the limit is only a knob. `total`
11
+ * tells a reader what it did not see, and a caller who wants more asks for it.
12
+ */
13
+ export const DEFAULT_LIMIT = 10;
14
+ export const MAX_LIMIT = 100;
15
+ const DISCLAIMER = "inclusion is not endorsement";
16
+ /** The full record — what `/services/<ledgerId>` returns. */
17
+ const wireFull = (e) => ({
18
+ ledgerId: e.ledgerId,
19
+ kind: e.kind,
20
+ title: e.title,
21
+ summary: e.summary,
22
+ keywords: e.keywords,
23
+ announcer: e.announcer,
24
+ provenance: e.provenance,
25
+ state: e.state,
26
+ ...(e.detail ? { detail: {
27
+ key: e.detail.key, sha256: e.detail.sha256, bytes: String(e.detail.bytes),
28
+ state: e.detail.state, ...(e.detail.reason ? { reason: e.detail.reason } : {}),
29
+ } } : {}),
30
+ announcedAtRound: String(e.announcedAtRound),
31
+ verifiedAtRound: String(e.verifiedAtRound),
32
+ grantedExpiryRound: String(e.grantedExpiryRound),
33
+ requestedExpiryRound: String(e.requestedExpiryRound),
34
+ txId: e.txId,
35
+ });
36
+ /**
37
+ * The LIST projection — leaner, because a list is for CHOOSING.
38
+ *
39
+ * `txId`, `requestedExpiryRound` and `verifiedAtRound` are provenance a reader wants about a
40
+ * service it has already picked, and paying for them 20 times to answer "which of these?" is
41
+ * exactly the context flooding this feature exists to prevent. The detail key AND its digest
42
+ * stay: an agent must be able to fetch and verify the document without a fourth request
43
+ * (SC-002).
44
+ */
45
+ const wireList = (e) => ({
46
+ ledgerId: e.ledgerId,
47
+ kind: e.kind,
48
+ title: e.title,
49
+ summary: e.summary,
50
+ keywords: e.keywords,
51
+ announcer: e.announcer,
52
+ provenance: e.provenance,
53
+ state: e.state,
54
+ ...(e.detail ? { detail: {
55
+ key: e.detail.key, sha256: e.detail.sha256, bytes: String(e.detail.bytes), state: e.detail.state,
56
+ } } : {}),
57
+ announcedAtRound: String(e.announcedAtRound),
58
+ grantedExpiryRound: String(e.grantedExpiryRound),
59
+ });
60
+ export function registryRoutes(args) {
61
+ const { store, chain, channel, tipRound, walkedRound, deny } = args;
62
+ const json = (status, body) => ({ status, json: body });
63
+ // ⚠️ `/summary`, NOT `/services/summary`, and the move is the fix for a real ambiguity: a
64
+ // ledger id may legitimately be the single word `summary` (the node's id grammar allows
65
+ // it), and a route matched before `/services/<id>` made that channel's entry unreachable
66
+ // while quietly answering a different question. A sibling of `/rejections` has no such
67
+ // overlap. The old path is GONE rather than aliased — an alias would shadow the same name.
68
+ const summary = {
69
+ path: "/summary",
70
+ handle: () => {
71
+ const s = store.summary(deny());
72
+ return json(200, {
73
+ entries: { live: String(s.entries.live), expired: String(s.entries.expired) },
74
+ kinds: Object.fromEntries(Object.entries(s.kinds).map(([k, v]) => [k, String(v)])),
75
+ keywords: s.keywords.slice(0, SUMMARY_KEYWORDS).map((k) => ({ keyword: k.keyword, live: String(k.live) })),
76
+ keywordsTruncated: s.keywords.length > SUMMARY_KEYWORDS,
77
+ // Under research D1 this is always zero. A non-zero value means an announcement was
78
+ // ordered and its body never reached this registry — the readability guarantee
79
+ // violated — so it is published here rather than left in a log (SC-004).
80
+ misses: { total: String(s.misses.total), lastRound: s.misses.lastRound === null ? null : String(s.misses.lastRound) },
81
+ // Not a loss — a delivery whose body this registry still holds while it retries an
82
+ // ownership check it could not complete. Zero in steady state; a number that persists
83
+ // means the chain reads behind `decideControl` are failing.
84
+ deferred: String(s.deferred),
85
+ chain,
86
+ channel,
87
+ // ⚠️ AS OF THE WALK, not the tip. An index answers only for what has been walked past:
88
+ // stalled below an announcement, it holds zero entries and zero misses, and stamping that
89
+ // with the node's tip made "not walked yet" read exactly like "walked, and empty".
90
+ // `tipRound` beside it makes the gap a number a reader can see.
91
+ asOfRound: String(walkedRound()),
92
+ tipRound: String(tipRound()),
93
+ disclaimer: DISCLAIMER,
94
+ });
95
+ },
96
+ };
97
+ const services = {
98
+ path: "/services",
99
+ handle: (url) => {
100
+ const rawLimit = url.searchParams.get("limit");
101
+ // REFUSED, not coerced. A caller that asked for something impossible has a bug, and
102
+ // silently answering a different question hides it.
103
+ // CLAMPED ABOVE, REFUSED BELOW — and the asymmetry is deliberate, matching the lease
104
+ // decision (research D4): asking for more than the operator allows is a reasonable
105
+ // request answered with what is allowed, whereas `limit=0` or `limit=abc` cannot be
106
+ // answered at all and silently substituting 20 would hide the caller's bug.
107
+ let limit = DEFAULT_LIMIT;
108
+ if (rawLimit !== null) {
109
+ const n = Number(rawLimit);
110
+ if (!Number.isInteger(n) || n < 1)
111
+ return json(400, { error: "bad_request", message: `limit must be a positive integer (it is clamped to ${MAX_LIMIT})` });
112
+ limit = Math.min(n, MAX_LIMIT);
113
+ }
114
+ const rawOffset = url.searchParams.get("offset");
115
+ let offset = 0;
116
+ if (rawOffset !== null) {
117
+ const n = Number(rawOffset);
118
+ if (!Number.isInteger(n) || n < 0)
119
+ return json(400, { error: "bad_request", message: "offset must be a non-negative integer" });
120
+ offset = n;
121
+ }
122
+ const q = url.searchParams.get("q");
123
+ const kind = url.searchParams.get("kind") ?? undefined;
124
+ const r = store.search({
125
+ terms: q ? q.split(/[\s,]+/) : [], ...(kind ? { kind: kind.trim().toLowerCase() } : {}),
126
+ limit, offset,
127
+ }, deny());
128
+ return json(200, {
129
+ total: String(r.total), limit: String(limit), offset: String(offset),
130
+ services: r.entries.map(wireList), disclaimer: DISCLAIMER,
131
+ });
132
+ },
133
+ };
134
+ const service = {
135
+ path: "/services/",
136
+ prefix: true,
137
+ handle: (url) => {
138
+ // THE DETAIL DOCUMENT, served by the registry itself.
139
+ //
140
+ // Because there is nowhere else to get it. `detail.key` is a content-cache key and the
141
+ // node's content cache is off by default; when it is on, its route is bound to 127.0.0.1
142
+ // INSIDE the node's container. So "fetch it and verify it yourself" was advice no agent
143
+ // could act on. The registry has already fetched the bytes and checked the announced
144
+ // digest over them, so what it serves is what the announcer committed to — and the digest
145
+ // travels in a header so a reader can check the same thing without trusting this hop.
146
+ const detailMatch = /^\/services\/(.+)\/detail$/.exec(url.pathname);
147
+ if (detailMatch) {
148
+ const id = decodeURIComponent(detailMatch[1]);
149
+ const e = store.get(id, deny());
150
+ if (!e || !e.detail)
151
+ return json(404, { error: "not_found", message: "no detail document is indexed for that channel" });
152
+ if (e.detail.state !== "ok")
153
+ return json(409, { error: "unfetchable", message: e.detail.reason ?? "the document could not be fetched",
154
+ key: e.detail.key, sha256: e.detail.sha256 });
155
+ const doc = store.detailDoc(e.detail.key);
156
+ if (!doc)
157
+ return json(404, { error: "not_found", message: "the document was verified but is no longer held" });
158
+ // Served as bytes, with the digest stated. NOT as JSON: an agent asked to verify a
159
+ // sha256 must receive exactly the bytes that were hashed.
160
+ return {
161
+ status: 200,
162
+ bytes: doc.bytes,
163
+ headers: {
164
+ "content-type": "text/plain; charset=utf-8",
165
+ "content-length": doc.bytes.byteLength,
166
+ "x-evolver-sha256": doc.sha256,
167
+ "x-evolver-announcer": e.announcer,
168
+ "x-evolver-provenance": e.provenance,
169
+ },
170
+ };
171
+ }
172
+ const id = decodeURIComponent(url.pathname.slice("/services/".length));
173
+ const e = store.get(id, deny());
174
+ // 404 means "nobody has announced it", NOT "the channel does not exist" — the registry
175
+ // knows nothing about channels nobody described, and must not imply otherwise.
176
+ if (!e)
177
+ return json(404, { error: "not_found", message: "no entry is indexed for that channel" });
178
+ return json(200, { ...wireFull(e), disclaimer: DISCLAIMER });
179
+ },
180
+ };
181
+ const rejections = {
182
+ path: "/rejections",
183
+ // Deliberately NOT on the agent's search path: a different reader (the announcer, the
184
+ // operator) with a different need (FR-005).
185
+ handle: () => json(200, {
186
+ rejections: store.rejections().map((r) => ({
187
+ txId: r.txId, round: String(r.round), sender: r.sender,
188
+ ledgerId: r.ledgerId, reason: r.reason, detail: r.detail,
189
+ })),
190
+ disclaimer: DISCLAIMER,
191
+ }),
192
+ };
193
+ return [summary, services, service, rejections];
194
+ }
@@ -0,0 +1,52 @@
1
+ // Application composition only. All generic cursor/storage/config/control ownership is in
2
+ // the SDK. Signing keys and chain provisioning remain in this parent, never worker options.
3
+ import { isValidNodeId } from "@mcsherrylabs/evolver-client";
4
+ import { registryChannel, registryChannelOperator, registryContentUrl, registryDenyList, registryMaxLeaseRounds, } from "../env.js";
5
+ import { provisionRegistryChannel } from "./provision.js";
6
+ import { REGISTRY_MODULE_NAME } from "./worker-module.js";
7
+ export function registryModule(args) {
8
+ if (!isValidNodeId(args.operator))
9
+ throw new Error("invalid fixed registry operator identity");
10
+ const channel = registryChannel();
11
+ const options = {
12
+ chain: args.chain, channel, operator: args.operator,
13
+ maxLeaseRounds: registryMaxLeaseRounds(), deny: [...registryDenyList()],
14
+ chainUrl: args.chainUrl, contentUrl: registryContentUrl(),
15
+ };
16
+ return {
17
+ // Production starts emitted JS. No tsx worker remapping, test factory or remote discovery.
18
+ worker: { url: new URL("./worker-module.js", import.meta.url).href, options },
19
+ lifecycle: {
20
+ name: REGISTRY_MODULE_NAME, channels: [channel],
21
+ async setup(context) {
22
+ await args.beforeProvision?.();
23
+ const { sender, signer } = await args.identity();
24
+ if (sender.nodeId !== args.operator)
25
+ throw new Error("registry signing identity differs from fixed worker operator");
26
+ const provisioned = await provisionRegistryChannel({
27
+ node: args.node, chainUrl: args.chainUrl, chainAgreedUrl: args.chainAgreedUrl,
28
+ channel, owner: sender, signer, nodeIdentity: args.nodeIdentity,
29
+ say: args.say, operator: registryChannelOperator(),
30
+ pace: async () => { await context.pace(); },
31
+ });
32
+ args.say(` registry ${provisioned.role}: ${channel}`);
33
+ },
34
+ },
35
+ };
36
+ }
37
+ /** HTTP is a parent-side bridge; the actual routes/SQLite reads run serialized in the worker.
38
+ * A failed tip/cursor/module read propagates to HTTP 500, never a fabricated empty index. */
39
+ export function registryReadRoutes(runtime, tip) {
40
+ const handle = async (url) => {
41
+ const currentTip = await tip();
42
+ const cursor = await runtime.store.cursor();
43
+ const walked = BigInt(cursor.round) > 0n ? String(BigInt(cursor.round) - 1n) : "0";
44
+ return await runtime.store.readModule(REGISTRY_MODULE_NAME, {
45
+ path: `${url.pathname}${url.search}`, tip: currentTip, walked, deny: [...registryDenyList()],
46
+ });
47
+ };
48
+ return [
49
+ { path: "/summary", handle }, { path: "/services", handle },
50
+ { path: "/services/", prefix: true, handle }, { path: "/rejections", handle },
51
+ ];
52
+ }
@@ -0,0 +1,35 @@
1
+ // Leases, in ROUNDS.
2
+ //
3
+ // ⚠️ Never wall-clock. Devnet commits on a ~600 ms floor when busy and a 300-second idle
4
+ // heartbeat when quiescent, so "an hour" is somewhere between 6,000 and 12 rounds on the SAME
5
+ // chain. A lease that means one thing under load and another at rest is not a lease.
6
+ //
7
+ // Pure: the ceiling arrives as an argument, so the rules are driveable without the environment.
8
+ /**
9
+ * Grant a lease: DHCP semantics.
10
+ *
11
+ * Clamped rather than refused, deliberately (research D4). Refusing punishes a caller for
12
+ * asking for more than the operator allows, and it makes the operation non-idempotent — an
13
+ * announcer would have to discover the ceiling by trial. Clamping answers the question.
14
+ */
15
+ export function grantLease(req) {
16
+ const ceiling = req.receivedRound + BigInt(req.maxLeaseRounds);
17
+ // A request in the PAST is floored at the round of receipt rather than granted as asked: an
18
+ // entry that is expired before it is written is not something an announcer can have meant,
19
+ // and it would make `state` flip on the very first sweep.
20
+ const floor = req.receivedRound;
21
+ const granted = req.requestedExpiryRound > ceiling ? ceiling
22
+ : req.requestedExpiryRound < floor ? floor
23
+ : req.requestedExpiryRound;
24
+ return {
25
+ requestedExpiryRound: req.requestedExpiryRound,
26
+ grantedExpiryRound: granted,
27
+ clamped: req.requestedExpiryRound > ceiling,
28
+ };
29
+ }
30
+ /** A lease INCLUDES its granted round; it is expired only once the tip has passed it. The
31
+ * boundary is stated here rather than at each call site because an off-by-one would silently
32
+ * retire entries a round early, which reads as a chain problem rather than an arithmetic one. */
33
+ export function isExpired(grantedExpiryRound, tipRound) {
34
+ return tipRound > grantedExpiryRound;
35
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve control, asking for as little as possible: the group read happens only when an
3
+ * owner is actually a group, so the ordinary individually-owned case costs one request.
4
+ */
5
+ export async function decideControl(q) {
6
+ const read = await q.readTerms(q.ledgerId);
7
+ if (!read.ok)
8
+ return { kind: "undetermined", why: `terms unreadable: ${read.why}` };
9
+ const owners = read.terms.owners ?? [];
10
+ // An identity owner is decided without a second read.
11
+ for (const o of owners) {
12
+ if (o.kind === "I" && o.name === q.sender)
13
+ return { kind: "controls", via: "identity", owner: o.name };
14
+ }
15
+ // A group owner needs membership. ⚠️ An unreadable membership read is undetermined for the
16
+ // same reason an unreadable terms read is: rejecting is TERMINAL, and the cursor has already
17
+ // moved, so a blip would discard an announcement permanently (research A9).
18
+ for (const o of owners) {
19
+ if (o.kind !== "G")
20
+ continue;
21
+ const m = await q.readGroupMembers(o.name);
22
+ if (!m.known)
23
+ return { kind: "undetermined", why: `members of group '${o.name}' unreadable: ${m.why}` };
24
+ if (m.members.includes(q.sender))
25
+ return { kind: "controls", via: "group", owner: o.name };
26
+ }
27
+ // Nobody owns it, or somebody else does. Both are the same terminal answer — and note an
28
+ // UNCLAIMED channel lands here, because the route answers 200 with `owners: []` for a
29
+ // channel that has never existed (research A2), so "unclaimed" is not a special case.
30
+ return { kind: "does-not-control", owners: owners.map((o) => o.name) };
31
+ }