@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4

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 (72) hide show
  1. package/CONFORMANCE.md +1 -1
  2. package/NOTICE +4 -7
  3. package/PROVENANCE.json +12 -17
  4. package/README.md +103 -12
  5. package/bin/agent-commons.mjs +8 -3
  6. package/examples/Dockerfile +1 -1
  7. package/package.json +5 -2
  8. package/src/carrier.mjs +7 -2
  9. package/src/channels.mjs +1 -1
  10. package/src/config.mjs +2 -1
  11. package/src/continuity.mjs +14 -7
  12. package/src/governance-scope.mjs +1 -1
  13. package/src/governance.mjs +4 -2
  14. package/src/index.d.ts +3 -1
  15. package/src/pillar.mjs +2 -2
  16. package/src/profiles.mjs +2 -1
  17. package/src/relay/auth.mjs +294 -0
  18. package/src/relay/board.mjs +223 -0
  19. package/src/relay/claims.mjs +25 -0
  20. package/src/relay/cli.mjs +49 -0
  21. package/src/relay/client.mjs +437 -0
  22. package/src/relay/docs/reconnect-v1.md +27 -0
  23. package/src/relay/docs/working-rules-v1.md +42 -0
  24. package/src/relay/enroll.mjs +232 -0
  25. package/src/relay/export-cli.mjs +47 -0
  26. package/src/relay/governance.mjs +1328 -0
  27. package/src/relay/mailbox.mjs +103 -0
  28. package/src/relay/records.mjs +146 -0
  29. package/src/relay/render.mjs +38 -0
  30. package/src/relay/roster.mjs +134 -0
  31. package/src/relay/routes.mjs +87 -0
  32. package/src/relay/schema.mjs +1081 -0
  33. package/src/relay/seed.mjs +89 -0
  34. package/src/relay/serve.mjs +116 -0
  35. package/src/relay/server.mjs +633 -0
  36. package/src/relay/signing.mjs +693 -0
  37. package/src/relay/test-mode.mjs +73 -0
  38. package/src/runtime.mjs +25 -12
  39. package/src/spaces/manifest.mjs +245 -0
  40. package/src/spaces/pillar-group.mjs +12 -0
  41. package/src/spaces/policy.mjs +229 -0
  42. package/src/spaces/store.mjs +325 -0
  43. package/src/trust.mjs +44 -5
  44. package/src/uuaid-namespace.d.mts +6 -0
  45. package/src/uuaid-namespace.mjs +48 -0
  46. package/src/vendor/pillar/blob/blob.mjs +12 -14
  47. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  48. package/src/vendor/pillar/groups.mjs +175 -0
  49. package/src/vendor/pillar/identity/jcs.mjs +4 -5
  50. package/src/vendor/pillar/identity/keychain.mjs +51 -2
  51. package/src/vendor/pillar/net/carrier-client.mjs +29 -7
  52. package/src/vendor/pillar/net/refusals.mjs +4 -2
  53. package/test/channels.test.mjs +0 -268
  54. package/test/cohort.test.mjs +0 -36
  55. package/test/collaboration.test.mjs +0 -459
  56. package/test/continuity.test.mjs +0 -229
  57. package/test/custody.negative.test.mjs +0 -165
  58. package/test/derivation-parity.test.mjs +0 -19
  59. package/test/fixtures/derivation-vectors.json +0 -26
  60. package/test/fixtures/ed25519-small-order.vectors.json +0 -15
  61. package/test/governance-expected-red.expected-red.mjs +0 -241
  62. package/test/governance-scope.test.mjs +0 -142
  63. package/test/governance-t1-t10.test.mjs +0 -477
  64. package/test/governance.test.mjs +0 -514
  65. package/test/hold-admission.test.mjs +0 -1185
  66. package/test/keychain-strict-ed25519.test.mjs +0 -240
  67. package/test/offline.test.mjs +0 -152
  68. package/test/runtime-channels.test.mjs +0 -1140
  69. package/test/runtime.test.mjs +0 -201
  70. package/test/security-runtime.test.mjs +0 -104
  71. package/test/strict-ed25519-consumers.test.mjs +0 -234
  72. package/test/transport.test.mjs +0 -353
@@ -0,0 +1,73 @@
1
+ // Cloud Commons relay: the --test-mode hooks (spec v3.4.5 13.2 "Test mode").
2
+ //
3
+ // server.mjs loads this module only when the relay starts with --test-mode, and only after the
4
+ // conjunctive start refusal has passed (the test aud AND a loopback or named test-interface
5
+ // bind). A non-test build can leave this file out entirely (security invariant: its entrypoint must not carry the
6
+ // flag).
7
+ //
8
+ // - Clock: relay time is the integer (ms) in <dir>/clock, read on every use. The harness moves it;
9
+ // nothing in a request can set or move it.
10
+ // - Barriers: a closed list of named points in the request path. While <dir>/barriers/<name>.hold
11
+ // exists, a request reaching that point writes <dir>/barriers/<name>.reached and waits until the
12
+ // .hold file is gone. No request can name, create or clear a barrier.
13
+ // - Container-local only: <dir> is one absolute, normalized path given at start, never
14
+ // taken from a request, with no symlink anywhere in it (its realpath is itself). The directory and
15
+ // every file read from it must be a real directory or regular file (never a symlink, so no path is
16
+ // redirected elsewhere), owned by this process's user, and not writable by group or others. The
17
+ // harness asserts no host mounts (13.2).
18
+
19
+ import { lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
+ import { isAbsolute, join, normalize } from "node:path";
21
+ import { RelayError } from "./records.mjs";
22
+
23
+ /**
24
+ * The closed barrier list. Relay: before the record handler runs, and after it committed (before the
25
+ * response). Reference client (13.2: "the reference client takes the same barrier hooks"): after it
26
+ * read an inbox record and before handling it, after handling and before acknowledging, and before
27
+ * it drains its outbox.
28
+ */
29
+ export const BARRIERS = Object.freeze(["before-dispatch", "after-commit", "client-after-read", "client-after-handle", "client-before-drain"]);
30
+ const POLL_MS = 10;
31
+
32
+ function privateEntry(path, kind) {
33
+ let st;
34
+ try { st = lstatSync(path); } catch { return null; }
35
+ const ok = (kind === "dir" ? st.isDirectory() : st.isFile()) && !st.isSymbolicLink()
36
+ && (typeof process.getuid !== "function" || st.uid === process.getuid()) && (st.mode & 0o022) === 0;
37
+ if (!ok) throw new RelayError("test-mode-path", 500, `${kind} must be a private ${kind === "dir" ? "directory" : "regular file"} of this user, not a symlink`);
38
+ return st;
39
+ }
40
+
41
+ /**
42
+ * Open the hooks for one test directory. Throws (and the relay refuses to start) unless the
43
+ * directory and its clock file pass the checks above and the clock holds a valid time.
44
+ * Returns { now(), barrier(name, signal), dir }.
45
+ */
46
+ export function openTestHooks(dir) {
47
+ if (typeof dir !== "string" || !isAbsolute(dir) || normalize(dir) !== dir || dir.length > 512) throw new RelayError("test-mode-path", 500, "test dir must be an absolute, normalized path");
48
+ if (!privateEntry(dir, "dir")) throw new RelayError("test-mode-path", 500, "test dir missing");
49
+ if (realpathSync(dir) !== dir) throw new RelayError("test-mode-path", 500, "test dir path must contain no symlink");
50
+ const clockPath = join(dir, "clock");
51
+ const barrierDir = join(dir, "barriers");
52
+
53
+ function now() {
54
+ if (!privateEntry(clockPath, "file")) throw new RelayError("test-clock", 500, "clock file missing");
55
+ const text = readFileSync(clockPath, "utf8");
56
+ if (!/^(0|[1-9][0-9]{0,15})\n?$/.test(text)) throw new RelayError("test-clock", 500, "clock file must hold one integer (ms)");
57
+ const t = Number(text.trim());
58
+ if (!Number.isSafeInteger(t)) throw new RelayError("test-clock", 500, "clock out of range");
59
+ return t;
60
+ }
61
+
62
+ /** Wait at a named point while its .hold file exists. Resolves at once when there is none. */
63
+ async function barrier(name, signal) {
64
+ if (!BARRIERS.includes(name)) throw new RelayError("test-barrier", 500, "unknown barrier");
65
+ const hold = join(barrierDir, `${name}.hold`);
66
+ if (!privateEntry(barrierDir, "dir") || !privateEntry(hold, "file")) return;
67
+ writeFileSync(join(barrierDir, `${name}.reached`), `${process.pid}\n`, { mode: 0o600 });
68
+ while (!signal?.aborted && privateEntry(hold, "file")) await new Promise(r => setTimeout(r, POLL_MS));
69
+ }
70
+
71
+ now(); // refuse to start on a missing or invalid clock
72
+ return Object.freeze({ now, barrier, dir });
73
+ }
package/src/runtime.mjs CHANGED
@@ -3,12 +3,15 @@ import { Keychain, seal, open, decrypt, envelopeSha, CarrierClient, jcs } from "
3
3
  import { CommonsStore } from "./store.mjs";
4
4
  import { RegistryTrust } from "./trust.mjs";
5
5
  import { BASE_PROTOCOL, createProfile, verifyProfile, proposeAlias, encode, decode, digest, signDocument, verifyDocument, contribution } from "./profiles.mjs";
6
- // L3 Slice 2 v6 §2.1: Pluggable transport and channel/tenant admission
6
+ // Pluggable transport and channel/tenant admission
7
7
  import { PillarCarrierTransport } from "./transport.mjs";
8
8
  import { classifyThread, assertChannelSendAdmission, assertChannelReceiveAdmission, validateTenantBinding } from "./channels.mjs";
9
9
 
10
+ // D-12: only a string envelope id goes into an audit event; anything else a peer sends (absent, 1.5, an object) becomes null.
11
+ const auditEnvelopeId=id=>typeof id==="string"?id:null;
12
+
10
13
  export class AgentCommons {
11
- // L3 Slice 2 v6 §2.1: options.transport and channel/tenant policy flags
14
+ // options.transport and channel/tenant policy flags
12
15
  constructor({keychain,store=new CommonsStore(),policy={mode:"local",agents:{}},carriers=[],transport,trust}={}){
13
16
  if(!keychain?._identity)throw new Error("A loaded Pillar keychain is required");
14
17
  this.keychain=keychain;this.store=store;this.policy=policy;this.trust=trust??new RegistryTrust({policy});this.carriers=carriers;
@@ -111,7 +114,7 @@ export class AgentCommons {
111
114
  await this.trust.authorize(this.uuaid,this.publicKey,"commons:contribute");
112
115
  return signDocument(this.keychain,"profile-contribution",{contribution:contribution(this.profile(profileId),options)});
113
116
  }
114
- // L3 Slice 2 v6 §2.1 / §4.2, §4.2.3, §5.2: channel/tenant admission, atomic authenticated admission record
117
+ // Channel/tenant admission, atomic authenticated admission record
115
118
  async send({recipient,profileId,namespace,body,thread="commons",kind="message",deferFlush=false}){
116
119
  if(typeof body!=="string"||Buffer.byteLength(body)>32000||typeof thread!=="string"||thread.length>120||!["message","profile-control"].includes(kind))throw new Error("Invalid bounded message");
117
120
  const classified=classifyThread(thread,this.policy);
@@ -146,7 +149,7 @@ export class AgentCommons {
146
149
  return {queued:queued.map(x=>({recipient:x.envelope.recipient,id:x.envelope.id})),deliveries:await this.flush(),groupPrivacy:"individually-sealed-recipient-copies"};
147
150
  }
148
151
  flush(){const next=this._flushTail.then(()=>this.flushInternal());this._flushTail=next.catch(()=>{});return next}
149
- // L3 Slice 2 v6 §2.1 / §3.1, §4.2, §4.2.3, §5.2: pre-delivery rechecks, signature verification, and delivery
152
+ // Pre-delivery rechecks, signature verification, and delivery
150
153
  async flushInternal(){
151
154
  if(!this.transport)return [];
152
155
  const results=[];
@@ -192,7 +195,7 @@ export class AgentCommons {
192
195
  return results;
193
196
  }
194
197
  receive(envelope){const next=this._receiveTail.then(()=>this.receiveInternal(envelope));this._receiveTail=next.catch(()=>{});return next}
195
- // L3 Slice 2 v6 §2.1 / §4.2, §4.2.3, §5.2: inbound channel and tenant admission
198
+ // Inbound channel and tenant admission
196
199
  async receiveInternal(envelope){
197
200
  const valid=open(envelope);if(!valid.ok)throw new Error(`Invalid Pillar envelope: ${valid.reason}`);
198
201
  if(envelope.delegation!==undefined)throw new Error("Delegated host admission requires a configured principal-status adapter; not yet enabled");
@@ -289,7 +292,7 @@ export class AgentCommons {
289
292
  }
290
293
  return results;
291
294
  }
292
- // L3 Slice 2 v6 §2.1 / §3.1, §8 (R2): foreign item skip with cursor advance and recipient isolation
295
+ // Foreign item skip with cursor advance and recipient isolation
293
296
  async poll(){
294
297
  if(!this.transport)throw new Error("No carrier configured");
295
298
  const cursors=this.store.get("cursors",{}),health=this.store.get("carrierHealth",{}),results=[];
@@ -303,10 +306,14 @@ export class AgentCommons {
303
306
  for(const item of inbox.envelopes){
304
307
  // A carrier may not rewind or repeat sequence numbers within a response.
305
308
  if(!Number.isSafeInteger(item?.seq)||item.seq<=last){results.push({carrier,rejected:true,reason:"non-monotonic-carrier-sequence"});continue}
306
- const valid=open(item?.envelope);
309
+ // D-12: open() can throw on peer input (it interpolates fields); a throw is one more invalid envelope, not a poll failure.
310
+ let valid;
311
+ try{valid=open(item?.envelope)}catch{valid={ok:false,reason:"validation-threw"}}
307
312
  if(!valid.ok){
308
- results.push({id:item?.envelope?.id,rejected:true,reason:`Invalid Pillar envelope: ${valid.reason}`});
309
- this.store.append(`rejected-${item?.envelope?.id}`,{kind:"quarantined-inbound",id:item?.envelope?.id,reason:`Invalid Pillar envelope: ${valid.reason}`});
313
+ // D-12: the peer's id is data inside the event, never the audit key; the cursor moves only after the audit write lands.
314
+ const id=auditEnvelopeId(item?.envelope?.id),reason=`Invalid Pillar envelope: ${typeof valid.reason==="string"?valid.reason:"invalid"}`;
315
+ results.push({id,rejected:true,reason});
316
+ this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
310
317
  last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
311
318
  continue;
312
319
  }
@@ -321,10 +328,16 @@ export class AgentCommons {
321
328
  if(error?.transient===true){
322
329
  // Trust backend outage is not a verdict on the sender: keep the cursor so the envelope is retried, and stop to preserve order.
323
330
  const failures=(health[carrier]?.failures??0)+1;health[carrier]={failures,nextProbeAt:Date.now()+Math.min(60000,1000*2**Math.min(failures,6)),error:error.code??"trust-unavailable"};this.store.set("carrierHealth",health);
324
- this.store.append(`deferred-${item.envelope?.id}`,{kind:"inbound-deferred-trust-unavailable",id:item.envelope?.id});
325
- results.push({id:item.envelope?.id,rejected:false,deferred:true,state:"trust-unavailable-retry",reason:error.code??"trust-unavailable"});break;
331
+ // D-12: JSON.stringify keeps the key one-to-one with id (null vs "null", lone surrogates), and the event is a
332
+ // function of id alone, so a retry rewrites the same row and two different ids never share a key. The
333
+ // "deferred/v2:" namespace keeps these keys apart from rows written as `deferred-${id}` before this change.
334
+ const id=auditEnvelopeId(item.envelope?.id);
335
+ this.store.append(`deferred/v2:${JSON.stringify(id)}`,{kind:"inbound-deferred-trust-unavailable",id});
336
+ results.push({id,rejected:false,deferred:true,state:"trust-unavailable-retry",reason:error.code??"trust-unavailable"});break;
326
337
  }
327
- results.push({id:item.envelope?.id,rejected:true,reason:error.message});this.store.append(`rejected-${item.envelope?.id}`,{kind:"quarantined-inbound",id:item.envelope?.id,reason:error.message});
338
+ // D-12: a thrown value need not carry a string message; never serialize it into the audit.
339
+ const id=auditEnvelopeId(item.envelope?.id),reason=typeof error?.message==="string"?error.message:"receive-failed";
340
+ results.push({id,rejected:true,reason});this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
328
341
  }
329
342
  last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
330
343
  }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Manifest verifier wrapper, admin signer and key→uuaid binding helpers
3
+ * (design §3, §4). Additive; imports the vendored pillar engine ONLY through
4
+ * ./pillar-group.mjs (C9). Computes no engine-vs-client hash (C13/V-7): the
5
+ * manifest path uses the vendored canonicalJson, never the engine's jcs (V-1).
6
+ *
7
+ * In scope here: V-3 (only whole chains yield a head), V-6's
8
+ * sign → verify → own-store-save → return order, B-1/B-2 (one binding,
9
+ * keyBindsRow: AC's parse-based rule-1 check plus the vendored strict-key
10
+ * rule), B-3 (policy-record candidates from the verified head), B-4 (full-uuaid
11
+ * keys admit only the roster's exact spelling at the engine trust gate), B-6
12
+ * (self check) and V-7's unit-level ASCII-key guard.
13
+ *
14
+ * R1: the admin change is the VENDORED `adminChange` (V-1, "vendor, don't
15
+ * write"), so a frozen record is refused and a leave/remove clears the target
16
+ * from `leaving` exactly as pillar's reference does (groups-v2.mjs:534, :541).
17
+ *
18
+ * Deferred by design, NOT stubbed: P-7 (a remove/leave manifest adds the
19
+ * pendingRemovals entry in the same step; it spans adapter.json),
20
+ * the sends themselves, V-7's send/receive wiring and
21
+ * B-5's transport-key snapshot.
22
+ */
23
+ import {
24
+ adminChange, adminOf, isStrictPublicKey, manifestHash, verifyChain,
25
+ } from "./pillar-group.mjs";
26
+ import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
27
+
28
+ /** A named error, so a caller (and a pole) can tell refusals apart. */
29
+ export class ManifestError extends Error {
30
+ constructor(name, message) { super(message); this.name = name; }
31
+ }
32
+
33
+ /** P-2: capabilities are exactly ["commons:message"] for every record. */
34
+ const AGENT_CAPABILITIES = Object.freeze(["commons:message"]);
35
+
36
+ // ── V-3: only whole chains ────────────────────────────────────────────────
37
+
38
+ /**
39
+ * The usable head of a record, only if its chain verifies from version 1.
40
+ * Never takes a head from an unverified record: verifyChain runs here, in
41
+ * this call, whatever the record's provenance (SpaceStore.loadRoster output
42
+ * re-verifies too — this is the explicit gate for a supplied record).
43
+ *
44
+ * If `rec.head` is present it must agree with the verified chain's last
45
+ * manifest; a record without one is tolerated because the chain itself is
46
+ * the verified source of the head.
47
+ */
48
+ export function verifiedHead(rec) {
49
+ if (!rec || !Array.isArray(rec.chain) || rec.chain.length === 0) {
50
+ throw new ManifestError("ChainVerifyError", "record: no chain to verify");
51
+ }
52
+ const v = verifyChain(rec.chain);
53
+ if (!v.ok) throw new ManifestError("ChainVerifyError", `chain rejected: ${v.reason}@${v.at}`);
54
+ const head = rec.chain[rec.chain.length - 1];
55
+ if (rec.head !== undefined && rec.head !== null) {
56
+ if (rec.head.version !== head.version || rec.head.hash !== manifestHash(head)) {
57
+ throw new ManifestError("HeadMismatchError", "rec.head does not match the verified chain's last manifest");
58
+ }
59
+ }
60
+ return head;
61
+ }
62
+
63
+ // ── B-6: self check ───────────────────────────────────────────────────────
64
+
65
+ /** Normalize a Keychain instance (._identity) or a raw identity object. */
66
+ function keychainIdentity(keychain) {
67
+ const id = keychain?._identity ?? keychain;
68
+ if (!id || typeof id.uuaid !== "string" || id.uuaid.length === 0 ||
69
+ typeof id.publicKeyHex !== "string" || id.publicKeyHex.length === 0) {
70
+ throw new ManifestError("SelfCheckError", "keychain: no loaded identity {uuaid, publicKeyHex}");
71
+ }
72
+ return id;
73
+ }
74
+
75
+ /**
76
+ * B-1/B-2: does `key` bind `uuaid`? Namespace alignment rule 1 — a key-derived
77
+ * agent UUAID in foundation, pillar or agentnet whose local id is the key's,
78
+ * PARSED, never rebuilt and compared (rule 2) — plus the vendored strict-key
79
+ * rule (no small-order or non-canonical point). One key therefore binds three
80
+ * spellings; B-4 is what keeps them apart at the gate.
81
+ */
82
+ export function keyBindsRow(uuaid, key) {
83
+ return keyBindsAgentUuaid(uuaid, key) && isStrictPublicKey(key);
84
+ }
85
+
86
+ /**
87
+ * B-6. Passes only when (a) the keychain's uuaid is bound by its own public key
88
+ * (keyBindsRow) AND (b) the VERIFIED head holds a row for that uuaid carrying
89
+ * that same key. Otherwise throws SelfCheckError. On a chain that verified,
90
+ * (b)'s key-mismatch arm is unreachable when (a) held (B-1: the local id is a
91
+ * function of the key, and rowChecks binds every row), so it is kept as a
92
+ * guard against a future vendored change and for callers that hand-build a
93
+ * head — same posture as the space store's keep-only-if clauses.
94
+ */
95
+ export function selfCheck(keychain, rec) {
96
+ const id = keychainIdentity(keychain);
97
+ if (!keyBindsRow(id.uuaid, id.publicKeyHex)) {
98
+ throw new ManifestError("SelfCheckError", "keychain uuaid does not derive from its own public key");
99
+ }
100
+ const head = verifiedHead(rec);
101
+ const row = head.members.find((r) => r.uuaid === id.uuaid);
102
+ if (!row) throw new ManifestError("SelfCheckError", "the verified head holds no row for this keychain's uuaid");
103
+ if (row.key !== id.publicKeyHex) throw new ManifestError("SelfCheckError", "the head's own row carries a different key");
104
+ return true;
105
+ }
106
+
107
+ // ── V-6: the admin signer (sign → verify → own-store-save → return) ──────
108
+
109
+ /**
110
+ * The store the vendored `adminChange` writes through: SpaceStore's own
111
+ * roster load/save, so `loadRoster`'s absent-means-null (F13) and
112
+ * `saveRoster`'s verify-before-create plus 0700 tightening (S-1) are the only
113
+ * path to disk. `loadGroupForCommand` needs has/load; :542 needs save.
114
+ */
115
+ function rosterStoreFor(store) {
116
+ return {
117
+ has: (id) => store.loadRoster(id) !== null,
118
+ load: (id) => {
119
+ const r = store.loadRoster(id);
120
+ if (!r) throw new ManifestError("RosterAbsentError", `no verified roster for ${String(id)}`);
121
+ return r;
122
+ },
123
+ save: (rec) => store.saveRoster(rec), // OWN STORE FIRST (S-1: refuses an unverified record)
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Build, sign and apply the next manifest to the ADMIN's own store, then
129
+ * return it for the caller to send (the send itself is not done here — this never
130
+ * sends). Order is load-bearing:
131
+ * 1. load through SpaceStore.loadRoster, which re-verifies (F13, V-3);
132
+ * 2. explicit verifiedHead (V-3) and selfCheck (B-6);
133
+ * 3. the signer must be the head's single active admin (F14);
134
+ * 4. a frozen record is refused BEFORE anything is built — the named error
135
+ * for pillar's own refusal (groups-v2.mjs:534);
136
+ * 5. the vendored `adminChange` builds, signs with the admin's own keychain
137
+ * (F14), verifies the next version, extends the chain, clears the target
138
+ * from `leaving` on leave/remove (:541) and saves to the admin's OWN
139
+ * store FIRST (:542);
140
+ * 6. only then return { manifest, record }.
141
+ *
142
+ * `mailbox: null` makes the vendored mailbox coupling a no-op
143
+ * (`syncMailboxRoster`, :457-458) — this wrapper owns no mailbox row.
144
+ *
145
+ * P-7 (a remove/leave manifest puts the member into pendingRemovals in the
146
+ * same step) is not done here: it spans the roster and adapter.json. The record
147
+ * fields other than chain/head/leaving (seen, frozen, …) are preserved
148
+ * verbatim; the receiver-side application semantics are not applied here.
149
+ *
150
+ * Synchronous; the adapter's serial queue (I-5) is the caller's job.
151
+ * @returns {{ manifest: object, record: object }}
152
+ */
153
+ export function signAndApplyNext(store, groupId, keychain, action, buildOpts = {}) {
154
+ const rec = store.loadRoster(groupId); // re-verifies from version 1 (F13); null = absent
155
+ if (!rec) throw new ManifestError("RosterAbsentError", `no verified roster for ${String(groupId)}`);
156
+ const head = verifiedHead(rec); // V-3, explicit
157
+ const id = keychainIdentity(keychain);
158
+ selfCheck(keychain, rec); // B-6
159
+ const admin = adminOf(head);
160
+ if (!admin || admin.uuaid !== id.uuaid || admin.key !== id.publicKeyHex) {
161
+ throw new ManifestError("NotAdminError", "the signer is not the head's single active admin");
162
+ }
163
+ // D1: pillar refuses an equivicated group here (:534); this is the named
164
+ // form of that refusal, and the vendored call below refuses it again.
165
+ if (rec.frozen) {
166
+ throw new ManifestError("FrozenGroupError",
167
+ `${String(groupId)} is frozen (equivocation); a frozen group can only be replaced by a new group`);
168
+ }
169
+ // A raw identity is accepted by signManifest but NOT by :543, which reads
170
+ // keychain._identity.uuaid unguarded — normalize to the keychain shape.
171
+ const signer = keychain?._identity ? keychain : { _identity: id };
172
+ const { rec: record, manifest } = adminChange({
173
+ store: rosterStoreFor(store), mailbox: null, keychain: signer,
174
+ groupId, action, opts: buildOpts,
175
+ });
176
+ return { manifest, record }; // …and only then handed back for sending
177
+ }
178
+
179
+ // ── B-3: policy-record candidates from the verified head ─────────────────
180
+
181
+ /**
182
+ * One candidate `policy.agents` entry from one roster row. The binding is
183
+ * keyBindsRow (B-1/B-2: rule 1 plus the vendored strict-key rule). A failing
184
+ * row THROWS; it is never
185
+ * skipped silently. Exported so the binding re-check is unit-testable on a
186
+ * hand-built row (on a verified chain rowChecks already binds every row, so
187
+ * the aggregate below re-checks as defense in depth).
188
+ */
189
+ export function agentRecordForRow(row) {
190
+ if (!row || typeof row.uuaid !== "string" || typeof row.key !== "string") {
191
+ throw new ManifestError("KeyBindingError", "row: expected {uuaid, key} strings");
192
+ }
193
+ if (!keyBindsRow(row.uuaid, row.key)) {
194
+ throw new ManifestError("KeyBindingError", `row ${row.uuaid}: key does not bind its uuaid`);
195
+ }
196
+ return { kind: "agent", publicKey: row.key, capabilities: [...AGENT_CAPABILITIES] };
197
+ }
198
+
199
+ /**
200
+ * B-3, pure: candidate `policy.agents` entries ONLY from the verified head's
201
+ * non-removed rows (active ∪ invited, design §5), keyed by the FULL row.uuaid
202
+ * — which is what makes B-4 hold. One key has a valid spelling in each of
203
+ * foundation, pillar and agentnet, all passing KEY_BINDING; a sender re-spelling
204
+ * a member's key in another of them is refused NOT_ADMITTED, because a policy
205
+ * built here holds only each row's EXACT spelling. A spelling outside the set
206
+ * never reaches policy: trust.authorize refuses it AGENT_ONLY. Writes no
207
+ * policy: the policy writer is the only writer and consumes this helper.
208
+ */
209
+ export function agentRecordsFromVerifiedHead(rec) {
210
+ const head = verifiedHead(rec);
211
+ const agents = {};
212
+ for (const row of head.members) {
213
+ if (row.status === "removed") continue;
214
+ agents[row.uuaid] = agentRecordForRow(row); // full-uuaid key; throws, never skips
215
+ }
216
+ return agents;
217
+ }
218
+
219
+ // ── V-7 (unit level): no non-ASCII object keys, any depth ────────────────
220
+
221
+ function isAsciiKey(k) {
222
+ for (const ch of k) if (ch.codePointAt(0) > 0x7f) return false;
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * V-7's guard, unit-tested here; its wiring into send and receive is
228
+ * slices 8/9. Refuses any non-ASCII object key at any depth — commons
229
+ * objects, charter objects and space profile documents including the
230
+ * lexicon. Pillar 2.0.2's jcs sorted by UTF-16LE bytes and disagreed with
231
+ * pillar-client's JCS on such keys (F16); the vendored 2.2.0 jcs sorts by code
232
+ * unit as RFC 8785 requires, but peers on older code still disagree, so no
233
+ * engine-vs-client hash may be computed over such keys (C13). Array indices are
234
+ * keys of an array object and are ASCII by construction. Contract: parsed-
235
+ * JSON or freshly built objects; a cyclic input is out of contract.
236
+ */
237
+ export function assertAsciiKeys(obj, path = "$") {
238
+ if (obj === null || typeof obj !== "object") return;
239
+ for (const k of Object.keys(obj)) {
240
+ if (!isAsciiKey(k)) {
241
+ throw new ManifestError("NonAsciiKeyError", `${path}: non-ASCII object key ${JSON.stringify(k)}`);
242
+ }
243
+ assertAsciiKeys(obj[k], `${path}.${k}`);
244
+ }
245
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The ONLY module under src/spaces/ that imports the vendored pillar groups-v2
3
+ * engine (C9). Later slices add names HERE; they never open a second import
4
+ * site. Re-exported by name, limited to what the space-store slice uses.
5
+ */
6
+ export {
7
+ RosterStore, isGroupIdV2, STORE_V,
8
+ // the manifest verifier wrapper and admin signer, and the vendored
9
+ // strict-key rule that manifest.mjs's keyBindsRow pairs with AC's rule-1
10
+ // binding (the engine's own uuaidForKey/keyBindsUuaid are not used here):
11
+ adminChange, adminOf, isStrictPublicKey, manifestHash, verifyChain,
12
+ } from "../vendor/pillar/groups-v2.mjs";
@@ -0,0 +1,229 @@
1
+ /** Synchronous space policy and operator stops. Queue ownership and sends belong to the adapter. */
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { manifestHash } from "./pillar-group.mjs";
4
+ import { agentRecordForRow, agentRecordsFromVerifiedHead, keyBindsRow, verifiedHead, selfCheck, signAndApplyNext } from "./manifest.mjs";
5
+ import { prunePendingRemovals, validateAdapterState } from "./store.mjs";
6
+ import { RegistryTrust } from "../trust.mjs";
7
+
8
+ const POLICY_FIELDS=["mode","channelsEnabled","holdBeforeAdmission","agents","tenantProfiles","channels"];
9
+ const emergencyEngines=new WeakSet(); // replacement invalidates the cached shared identity until a fresh engine is constructed
10
+ const quarantinedEngines=new WeakSet();
11
+ const isCanonicalTrust=RegistryTrust.isCanonicalTrust;
12
+ const denyTrust=RegistryTrust.prototype.denyPermanently;
13
+ const reflectApply=Reflect.apply;
14
+ function plainPolicy(value){return value!==null&&typeof value==="object"&&[Object.prototype,null].includes(Object.getPrototypeOf(value))}
15
+ function latched(state){return state.hardStop!==null}
16
+ export class PolicyError extends Error {
17
+ constructor(name,message){super(message);this.name=name}
18
+ }
19
+ export function assertPolicyIdentity(engine){
20
+ if(quarantinedEngines.has(engine)||!isCanonicalTrust(engine?.trust))throw new PolicyError("PolicyIdentityError","A fresh canonical RegistryTrust receiver is required");
21
+ const policy=engine?.policy,prototype=policy&&Object.getPrototypeOf(policy);
22
+ if(policy===null||typeof policy!=="object"||(prototype!==Object.prototype&&prototype!==null)||Object.isFrozen(policy)||!Object.isExtensible(policy)||policy!==engine?.trust?.policy){
23
+ throw new PolicyError("PolicyIdentityError","Engine and trust must share one mutable plain policy object");
24
+ }
25
+ return policy;
26
+ }
27
+ function channels(groupId){return {content:`channel://agentnet/group/${groupId}`,control:`channel://agentnet/group-ctl/${groupId}`}}
28
+ function closed(groupId){
29
+ const uris=channels(groupId);
30
+ return {mode:"local",channelsEnabled:true,holdBeforeAdmission:true,agents:{},tenantProfiles:{},channels:{[uris.content]:{members:[]},[uris.control]:{members:[]}}};
31
+ }
32
+ function outsideAllowlist(rows,allowlist){
33
+ return rows.find(row=>!allowlist.some(allowed=>allowed.uuaid===row.uuaid&&allowed.key===row.key));
34
+ }
35
+ function outsider(rec,state,allowlist){
36
+ const head=verifiedHead(rec),rows=[...head.members.filter(row=>row.status!=="removed"),...state.pendingRemovals];
37
+ return outsideAllowlist(rows,allowlist);
38
+ }
39
+ function closePolicies(policy,groupId,engine){
40
+ let failure;
41
+ const references=[engine,engine?.trust].filter(Boolean);
42
+ for(const candidate of new Set([policy,engine?.policy,engine?.trust?.policy])){
43
+ try{
44
+ if(!plainPolicy(candidate)||!Object.isExtensible(candidate))throw new TypeError("Authority policy cannot be closed in place");
45
+ Object.assign(candidate,closed(groupId));
46
+ }catch(error){
47
+ failure??=error;
48
+ for(const owner of references){
49
+ if(owner.policy!==candidate)continue;
50
+ try{
51
+ const replacement=closed(groupId);owner.policy=replacement;
52
+ if(owner.policy!==replacement)throw new TypeError("Authority policy replacement did not take effect");
53
+ emergencyEngines.add(engine);
54
+ }catch(fallbackError){failure??=fallbackError}
55
+ }
56
+ }
57
+ }
58
+ let allClosed=false;
59
+ try{allClosed=(engine?[engine.policy,engine.trust?.policy]:[policy]).every(value=>plainPolicy(value)&&POLICY_FIELDS.every(field=>isDeepStrictEqual(value[field],closed(groupId)[field])))}catch(error){failure??=error}
60
+ if(!allClosed&&engine){
61
+ if(!isCanonicalTrust(engine.trust))throw new PolicyError("PolicyIdentityError","Cannot deny an unsupported trust receiver");
62
+ reflectApply(denyTrust,engine.trust,[]);
63
+ quarantinedEngines.add(engine);
64
+ }
65
+ return {closed:allClosed,error:failure??(allClosed?null:new PolicyError("PolicyCloseError","An authority consumer remains open"))};
66
+ }
67
+ function requireSharedPolicy(policy,engine){
68
+ if(assertPolicyIdentity(engine)!==policy)throw new PolicyError("PolicyIdentityError","Restart with a fresh engine to recover the shared policy identity");
69
+ }
70
+ function expectedPolicy(rec,self,state,ext,now){
71
+ if(latched(state))return closed(state.groupId);
72
+ const head=verifiedHead(rec),rows=head.members.filter(row=>row.status!=="removed");
73
+ const pending=prunePendingRemovals(state.pendingRemovals,rec,now).kept;
74
+ const agents=agentRecordsFromVerifiedHead(rec);
75
+ for(const entry of pending)agents[entry.uuaid]=agentRecordForRow(entry);
76
+ const tenantProfiles={};
77
+ if(ext.tenant!=null){for(const agent of Object.values(agents))agent.tenantId=ext.tenant;tenantProfiles[ext.profile.id]=ext.tenant}
78
+ const selfId=self?._identity?.uuaid??self?.uuaid??self,me=head.members.find(row=>row.uuaid===selfId);
79
+ const stopped=!!rec.frozen||state.softStop!==null;
80
+ const content=stopped?[]:head.members.filter(row=>row.status==="active"&&!rec.leaving.includes(row.uuaid)).map(row=>row.uuaid);
81
+ const control=[...new Set((rec.frozen?rows:[...rows,...pending]).map(row=>row.uuaid))];
82
+ // P-6: a self-removed instance has no content or control channel authority.
83
+ if(!me||me.status==="removed"){content.length=0;control.length=0}
84
+ const uris=channels(state.groupId);
85
+ return {mode:"local",channelsEnabled:true,holdBeforeAdmission:true,agents,tenantProfiles,channels:{[uris.content]:{members:content},[uris.control]:{members:control}}};
86
+ }
87
+ function stop(policy,state,reason,{engine,store,onSignal=()=>{},now=Date.now()}={}){
88
+ if(state.hardStop===null)state.hardStop={reason,at:now};
89
+ let persisted=false,failure;
90
+ try{store.saveAdapter(state.groupId,state);persisted=true}catch(error){failure=error}
91
+ const outcome=closePolicies(policy,state.groupId,engine);failure??=outcome.error;
92
+ try{onSignal({code:"SPACE_CLOSED",groupId:state.groupId,...state.hardStop,persisted,closed:outcome.closed})}catch(error){failure??=error}
93
+ if(failure)throw failure;
94
+ return false;
95
+ }
96
+ /** P-9 reads the verified files again; a policy file is never an input. */
97
+ export function checkPolicy(policy,rec,self,state,ext,allowlist,options){
98
+ // rec, ext and allowlist remain for caller compatibility; the check reads the persisted inputs again.
99
+ const {engine,store,now=Date.now()}=options;
100
+ if(!isCanonicalTrust(engine?.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
101
+ if(quarantinedEngines.has(engine))requireSharedPolicy(policy,engine);
102
+ const loaded=store.loadAdapter(state.groupId,{now});
103
+ const saved=store.loadRoster(state.groupId),savedState=loaded?.state;
104
+ if(!savedState)throw new PolicyError("AdapterAbsentError","No persisted adapter state for policy check");
105
+ const expected=latched(savedState)?closed(state.groupId):expectedPolicy(saved,self,savedState,verifiedHead(saved).ext.commons,now);
106
+ const bound=Object.entries(policy.agents??{}).every(([uuaid,agent])=>keyBindsRow(uuaid,agent?.publicKey)&&agent?.kind==="agent"&&isDeepStrictEqual(agent?.capabilities,["commons:message"]));
107
+ if(policy===engine.policy&&policy===engine.trust.policy&&bound&&POLICY_FIELDS.every(field=>isDeepStrictEqual(policy[field],expected[field])))return true;
108
+ return stop(policy,state,"policy-mismatch",options);
109
+ }
110
+ /** One run-to-completion write. The options supply the actual engine, store and operator signal. */
111
+ export function writePolicy(policy,rec,self,state,ext,allowlist,options){
112
+ try{
113
+ if(!isCanonicalTrust(options.engine?.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
114
+ if(quarantinedEngines.has(options.engine))requireSharedPolicy(policy,options.engine);
115
+ if(latched(state)){Object.assign(policy,closed(state.groupId));checkPolicy(policy,rec,self,state,ext,allowlist,options);return policy}
116
+ if(emergencyEngines.has(options.engine))requireSharedPolicy(policy,options.engine);
117
+ if(outsider(rec,state,allowlist)){stop(policy,state,"outsider",options);checkPolicy(policy,rec,self,state,ext,allowlist,options);return policy}
118
+ const expected=expectedPolicy(rec,self,state,ext,options.now??Date.now());
119
+ Object.assign(policy,expected);
120
+ checkPolicy(policy,rec,self,state,ext,allowlist,options);
121
+ return policy;
122
+ }catch(error){
123
+ closePolicies(policy,state.groupId,options.engine);
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ /** Owns only policy/state operations; the future adapter invokes these on its serial queue. */
129
+ export class SpacePolicy {
130
+ constructor({engine,store,groupId,allowlist,onSignal=()=>{},clock=Date.now}){
131
+ this.policy=assertPolicyIdentity(engine);
132
+ this.engine=engine;this.store=store;this.groupId=groupId;this.self=engine.keychain;
133
+ this.allowlist=allowlist;this.onSignal=onSignal;this.clock=clock;
134
+ if(!Array.isArray(allowlist))throw new PolicyError("AllowlistError","An explicit operator allowlist is required");
135
+ Object.assign(this.policy,closed(this.groupId));
136
+ this.rebuild();
137
+ }
138
+ _load(){
139
+ const now=this.clock(),loaded=this.store.loadAdapter(this.groupId,{now});
140
+ if(!loaded)throw new PolicyError("AdapterAbsentError","No adapter state; installation is a separate operation");
141
+ return {rec:this.store.loadRoster(this.groupId),state:loaded.state,now};
142
+ }
143
+ _options(now){return {engine:this.engine,store:this.store,onSignal:this.onSignal,now}}
144
+ rebuild(){
145
+ try{
146
+ if(!isCanonicalTrust(this.engine.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
147
+ if(quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
148
+ const {rec,state,now}=this._load();
149
+ if(latched(state))writePolicy(this.policy,rec,this.self,state,null,this.allowlist,this._options(now));
150
+ if(!rec){if(latched(state))return this.policy;throw new PolicyError("RosterAbsentError","No verified roster for policy rebuild")}
151
+ const head=verifiedHead(rec);selfCheck(this.self,rec);
152
+ if(!latched(state)&&emergencyEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
153
+ // Inspect every verified change since consent, including changes reverted while this instance was down.
154
+ const after=rec.chain.filter(manifest=>manifest.version>state.consent.manifestVersion);
155
+ let previous=rec.chain.find(manifest=>manifest.version===state.consent.manifestVersion)?.ext.commons;
156
+ if(!previous)throw new PolicyError("ConsentHeadError","Consent must name a verified manifest version");
157
+ let changed=false,reconsentSignal=false;
158
+ for(const manifest of after){
159
+ const current=manifest.ext.commons;
160
+ if(!isDeepStrictEqual(previous.profile,current.profile)||previous.tenant!==current.tenant){
161
+ if(state.softStop===null){state.softStop={reason:"reconsent",at:now};changed=true;reconsentSignal=true}
162
+ }
163
+ previous=current;
164
+ }
165
+ const scanned=rec.chain.find(manifest=>manifest.version===state.outsiderScan.version);
166
+ const scanVersion=scanned&&manifestHash(scanned)===state.outsiderScan.hash?scanned.version:0;
167
+ const unseen=rec.chain.filter(manifest=>manifest.version>scanVersion);
168
+ const hit=unseen.some(manifest=>outsideAllowlist(manifest.members.filter(row=>row.status!=="removed"),this.allowlist));
169
+ const from=state.outsiderScan,to={version:head.version,hash:manifestHash(head)};
170
+ if(!isDeepStrictEqual(from,to)){state.outsiderScan=to;changed=true}
171
+ if(hit&&!latched(state))stop(this.policy,state,"outsider",this._options(now));
172
+ else{
173
+ if(changed)this.store.saveAdapter(this.groupId,state);
174
+ if(hit)this.onSignal({code:"SPACE_OUTSIDER_SEEN",groupId:this.groupId,from,to});
175
+ }
176
+ if(reconsentSignal)this.onSignal({code:"SPACE_RECONSENT_REQUIRED",groupId:this.groupId,at:now});
177
+ return writePolicy(this.policy,rec,this.self,state,head.ext.commons,this.allowlist,this._options(now));
178
+ }catch(error){
179
+ try{closePolicies(this.policy,this.groupId,this.engine)}catch{} // retain the original read/write failure
180
+ throw error;
181
+ }
182
+ }
183
+ /** Call after rebuild(), including after pending TTL expiry; the adapter owns the expiry timer. */
184
+ check(){
185
+ try{
186
+ const {rec,state,now}=this._load();
187
+ return checkPolicy(this.policy,rec,this.self,state,latched(state)?null:verifiedHead(rec).ext.commons,this.allowlist,this._options(now));
188
+ }catch(error){
189
+ try{closePolicies(this.policy,this.groupId,this.engine)}catch{}
190
+ throw error;
191
+ }
192
+ }
193
+ hardResume(){
194
+ requireSharedPolicy(this.policy,this.engine);
195
+ const {rec,state,now}=this._load(),head=verifiedHead(rec);selfCheck(this.self,rec);
196
+ if(!isDeepStrictEqual(state.outsiderScan,{version:head.version,hash:manifestHash(head)}))throw new PolicyError("OutsiderScanBehindError","Run rebuild first: outsider scan is behind the verified head");
197
+ if(outsider(rec,state,this.allowlist))throw new PolicyError("OutsiderError","Hard resume requires an outsider-free verified head and pending set");
198
+ state.hardStop=null;this.store.saveAdapter(this.groupId,state);
199
+ return this.rebuild();
200
+ }
201
+ reconsent(consent){
202
+ if(!isCanonicalTrust(this.engine.trust)||emergencyEngines.has(this.engine)||quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
203
+ const {rec,state}=this._load(),head=verifiedHead(rec);
204
+ state.consent=structuredClone(consent);state.profile=structuredClone(head.ext.commons.profile);state.softStop=null;
205
+ validateAdapterState(state,this.groupId);
206
+ if(state.consent.manifestVersion!==head.version)throw new PolicyError("ConsentHeadError","Reconsent must name the current verified manifest");
207
+ this.store.saveAdapter(this.groupId,state);
208
+ return this.rebuild(); // E-3's checked profile installation remains the installer's responsibility.
209
+ }
210
+ /** P-7: reserved envelope id is mandatory for a remove/leave; roster FIRST, adapter SECOND. */
211
+ applyOwnChange(action,buildOpts={}, {outboxId}={}){
212
+ if(!isCanonicalTrust(this.engine.trust)||emergencyEngines.has(this.engine)||quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
213
+ const {rec,state,now}=this._load(),head=verifiedHead(rec);
214
+ let nextState=state;
215
+ if(action==="remove"||action==="leave"){
216
+ const row=head.members.find(member=>member.uuaid===buildOpts.target);
217
+ if(!row)throw new PolicyError("RemovalTargetError","A pending removal requires a row in the verified head");
218
+ const pending={uuaid:row.uuaid,key:row.key,manifestVersion:head.version+1,queuedAt:now,outboxId};
219
+ nextState={...state,pendingRemovals:[...state.pendingRemovals.filter(entry=>entry.uuaid!==row.uuaid),pending]};
220
+ validateAdapterState(nextState,this.groupId); // preflight before either file write
221
+ }
222
+ let result;
223
+ try{
224
+ result=signAndApplyNext(this.store,this.groupId,this.self,action,buildOpts);
225
+ if(nextState!==state)this.store.saveAdapter(this.groupId,nextState);
226
+ }finally{this.rebuild()}
227
+ return result;
228
+ }
229
+ }