@uuaid/agent-commons 0.0.0 → 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 (78) hide show
  1. package/CONFORMANCE.md +26 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +22 -0
  4. package/PROVENANCE.json +24 -0
  5. package/README.md +404 -5
  6. package/bin/agent-commons.mjs +76 -0
  7. package/examples/Dockerfile +12 -0
  8. package/examples/agent.mjs +17 -0
  9. package/examples/global-policy.json +9 -0
  10. package/examples/run-cohort.mjs +68 -0
  11. package/package.json +31 -8
  12. package/profiles/agentnet.chat.json +11 -0
  13. package/profiles/local.json +11 -0
  14. package/profiles/zilligon.com.json +11 -0
  15. package/profiles/zilligons.com.json +11 -0
  16. package/src/carrier.mjs +84 -0
  17. package/src/channels.d.mts +75 -0
  18. package/src/channels.mjs +158 -0
  19. package/src/cohort.mjs +61 -0
  20. package/src/collaboration.mjs +875 -0
  21. package/src/config.mjs +40 -0
  22. package/src/continuity.d.mts +24 -0
  23. package/src/continuity.mjs +601 -0
  24. package/src/governance-scope.d.mts +46 -0
  25. package/src/governance-scope.mjs +139 -0
  26. package/src/governance.mjs +355 -0
  27. package/src/index.d.ts +111 -0
  28. package/src/index.mjs +15 -0
  29. package/src/loop.mjs +71 -0
  30. package/src/offline.d.mts +7 -0
  31. package/src/offline.mjs +29 -0
  32. package/src/pillar.mjs +59 -0
  33. package/src/profiles.d.mts +12 -0
  34. package/src/profiles.mjs +92 -0
  35. package/src/relay/auth.mjs +294 -0
  36. package/src/relay/board.mjs +223 -0
  37. package/src/relay/claims.mjs +25 -0
  38. package/src/relay/cli.mjs +49 -0
  39. package/src/relay/client.mjs +437 -0
  40. package/src/relay/docs/reconnect-v1.md +27 -0
  41. package/src/relay/docs/working-rules-v1.md +42 -0
  42. package/src/relay/enroll.mjs +232 -0
  43. package/src/relay/export-cli.mjs +47 -0
  44. package/src/relay/governance.mjs +1328 -0
  45. package/src/relay/mailbox.mjs +103 -0
  46. package/src/relay/records.mjs +146 -0
  47. package/src/relay/render.mjs +38 -0
  48. package/src/relay/roster.mjs +134 -0
  49. package/src/relay/routes.mjs +87 -0
  50. package/src/relay/schema.mjs +1081 -0
  51. package/src/relay/seed.mjs +89 -0
  52. package/src/relay/serve.mjs +116 -0
  53. package/src/relay/server.mjs +633 -0
  54. package/src/relay/signing.mjs +693 -0
  55. package/src/relay/test-mode.mjs +73 -0
  56. package/src/runtime.mjs +359 -0
  57. package/src/spaces/manifest.mjs +245 -0
  58. package/src/spaces/pillar-group.mjs +12 -0
  59. package/src/spaces/policy.mjs +229 -0
  60. package/src/spaces/store.mjs +325 -0
  61. package/src/store.mjs +73 -0
  62. package/src/transport.d.mts +76 -0
  63. package/src/transport.mjs +267 -0
  64. package/src/trust.mjs +104 -0
  65. package/src/uuaid-namespace.d.mts +6 -0
  66. package/src/uuaid-namespace.mjs +48 -0
  67. package/src/vendor/pillar/blob/blob.mjs +508 -0
  68. package/src/vendor/pillar/crypto/e2e.mjs +97 -0
  69. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  70. package/src/vendor/pillar/groups.mjs +175 -0
  71. package/src/vendor/pillar/identity/jcs.mjs +34 -0
  72. package/src/vendor/pillar/identity/keychain.mjs +241 -0
  73. package/src/vendor/pillar/identity/tier.mjs +130 -0
  74. package/src/vendor/pillar/net/carrier-client.mjs +219 -0
  75. package/src/vendor/pillar/net/delegation.mjs +209 -0
  76. package/src/vendor/pillar/net/envelope.mjs +258 -0
  77. package/src/vendor/pillar/net/refusals.mjs +129 -0
  78. package/src/vendor/pillar/net/seed-tier.mjs +185 -0
@@ -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
+ }