@sema-agent/core 5.21.0 → 5.22.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 (73) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +6 -3
  3. package/dist/agents/subagent.d.ts +6 -0
  4. package/dist/agents/subagent.js +45 -4
  5. package/dist/brain/errors.d.ts +20 -0
  6. package/dist/brain/errors.js +40 -0
  7. package/dist/brain/retry.d.ts +16 -2
  8. package/dist/brain/retry.js +3 -2
  9. package/dist/brain/status-sink.d.ts +9 -2
  10. package/dist/brain/stream-engine.d.ts +22 -0
  11. package/dist/brain/stream-engine.js +41 -10
  12. package/dist/core/ask-class.d.ts +48 -0
  13. package/dist/core/ask-class.js +33 -0
  14. package/dist/core/checkpoint-store.d.ts +103 -10
  15. package/dist/core/checkpoint-store.js +3 -1
  16. package/dist/core/governance-codes.d.ts +38 -0
  17. package/dist/core/governance-codes.js +11 -0
  18. package/dist/core/hooks.d.ts +39 -0
  19. package/dist/core/hooks.js +26 -2
  20. package/dist/core/locked-config.d.ts +7 -1
  21. package/dist/core/locked-config.js +2 -1
  22. package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
  23. package/dist/core/memory-engine/delegation-provenance.js +26 -0
  24. package/dist/core/memory-engine/engine.d.ts +67 -1
  25. package/dist/core/memory-engine/engine.js +270 -12
  26. package/dist/core/memory-engine/header-hints.d.ts +30 -0
  27. package/dist/core/memory-engine/header-hints.js +41 -0
  28. package/dist/core/memory-engine/index.d.ts +3 -2
  29. package/dist/core/memory-engine/index.js +3 -2
  30. package/dist/core/memory-engine/layout.d.ts +166 -0
  31. package/dist/core/memory-engine/layout.js +399 -0
  32. package/dist/core/memory-engine/tools.d.ts +30 -0
  33. package/dist/core/memory-engine/tools.js +108 -17
  34. package/dist/core/permission-rule-consent.d.ts +25 -9
  35. package/dist/core/permission-rule-consent.js +91 -20
  36. package/dist/core/permission-rule-model.d.ts +9 -1
  37. package/dist/core/permission-rule-model.js +2 -2
  38. package/dist/core/permission-rule-org.d.ts +161 -0
  39. package/dist/core/permission-rule-org.js +211 -0
  40. package/dist/core/permission-rule-store.d.ts +249 -6
  41. package/dist/core/permission-rule-store.js +313 -3
  42. package/dist/core/permission-rule-sync.d.ts +131 -0
  43. package/dist/core/permission-rule-sync.js +314 -0
  44. package/dist/core/runner/prepare-memory.js +35 -8
  45. package/dist/core/runner/prepare-task.d.ts +54 -1
  46. package/dist/core/runner/prepare-task.js +246 -27
  47. package/dist/core/runner/runtask.js +147 -6
  48. package/dist/core/shared-memory/contract.js +19 -4
  49. package/dist/core/shared-memory/normalize.d.ts +3 -1
  50. package/dist/core/shared-memory/tools.js +73 -17
  51. package/dist/core/shared-memory/types.d.ts +27 -1
  52. package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
  53. package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
  54. package/dist/core/task-notification.d.ts +5 -2
  55. package/dist/core/task-registry-agent.d.ts +1 -1
  56. package/dist/core/task-registry-agent.js +6 -2
  57. package/dist/core/task-registry-shared.d.ts +9 -2
  58. package/dist/core/task-registry.d.ts +9 -3
  59. package/dist/core/task-registry.js +2 -0
  60. package/dist/core/tool-policy.d.ts +120 -2
  61. package/dist/core/tool-policy.js +116 -6
  62. package/dist/core/trace.d.ts +32 -1
  63. package/dist/core/types.d.ts +56 -3
  64. package/dist/index.d.ts +12 -7
  65. package/dist/index.js +10 -5
  66. package/dist/stores/file/checkpoint-store.d.ts +4 -0
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
  69. package/dist/stores/file/permission-rule-adopt.js +95 -0
  70. package/dist/stores/file/permission-rule-store.d.ts +80 -2
  71. package/dist/stores/file/permission-rule-store.js +189 -46
  72. package/dist/tools/fs/fs-search-tools.js +0 -1
  73. package/package.json +1 -1
@@ -1,5 +1,8 @@
1
1
  import { canonicalize } from "./canonical-json.js";
2
- import { isRuleLive } from "./permission-rule-model.js";
2
+ import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
+ export function sameRuleOwner(a, b) {
4
+ return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
5
+ }
3
6
  export const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
4
7
  export function writerOf(store) {
5
8
  const w = store[PERMISSION_RULE_WRITER];
@@ -43,6 +46,280 @@ export function assertDeleteDeltaCarriesNoAdd(delta) {
43
46
  throw new Error("a tighten-delete must carry a tombstone naming at least one observed add dot");
44
47
  }
45
48
  }
49
+ function isValidScope(scope) {
50
+ const sc = scope;
51
+ return sc?.kind === "global" || (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "");
52
+ }
53
+ function isValidDot(dot) {
54
+ const d = dot;
55
+ return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
56
+ }
57
+ function scopeKey(scope) {
58
+ return JSON.stringify(scope.kind === "global" ? ["g"] : ["p", scope.root]);
59
+ }
60
+ function dotKey(dot) {
61
+ return JSON.stringify([dot.actor, dot.counter]);
62
+ }
63
+ function ruleIdentityKey(rule, scope) {
64
+ return JSON.stringify([rule, scopeKey(scope)]);
65
+ }
66
+ function compareDots(a, b) {
67
+ return a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : a.counter - b.counter;
68
+ }
69
+ export function dotAtOrBelowFrontier(dot, frontier) {
70
+ const cap = frontier[dot.actor];
71
+ return typeof cap === "number" && Number.isFinite(cap) && dot.counter <= cap;
72
+ }
73
+ export function ruleSyncVector(state) {
74
+ const out = {};
75
+ const feed = (dot) => {
76
+ const cur = out[dot.actor];
77
+ if (cur === undefined || dot.counter > cur)
78
+ out[dot.actor] = dot.counter;
79
+ };
80
+ for (const r of state.rules)
81
+ for (const a of r.adds)
82
+ feed(a.dot);
83
+ for (const t of state.tombstones) {
84
+ feed(t.deletedBy);
85
+ for (const d of t.removedDots)
86
+ feed(d);
87
+ }
88
+ return out;
89
+ }
90
+ export function joinFrontiers(a, b) {
91
+ const out = { ...a };
92
+ for (const [actor, counter] of Object.entries(b)) {
93
+ const cur = out[actor];
94
+ if (cur === undefined || counter > cur)
95
+ out[actor] = counter;
96
+ }
97
+ return out;
98
+ }
99
+ export function normalizePersistedRule(r) {
100
+ const parsed = parseAllowRuleText(r.rule);
101
+ if ("reject" in parsed)
102
+ return { reject: "invalid_rule_text" };
103
+ const p = parsed.rule;
104
+ if (p.rule !== r.rule)
105
+ return { reject: "invalid_rule_text" };
106
+ if (r.tool !== p.tool || r.match !== p.match || r.command !== p.command)
107
+ return { reject: "metadata_mismatch" };
108
+ return { rule: r };
109
+ }
110
+ export function screenRuleSyncState(state) {
111
+ const rejected = [];
112
+ const rules = [];
113
+ for (const r of state.rules) {
114
+ if (!Array.isArray(r.adds) || r.adds.length === 0)
115
+ continue;
116
+ const n = normalizePersistedRule(r);
117
+ if ("reject" in n) {
118
+ for (const a of r.adds)
119
+ rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
120
+ continue;
121
+ }
122
+ rules.push(r);
123
+ }
124
+ return { state: { rules, tombstones: state.tombstones }, rejected };
125
+ }
126
+ export function joinRuleStates(a, b) {
127
+ const sa = screenRuleSyncState(a).state;
128
+ const sb = screenRuleSyncState(b).state;
129
+ const ruleGroups = new Map();
130
+ for (const r of [...sa.rules, ...sb.rules]) {
131
+ const key = ruleIdentityKey(r.rule, r.scope);
132
+ const existing = ruleGroups.get(key);
133
+ if (existing === undefined) {
134
+ ruleGroups.set(key, { ...r, adds: [...r.adds] });
135
+ continue;
136
+ }
137
+ for (const add of r.adds) {
138
+ const i = existing.adds.findIndex((x) => sameDot(x.dot, add.dot));
139
+ if (i === -1)
140
+ existing.adds.push({ ...add });
141
+ else if (canonicalize(add) < canonicalize(existing.adds[i]))
142
+ existing.adds[i] = { ...add };
143
+ }
144
+ }
145
+ const rules = [...ruleGroups.values()]
146
+ .map((r) => ({ ...r, adds: [...r.adds].sort((x, y) => compareDots(x.dot, y.dot)) }))
147
+ .sort((x, y) => (x.rule < y.rule ? -1 : x.rule > y.rule ? 1 : scopeKey(x.scope) < scopeKey(y.scope) ? -1 : scopeKey(x.scope) > scopeKey(y.scope) ? 1 : 0));
148
+ const tombGroups = new Map();
149
+ for (const t of [...sa.tombstones, ...sb.tombstones]) {
150
+ if (!Array.isArray(t.removedDots) || t.removedDots.length === 0)
151
+ continue;
152
+ const key = JSON.stringify([t.rule, scopeKey(t.scope), dotKey(t.deletedBy)]);
153
+ const existing = tombGroups.get(key);
154
+ if (existing === undefined) {
155
+ tombGroups.set(key, { ...t, removedDots: [...t.removedDots] });
156
+ continue;
157
+ }
158
+ for (const d of t.removedDots)
159
+ if (!existing.removedDots.some((x) => sameDot(x, d)))
160
+ existing.removedDots.push(d);
161
+ }
162
+ const tombstones = [...tombGroups.values()]
163
+ .map((t) => ({ ...t, removedDots: [...t.removedDots].sort(compareDots) }))
164
+ .sort((x, y) => x.rule < y.rule ? -1 : x.rule > y.rule ? 1 : scopeKey(x.scope) < scopeKey(y.scope) ? -1 : scopeKey(x.scope) > scopeKey(y.scope) ? 1 : compareDots(x.deletedBy, y.deletedBy));
165
+ return { rules, tombstones };
166
+ }
167
+ export function collectBelowFrontier(state, frontier) {
168
+ const collectable = state.tombstones.filter((t) => dotAtOrBelowFrontier(t.deletedBy, frontier) && t.removedDots.every((d) => dotAtOrBelowFrontier(d, frontier)));
169
+ if (collectable.length === 0)
170
+ return { state, collectedTombstones: 0 };
171
+ const keep = state.tombstones.filter((t) => !collectable.includes(t));
172
+ const rules = state.rules
173
+ .map((r) => {
174
+ const covered = collectable.filter((t) => t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
175
+ return covered.length === 0 ? r : { ...r, adds: r.adds.filter((a) => !covered.some((d) => sameDot(d, a.dot))) };
176
+ })
177
+ .filter((r) => r.adds.length > 0);
178
+ return { state: { rules, tombstones: keep }, collectedTombstones: collectable.length };
179
+ }
180
+ export function applySyncJoin(cur, delta, nowMs) {
181
+ if (delta.kind !== "sync-join" || delta.inbound === null || typeof delta.inbound !== "object" || !Array.isArray(delta.inbound.rules) || !Array.isArray(delta.inbound.tombstones)) {
182
+ throw new Error("a sync-join delta must carry an inbound state with rules and tombstones arrays");
183
+ }
184
+ for (const t of delta.inbound.tombstones) {
185
+ if (!Array.isArray(t?.removedDots) || t.removedDots.length === 0) {
186
+ throw new Error("an inbound tombstone must name at least one observed add dot — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)");
187
+ }
188
+ if (!isValidScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
189
+ throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
190
+ }
191
+ const canonicalTomb = parseAllowRuleText(t.rule);
192
+ if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
193
+ throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${t.rule}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
194
+ }
195
+ }
196
+ const droppedInbound = [];
197
+ const quarantinedNow = [];
198
+ const screened = screenRuleSyncState(delta.inbound);
199
+ droppedInbound.push(...screened.rejected);
200
+ const forgedDot = (dot) => dot.actor === cur.actor && dot.counter > cur.counter;
201
+ const dotIdentity = new Map();
202
+ for (const r of cur.rules)
203
+ for (const a of r.adds)
204
+ dotIdentity.set(dotKey(a.dot), ruleIdentityKey(r.rule, r.scope));
205
+ for (const q of cur.quarantined)
206
+ dotIdentity.set(dotKey(q.add.dot), ruleIdentityKey(q.rule, q.scope));
207
+ const inboundClaims = new Map();
208
+ for (const r of screened.state.rules) {
209
+ const identity = ruleIdentityKey(r.rule, r.scope);
210
+ for (const a of r.adds) {
211
+ const claims = inboundClaims.get(dotKey(a.dot)) ?? new Set();
212
+ claims.add(identity);
213
+ inboundClaims.set(dotKey(a.dot), claims);
214
+ }
215
+ }
216
+ const inboundTombstones = screened.state.tombstones.filter((t) => {
217
+ const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
218
+ if (forged === undefined)
219
+ return true;
220
+ droppedInbound.push({ rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
221
+ return false;
222
+ });
223
+ const tombstoneRejected = inboundTombstones.length !== screened.state.tombstones.length;
224
+ let withheldAdds = 0;
225
+ const inboundRules = [];
226
+ for (const r of screened.state.rules) {
227
+ if (tombstoneRejected) {
228
+ withheldAdds += r.adds.length;
229
+ continue;
230
+ }
231
+ if (!isValidScope(r.scope)) {
232
+ for (const a of r.adds)
233
+ droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
234
+ continue;
235
+ }
236
+ const identity = ruleIdentityKey(r.rule, r.scope);
237
+ const kept = r.adds.filter((a) => {
238
+ if (!isValidDot(a.dot)) {
239
+ droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
240
+ return false;
241
+ }
242
+ if (forgedDot(a.dot)) {
243
+ droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
244
+ return false;
245
+ }
246
+ const known = dotIdentity.get(dotKey(a.dot));
247
+ if ((known !== undefined && known !== identity) || (inboundClaims.get(dotKey(a.dot))?.size ?? 0) > 1) {
248
+ droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "dot_identity_conflict" });
249
+ return false;
250
+ }
251
+ return true;
252
+ });
253
+ if (kept.length > 0)
254
+ inboundRules.push({ ...r, adds: kept });
255
+ }
256
+ const inbound = { rules: inboundRules, tombstones: inboundTombstones };
257
+ const localScreen = screenRuleSyncState({ rules: cur.rules, tombstones: cur.tombstones });
258
+ const quarantined = [...cur.quarantined];
259
+ const quarantineKey = (rule, scope, dot) => JSON.stringify([rule, scopeKey(scope), dotKey(dot)]);
260
+ const pushQuarantine = (rule, scope, add, reason) => {
261
+ if (quarantined.some((q) => q.rule === rule && sameScope(q.scope, scope) && sameDot(q.add.dot, add.dot)))
262
+ return;
263
+ quarantined.push({ rule, scope, add, reason, atMs: nowMs });
264
+ quarantinedNow.push({ rule, scope, dot: add.dot, reason });
265
+ };
266
+ for (const rej of localScreen.rejected) {
267
+ const row = cur.rules.find((r) => r.rule === rej.rule && sameScope(r.scope, rej.scope));
268
+ const add = row?.adds.find((a) => sameDot(a.dot, rej.dot));
269
+ if (add !== undefined && (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch")) {
270
+ pushQuarantine(rej.rule, rej.scope, add, rej.reason);
271
+ }
272
+ }
273
+ let joined = joinRuleStates(localScreen.state, inbound);
274
+ let collectedTombstones = 0;
275
+ if (delta.gcFrontier !== undefined) {
276
+ const collected = collectBelowFrontier(joined, delta.gcFrontier);
277
+ joined = collected.state;
278
+ collectedTombstones = collected.collectedTombstones;
279
+ const inboundDots = new Set(inbound.rules.flatMap((r) => r.adds.map((a) => dotKey(a.dot))));
280
+ const live = applyTombstones(joined.rules, joined.tombstones);
281
+ for (const r of live) {
282
+ for (const a of r.adds) {
283
+ if (dotAtOrBelowFrontier(a.dot, delta.gcFrontier) && !inboundDots.has(dotKey(a.dot))) {
284
+ pushQuarantine(r.rule, r.scope, a, "below_gc_frontier");
285
+ }
286
+ }
287
+ }
288
+ }
289
+ for (const q of delta.quarantine ?? []) {
290
+ for (const dot of q.dots) {
291
+ if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
292
+ throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
293
+ }
294
+ const row = joined.rules.find((r) => r.rule === q.rule && sameScope(r.scope, q.scope));
295
+ const add = row?.adds.find((a) => sameDot(a.dot, dot));
296
+ if (add === undefined)
297
+ continue;
298
+ pushQuarantine(q.rule, q.scope, add, q.reason);
299
+ }
300
+ }
301
+ const quarantinedRows = new Set(quarantined.map((q) => quarantineKey(q.rule, q.scope, q.add.dot)));
302
+ const rules = joined.rules
303
+ .map((r) => ({ ...r, adds: r.adds.filter((a) => !quarantinedRows.has(quarantineKey(r.rule, r.scope, a.dot))) }))
304
+ .filter((r) => r.adds.length > 0);
305
+ return {
306
+ next: {
307
+ actor: cur.actor,
308
+ counter: cur.counter,
309
+ rules,
310
+ tombstones: joined.tombstones,
311
+ quarantined,
312
+ observedVector: delta.observedVector !== undefined ? joinFrontiers(cur.observedVector ?? {}, delta.observedVector) : cur.observedVector,
313
+ },
314
+ report: { droppedInbound, quarantined: quarantinedNow, collectedTombstones, withheldAdds },
315
+ };
316
+ }
317
+ export function assertRedemptionNotQuarantined(quarantined, delta) {
318
+ const hit = quarantined.find((q) => sameDot(q.add.dot, delta.add.dot));
319
+ if (hit !== undefined) {
320
+ throw new Error(`this approval's add (dot ${delta.add.dot.actor}#${delta.add.dot.counter}) was quarantined (${hit.reason}) — replaying the old record cannot revive it; a new consent (a new ask, a new record, a new dot) is required`);
321
+ }
322
+ }
46
323
  export async function ruleStoreChecksum(payload) {
47
324
  const { createHash } = await import("node:crypto");
48
325
  return `sha256:${createHash("sha256").update(canonicalize(payload), "utf8").digest("hex")}`;
@@ -110,14 +387,18 @@ export const EMPTY_RULE_STORE = {
110
387
  };
111
388
  export class InMemoryPermissionRuleStore {
112
389
  actor;
390
+ now;
113
391
  durability = "process-local";
114
392
  fidelity = "structured-clone";
115
393
  rules = [];
116
394
  tombstones = [];
395
+ quarantinedRows = [];
396
+ observedVector;
117
397
  rev = 0;
118
398
  counter = 0;
119
- constructor(actor = `mem-${Math.random().toString(36).slice(2, 10)}`) {
399
+ constructor(actor = `mem-${Math.random().toString(36).slice(2, 10)}`, now = Date.now) {
120
400
  this.actor = actor;
401
+ this.now = now;
121
402
  }
122
403
  async list() {
123
404
  return {
@@ -126,18 +407,47 @@ export class InMemoryPermissionRuleStore {
126
407
  rev: this.rev,
127
408
  };
128
409
  }
410
+ async quarantined() {
411
+ return structuredClone(this.quarantinedRows);
412
+ }
129
413
  [PERMISSION_RULE_WRITER] = {
130
414
  nextDot: async () => ({ actor: this.actor, counter: ++this.counter }),
415
+ readRaw: async () => structuredClone({
416
+ actor: this.actor,
417
+ counter: this.counter,
418
+ rev: this.rev,
419
+ rules: this.rules,
420
+ tombstones: this.tombstones,
421
+ ...(this.observedVector !== undefined ? { observedVector: this.observedVector } : {}),
422
+ quarantined: this.quarantinedRows,
423
+ }),
131
424
  apply: async (delta, opts) => {
132
425
  if (opts.expectedRev !== this.rev)
133
426
  return { conflict: true, rev: this.rev };
134
427
  if (delta.kind === "redemption-add") {
428
+ assertRedemptionNotQuarantined(this.quarantinedRows, delta);
135
429
  this.rules = foldDelta(this.rules, delta);
136
430
  }
137
- else {
431
+ else if (delta.kind === "tighten-delete") {
138
432
  assertDeleteDeltaCarriesNoAdd(delta);
139
433
  this.tombstones = [...this.tombstones, delta.tombstone];
140
434
  }
435
+ else {
436
+ const { next, report } = applySyncJoin({
437
+ actor: this.actor,
438
+ counter: this.counter,
439
+ rules: this.rules,
440
+ tombstones: this.tombstones,
441
+ quarantined: this.quarantinedRows,
442
+ ...(this.observedVector !== undefined ? { observedVector: this.observedVector } : {}),
443
+ }, delta, this.now());
444
+ this.rules = next.rules;
445
+ this.tombstones = next.tombstones;
446
+ this.quarantinedRows = next.quarantined;
447
+ this.observedVector = next.observedVector;
448
+ this.rev += 1;
449
+ return { rev: this.rev, sync: report };
450
+ }
141
451
  this.rev += 1;
142
452
  return { rev: this.rev };
143
453
  },
@@ -0,0 +1,131 @@
1
+ /**
2
+ * design/182 §4 — the cloud-sync client for persisted allow rules.
3
+ *
4
+ * One round = one RTT, full state both directions. The data model is already a state CRDT (OR-Set:
5
+ * per-add dots + observed-remove tombstones), so sync is the JOIN of two states — no cursors, no
6
+ * baselines, no three-way reconcile, and the whole cursor-retention/prefill/echo family of defenses the
7
+ * memory sync face carries is structurally absent here (a registered, deliberate divergence). What IS
8
+ * inherited from that face is the contract style: an injected transport (core bundles no fetch), the
9
+ * transport running BEFORE any local write (a throw aborts the round with zero local effects), a
10
+ * picked-not-spread response parse, and a pure join both ends share.
11
+ *
12
+ * The safety polarity is the opposite of memory's: a memory sync bug loses data; a rule sync bug
13
+ * WIDENS what runs without asking. Every ambiguous call here therefore resolves toward the tightening
14
+ * side — the tighten-only partial application (§4.3), the frontier fence (§5.2), and the rule that a
15
+ * LOCAL row only ever leaves the live view through an explicit, disclosed quarantine instruction.
16
+ *
17
+ * ## Trust domain (stated, not defended against)
18
+ *
19
+ * Connecting a principal's store to a sync endpoint merges that principal's devices, the server and
20
+ * every device's transport into ONE consent trust domain: any compromised member can mint persisting
21
+ * allow rules for the whole domain (an injected add rides the next full-state exchange everywhere).
22
+ * Per-add provenance keeps the minting replica auditable, resurrection/landing disclosures keep changes
23
+ * visible, and the single validator still refuses every illegal SHAPE from the wire — but the
24
+ * "one human click behind every add" guarantee is domain-level, not device-level, until the
25
+ * privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
26
+ * level they are buying.
27
+ */
28
+ import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
+ import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
+ /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
31
+ * retries; a non-2xx / network failure should THROW (the round then aborts with zero local effects —
32
+ * the transport runs before any local write). */
33
+ export type PermissionRuleSyncTransport = (path: string, body: unknown) => Promise<unknown>;
34
+ /** The one wire path of this protocol. */
35
+ export declare const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
36
+ /** The wire request: the local raw state, whole. `replica` is this store's actor id — an observation /
37
+ * frontier-accounting fact, NEVER an identity input; `principal` is an envelope echo — authentication
38
+ * lives on the transport's authenticated channel, and the server must never trust this field. */
39
+ export interface RuleSyncRequestBody {
40
+ principal: string;
41
+ replica: string;
42
+ state: RuleSyncState;
43
+ observedVector?: RuleSyncFrontier;
44
+ }
45
+ /** The wire response (server twin: join its persisted state with the request state, answer the whole
46
+ * merged form). `gcFrontier` is the COLLECTED watermark (§5.1) — absent in v2.0 by protocol. */
47
+ export interface RuleSyncResponseBody {
48
+ principal: string;
49
+ merged: RuleSyncState;
50
+ gcFrontier?: RuleSyncFrontier;
51
+ dropped?: Array<{
52
+ rule: string;
53
+ scope: RuleScope;
54
+ dot: RuleDot;
55
+ reason: string;
56
+ }>;
57
+ }
58
+ export interface PermissionRuleSyncResult {
59
+ /** True ⟺ the round landed with zero drops, zero quarantines and zero warnings. */
60
+ ok: boolean;
61
+ /** Local set sizes at push time (full-state protocol: the wire carries the whole set). */
62
+ pushed: {
63
+ addDots: number;
64
+ tombstones: number;
65
+ };
66
+ /** What the landing added relative to the local pre-state. */
67
+ landed: {
68
+ newAdds: number;
69
+ newTombstones: number;
70
+ };
71
+ /** (rule, scope) pairs that went removed → live in this round — add-wins made visible (§4.6). */
72
+ resurrected: Array<{
73
+ rule: string;
74
+ scope: RuleScope;
75
+ byDot: RuleDot;
76
+ }>;
77
+ /** Inbound refusals and local quarantine moves, typed (closed reason set, governance-codes family). */
78
+ dropped: RuleSyncDrop[];
79
+ /** The store revision after landing (the pre-round revision when nothing landed). */
80
+ rev: number;
81
+ /** Honest disclosures for tolerated protocol irregularities (withheld adds, unshapeable entries…). */
82
+ warnings?: string[];
83
+ }
84
+ /** A typed, DISCRIMINABLE refusal: a local-owner bucket has no cloud home until it is adopted. Distinct
85
+ * by `code` from a missing principal, so "not signed in" and "this bucket cannot sync" never collapse
86
+ * into one indistinguishable failure shape. */
87
+ export declare const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
88
+ /**
89
+ * Run one sync round for one principal's bucket.
90
+ *
91
+ * Order of operations is the contract: read raw → transport (no local write yet; a throw aborts with
92
+ * zero local effects) → validate the response (picked-not-spread; principal echo mismatch throws) →
93
+ * partition tighten-only (§4.3) → land the WHOLE round as ONE `sync-join` delta under optimistic
94
+ * concurrency — a conflict re-reads and RE-JOINS (the join's commutativity/idempotence makes the
95
+ * recompute correct by construction; adds and tombstones that landed during the RTT survive it).
96
+ */
97
+ export declare function syncPermissionRules(opts: {
98
+ provider: PermissionRuleStoreProvider;
99
+ principal: string;
100
+ /** Accepted only as `kind: "principal"` (and it must agree with `principal`). A local-owner value is
101
+ * refused with the typed {@link LOCAL_OWNER_UNSYNCABLE_CODE} error — see design/182 §4.5. */
102
+ owner?: RuleOwner;
103
+ transport: PermissionRuleSyncTransport;
104
+ /** Injected clock — observation timestamps only, never adjudication input. */
105
+ now?: () => number;
106
+ }): Promise<PermissionRuleSyncResult>;
107
+ interface ParsedRuleSyncResponse {
108
+ principal: string;
109
+ merged: {
110
+ rules: PersistedAllowRule[];
111
+ tombstones: Array<RuleTombstone | undefined>;
112
+ };
113
+ gcFrontier?: RuleSyncFrontier;
114
+ dropped: Array<{
115
+ rule: string;
116
+ scope: RuleScope;
117
+ dot: RuleDot;
118
+ reason: string;
119
+ }>;
120
+ warnings: string[];
121
+ }
122
+ /**
123
+ * Parse one wire response. Envelope violations (missing/mistyped principal, echo mismatch, no merged
124
+ * state, an unparseable frontier) THROW — a protocol error aborts the round with zero local effects.
125
+ * ENTRY-level violations degrade per §4.3: an unshapeable rule/add is skipped with a warning (it never
126
+ * reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
127
+ * tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
128
+ * below is PICKED off the response, the object itself is discarded.
129
+ */
130
+ export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
131
+ export {};