@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
@@ -28,7 +28,7 @@
28
28
  * defended against: for a file backend, host = user, no more and no less.
29
29
  */
30
30
  import { type RuleScope, type RuleDot } from "./permission-rule-model.js";
31
- import type { PermissionRuleStoreProvider } from "./permission-rule-store.js";
31
+ import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
32
32
  /** One candidate rule inside an approval record: the exact text and where it would apply. */
33
33
  export interface RuleCandidate {
34
34
  rule: string;
@@ -36,10 +36,18 @@ export interface RuleCandidate {
36
36
  }
37
37
  /** What the approval record is for — it decides the provenance the minted adds carry. */
38
38
  export type RuleApprovalKind = "card" | "import" | "starter";
39
- /** A durable record of one pending/settled human decision about minting rules. */
39
+ /** A durable record of one pending/settled human decision about minting rules.
40
+ *
41
+ * Identity (design/182 §4.5, additive): a v1 record carries `principal` and nothing else — it stays
42
+ * valid forever and projects at runtime to a principal-kind {@link RuleOwner} (zero migration; pending
43
+ * and redeemed-but-unlanded records redeem across the upgrade unchanged). Only the local-owner path
44
+ * mints records with the `owner` field and NO principal. A record carrying both, disagreeing, is
45
+ * contradictory and unredeemable. */
40
46
  export interface RuleApprovalRecord {
41
47
  id: string;
42
- principal: string;
48
+ principal?: string;
49
+ /** Structural owner (additive). Absent on every record a v1 tree minted. */
50
+ owner?: RuleOwner;
43
51
  kind: RuleApprovalKind;
44
52
  state: "pending" | "approved" | "redeemed";
45
53
  candidates: RuleCandidate[];
@@ -118,7 +126,9 @@ export declare function mintRuleTicket(recordId: string, candidateIndex: number)
118
126
  * engine at all.
119
127
  */
120
128
  export declare function prepareCardApproval(opts: {
121
- principal: string;
129
+ principal?: string;
130
+ /** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
131
+ owner?: RuleOwner;
122
132
  /** The tool whose ask this card belongs to. Only the shell tool the rule lane speaks for is accepted. */
123
133
  toolName: string;
124
134
  /** The command as adjudicated — the same bytes the ask carried. */
@@ -143,7 +153,9 @@ export declare function prepareCardApproval(opts: {
143
153
  */
144
154
  export declare function confirmRuleApproval(opts: {
145
155
  approvalId: string;
146
- principal: string;
156
+ principal?: string;
157
+ /** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
158
+ owner?: RuleOwner;
147
159
  /**
148
160
  * REQUIRED for a card record: the index of the option the person chose. A card presents alternatives of
149
161
  * different breadth, so "they said yes" is not an answer on its own — "they said yes to THIS one" is.
@@ -182,7 +194,8 @@ export type RedeemResult = {
182
194
  */
183
195
  export declare function redeemRuleTicket(opts: {
184
196
  ticket: RuleTicket;
185
- principal: string;
197
+ principal?: string;
198
+ owner?: RuleOwner;
186
199
  deps: RuleConsentDeps;
187
200
  }): Promise<RedeemResult>;
188
201
  /** The three user-editable settings layers this version reads. */
@@ -238,7 +251,8 @@ export interface ImportResult {
238
251
  */
239
252
  export declare function prepareCcImport(opts: {
240
253
  layers: CcImportLayer[];
241
- principal: string;
254
+ principal?: string;
255
+ owner?: RuleOwner;
242
256
  deps: RuleConsentDeps;
243
257
  }): Promise<{
244
258
  preview: ImportPreview;
@@ -256,7 +270,8 @@ export declare const STARTER_RULES: readonly string[];
256
270
  /** Prepare the starter batch as a pending approval record. Nothing takes effect without confirmation;
257
271
  * an unconfirmed deployment simply starts with an empty rule set. */
258
272
  export declare function prepareStarterBatch(opts: {
259
- principal: string;
273
+ principal?: string;
274
+ owner?: RuleOwner;
260
275
  deps: RuleConsentDeps;
261
276
  }): Promise<{
262
277
  preview: RuleCandidate[];
@@ -268,7 +283,8 @@ export declare function prepareStarterBatch(opts: {
268
283
  */
269
284
  export declare function redeemRuleBatch(opts: {
270
285
  approvalId: string;
271
- principal: string;
286
+ principal?: string;
287
+ owner?: RuleOwner;
272
288
  deps: RuleConsentDeps;
273
289
  }): Promise<ImportResult | {
274
290
  status: "refused";
@@ -1,5 +1,5 @@
1
1
  import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
2
- import { errText, sameScope, writerOf } from "./permission-rule-store.js";
2
+ import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
3
3
  export class InMemoryRuleApprovalRecordStore {
4
4
  rows = new Map();
5
5
  async get(id) {
@@ -36,6 +36,49 @@ function requirePrincipal(principal, entry) {
36
36
  }
37
37
  return principal;
38
38
  }
39
+ function resolveCallerOwner(principal, owner, entry) {
40
+ if (owner !== undefined) {
41
+ if (owner.kind === "principal") {
42
+ const p = requirePrincipal(owner.principal, entry);
43
+ if (typeof principal === "string" && principal !== "" && principal !== p) {
44
+ throw new Error(`${entry}: contradictory identity — principal "${principal}" and owner principal "${p}" disagree`);
45
+ }
46
+ return { kind: "principal", principal: p };
47
+ }
48
+ if (typeof principal === "string" && principal !== "") {
49
+ throw new Error(`${entry}: contradictory identity — a principal and a local-owner were both given; a bucket has one owner`);
50
+ }
51
+ return { kind: "local-owner" };
52
+ }
53
+ return { kind: "principal", principal: requirePrincipal(principal, entry) };
54
+ }
55
+ function ownerOfRecord(rec) {
56
+ if (rec.owner !== undefined) {
57
+ if (rec.owner.kind === "principal") {
58
+ if (typeof rec.owner.principal !== "string" || rec.owner.principal === "")
59
+ return undefined;
60
+ if (typeof rec.principal === "string" && rec.principal !== "" && rec.principal !== rec.owner.principal)
61
+ return undefined;
62
+ return rec.owner;
63
+ }
64
+ if (typeof rec.principal === "string" && rec.principal !== "")
65
+ return undefined;
66
+ return { kind: "local-owner" };
67
+ }
68
+ return typeof rec.principal === "string" && rec.principal !== "" ? { kind: "principal", principal: rec.principal } : undefined;
69
+ }
70
+ function recordIdentityOf(owner) {
71
+ return owner.kind === "principal" ? { principal: owner.principal } : { owner: { kind: "local-owner" } };
72
+ }
73
+ function storeForOwner(provider, owner) {
74
+ if (owner.kind === "principal")
75
+ return provider.forPrincipal(owner.principal);
76
+ const store = provider.forLocalOwner?.();
77
+ if (store === undefined) {
78
+ throw new Error("this deployment's permission-rule provider has no local-owner bucket (forLocalOwner is not implemented)");
79
+ }
80
+ return store;
81
+ }
39
82
  export function mintRuleTicket(recordId, candidateIndex) {
40
83
  return `rt.${candidateIndex}.${recordId}`;
41
84
  }
@@ -46,7 +89,7 @@ function parseRuleTicket(ticket) {
46
89
  return { index: Number(m[1]), recordId: m[2] };
47
90
  }
48
91
  export async function prepareCardApproval(opts) {
49
- const principal = requirePrincipal(opts.principal, "prepareCardApproval");
92
+ const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCardApproval");
50
93
  if ("candidates" in opts) {
51
94
  const e = new Error("prepareCardApproval does not accept caller candidates — card options are minted by the engine from the adjudicated command");
52
95
  e.code = "config.invalid_argument";
@@ -60,7 +103,7 @@ export async function prepareCardApproval(opts) {
60
103
  return undefined;
61
104
  const record = {
62
105
  id: mintId(opts.deps, "rar"),
63
- principal,
106
+ ...recordIdentityOf(owner),
64
107
  kind: "card",
65
108
  state: "pending",
66
109
  rev: 0,
@@ -74,10 +117,11 @@ export async function prepareCardApproval(opts) {
74
117
  }
75
118
  const CARD_RULE_TOOL = "Bash";
76
119
  export async function confirmRuleApproval(opts) {
77
- const principal = requirePrincipal(opts.principal, "confirmRuleApproval");
120
+ const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
78
121
  const no = (reason) => ({ ok: false, reason });
79
122
  const rec = await opts.deps.approvals.get(opts.approvalId);
80
- if (rec === undefined || rec.principal !== principal)
123
+ const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
124
+ if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
81
125
  return no("record_not_found");
82
126
  if (rec.kind === "card") {
83
127
  const chosen = opts.selectedCandidate;
@@ -101,14 +145,15 @@ export async function confirmRuleApproval(opts) {
101
145
  return won ? { ok: true } : no("conflict");
102
146
  }
103
147
  export async function redeemRuleTicket(opts) {
104
- const principal = requirePrincipal(opts.principal, "redeemRuleTicket");
148
+ const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
105
149
  const parsed = parseRuleTicket(opts.ticket);
106
150
  if (parsed === undefined)
107
151
  return { status: "refused", reason: "malformed ticket" };
108
152
  const rec = await opts.deps.approvals.get(parsed.recordId);
109
153
  if (rec === undefined)
110
154
  return { status: "refused", reason: "no such approval record" };
111
- if (rec.principal !== principal)
155
+ const recOwner = ownerOfRecord(rec);
156
+ if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
112
157
  return { status: "refused", reason: "the ticket belongs to a different principal" };
113
158
  if (rec.state === "pending")
114
159
  return { status: "refused", reason: "the approval record has not been confirmed" };
@@ -121,7 +166,13 @@ export async function redeemRuleTicket(opts) {
121
166
  const parsedRule = parseAllowRuleText(candidate.rule);
122
167
  if ("reject" in parsedRule)
123
168
  return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
124
- const store = opts.deps.provider.forPrincipal(principal);
169
+ let store;
170
+ try {
171
+ store = storeForOwner(opts.deps.provider, caller);
172
+ }
173
+ catch (err) {
174
+ return { status: "refused", reason: errText(err) };
175
+ }
125
176
  const writer = writerOf(store);
126
177
  if (writer === undefined)
127
178
  return { status: "refused", reason: "the resolved permission-rule store has no write face" };
@@ -140,14 +191,20 @@ export async function redeemRuleTicket(opts) {
140
191
  const theirs = again?.redeemedDots?.[parsed.index];
141
192
  if (theirs === undefined)
142
193
  return { status: "refused", reason: "the approval record changed state concurrently" };
143
- return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
194
+ return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
144
195
  }
145
196
  }
146
- return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
197
+ return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
147
198
  }
148
199
  const REDEEM_MAX_ATTEMPTS = 8;
149
200
  async function applyRedemption(args) {
150
- const store = args.deps.provider.forPrincipal(args.principal);
201
+ let store;
202
+ try {
203
+ store = storeForOwner(args.deps.provider, args.owner);
204
+ }
205
+ catch (err) {
206
+ return { status: "refused", reason: errText(err) };
207
+ }
151
208
  const writer = writerOf(store);
152
209
  if (writer === undefined)
153
210
  return { status: "refused", reason: "the resolved permission-rule store has no write face" };
@@ -170,7 +227,10 @@ async function applyRedemption(args) {
170
227
  match: args.parsedRule.match,
171
228
  command: args.parsedRule.command,
172
229
  add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
173
- redemption: { recordId: args.recordId, principal: args.principal },
230
+ redemption: {
231
+ recordId: args.recordId,
232
+ ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
233
+ },
174
234
  }, { expectedRev: rev });
175
235
  }
176
236
  catch (err) {
@@ -187,7 +247,7 @@ function originOfRecordKind(kind) {
187
247
  }
188
248
  const IMPORT_UNCOVERED = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
189
249
  export async function prepareCcImport(opts) {
190
- const principal = requirePrincipal(opts.principal, "prepareCcImport");
250
+ const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport");
191
251
  const candidates = [];
192
252
  const skipped = [];
193
253
  const layers = [];
@@ -234,7 +294,7 @@ export async function prepareCcImport(opts) {
234
294
  }
235
295
  const record = {
236
296
  id: mintId(opts.deps, "rar"),
237
- principal,
297
+ ...recordIdentityOf(owner),
238
298
  kind: "import",
239
299
  state: "pending",
240
300
  rev: 0,
@@ -256,7 +316,7 @@ export const STARTER_RULES = [
256
316
  "Bash(npm --version)",
257
317
  ];
258
318
  export async function prepareStarterBatch(opts) {
259
- const principal = requirePrincipal(opts.principal, "prepareStarterBatch");
319
+ const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareStarterBatch");
260
320
  const candidates = [];
261
321
  for (const text of STARTER_RULES) {
262
322
  const parsed = parseAllowRuleText(text);
@@ -266,7 +326,7 @@ export async function prepareStarterBatch(opts) {
266
326
  }
267
327
  const record = {
268
328
  id: mintId(opts.deps, "rar"),
269
- principal,
329
+ ...recordIdentityOf(owner),
270
330
  kind: "starter",
271
331
  state: "pending",
272
332
  rev: 0,
@@ -277,11 +337,12 @@ export async function prepareStarterBatch(opts) {
277
337
  return { preview: candidates, approvalId: record.id };
278
338
  }
279
339
  export async function redeemRuleBatch(opts) {
280
- const principal = requirePrincipal(opts.principal, "redeemRuleBatch");
340
+ const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleBatch");
281
341
  const rec = await opts.deps.approvals.get(opts.approvalId);
282
342
  if (rec === undefined)
283
343
  return { status: "refused", reason: "no such approval record" };
284
- if (rec.principal !== principal)
344
+ const recOwner = ownerOfRecord(rec);
345
+ if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
285
346
  return { status: "refused", reason: "the record belongs to a different principal" };
286
347
  if (rec.kind === "card")
287
348
  return { status: "refused", reason: "a card record is redeemed by its chosen ticket, not as a batch" };
@@ -291,7 +352,13 @@ export async function redeemRuleBatch(opts) {
291
352
  const deduped = [];
292
353
  const skippedAtRedeem = [];
293
354
  let rev = 0;
294
- const store = opts.deps.provider.forPrincipal(principal);
355
+ let store;
356
+ try {
357
+ store = storeForOwner(opts.deps.provider, caller);
358
+ }
359
+ catch (err) {
360
+ return { status: "refused", reason: errText(err) };
361
+ }
295
362
  for (let i = 0; i < rec.candidates.length; i++) {
296
363
  const candidate = rec.candidates[i];
297
364
  let alreadyThere = false;
@@ -303,7 +370,11 @@ export async function redeemRuleBatch(opts) {
303
370
  catch (err) {
304
371
  return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
305
372
  }
306
- const res = await redeemRuleTicket({ ticket: mintRuleTicket(rec.id, i), principal, deps: opts.deps });
373
+ const res = await redeemRuleTicket({
374
+ ticket: mintRuleTicket(rec.id, i),
375
+ ...(caller.kind === "principal" ? { principal: caller.principal } : { owner: caller }),
376
+ deps: opts.deps,
377
+ });
307
378
  if (res.status === "refused") {
308
379
  skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
309
380
  continue;
@@ -126,8 +126,16 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
126
126
  * This is the ONE validator every entry point shares — minting a suggestion, importing another
127
127
  * product's settings, reloading a store file, and any future backend. A shape refused here cannot enter
128
128
  * the store through any door.
129
+ *
130
+ * `direction` (design/182 §7, additive): the bare-interpreter-prefix refusal is an ALLOW-side argument
131
+ * — one approval click cannot be read as authorizing arbitrary programs. An org deny/ask rule has the
132
+ * opposite polarity: `Bash(node:*)` as a DENY is a legitimately wide tightening, and refusing it would
133
+ * silently disarm exactly the policy an administrator most wants. `"tighten"` therefore admits the
134
+ * interpreter-headed prefix form and changes nothing else; the default stays the allow-side floor.
129
135
  */
130
- export declare function parseAllowRuleText(text: string): {
136
+ export declare function parseAllowRuleText(text: string, opts?: {
137
+ direction?: "allow" | "tighten";
138
+ }): {
131
139
  rule: ParsedAllowRule;
132
140
  } | {
133
141
  reject: RuleReject;
@@ -43,7 +43,7 @@ function foldSpacing(s) {
43
43
  function reject(code, message) {
44
44
  return { reject: { code, message } };
45
45
  }
46
- export function parseAllowRuleText(text) {
46
+ export function parseAllowRuleText(text, opts) {
47
47
  if (text.length > MAX_RULE_TEXT_CHARS) {
48
48
  return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
49
49
  }
@@ -67,7 +67,7 @@ export function parseAllowRuleText(text) {
67
67
  ? reject("invalid.empty_command", `rule "${text}" names no command`)
68
68
  : reject("invalid.not_simple_command", `rule "${text}" is not a single simple command (${floor.reject})`);
69
69
  }
70
- if (match === "prefix" && BARE_INTERPRETER_NAMES.has(floor.name)) {
70
+ if (match === "prefix" && BARE_INTERPRETER_NAMES.has(floor.name) && opts?.direction !== "tighten") {
71
71
  return reject("invalid.bare_interpreter_prefix", `prefix rule "${text}" is headed by the interpreter "${floor.name}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
72
72
  }
73
73
  const command = foldSpacing(body);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * design/182 §7 — the ORGANIZATION rule layer: core's half of the contract.
3
+ *
4
+ * Org rules and personal rules are opposite in polarity (deny/ask vs allow), authority (a server-side
5
+ * administrator publish vs one human click per add) and evolution (whole-snapshot replacement vs
6
+ * convergent merge). So the org layer has NO CRDT, no tombstones and no join: the server is the single
7
+ * authority, a client holds a read-only versioned snapshot, an update is a whole replacement (revision
8
+ * strictly ordered), and a rollback is a NEW revision carrying old content. Cross-layer tombstones are
9
+ * structurally impossible — an org rule never enters `PersistedAllowRule`, and an org snapshot replace
10
+ * never touches a personal store's bytes. The two layers meet only at the CONSUMPTION site, where org
11
+ * deny/ask folds strict-side: **org deny > org ask (non-dismissable) > personal allow rule > bare ask**.
12
+ *
13
+ * ## Availability semantics (the fail-closed shape, ruled through three review rounds)
14
+ *
15
+ * A deployment DECLARES governance (`governed: true`) — governance is never inferred from wiring, so a
16
+ * forgotten provider is a loud boot contradiction instead of a silently ungoverned deployment. Under
17
+ * that declaration the org adjudication surface must ALWAYS be effective:
18
+ * - the last successfully installed snapshot persists durably (`PersistedOrgRuleState`) and keeps
19
+ * adjudicating while the provider is unreachable, up to a deployment-configured staleness bound;
20
+ * - beyond the bound (or before any snapshot was ever installed) the resolution is `unavailable`, and
21
+ * the CONSUMING gate must fail the ENTIRE decision boundary closed: every terminal allow — policy
22
+ * allow and default allow included — tightens to an ask carrying `decisionReason: "org_unavailable"`
23
+ * AND `requiresRealApproval: true` (the same non-dismissable bit an org ask itself carries; without
24
+ * it a blanket `onAsk: "allow"` deployment would clear the synthetic ask — a third fail-open path),
25
+ * with the personal-rule lane and the auto-mode classifier both disarmed. That obligation lives at
26
+ * the gate integration; THIS module's contract is to report `unavailable` honestly and never lapse
27
+ * into an empty-rules answer that reads as "not governed".
28
+ *
29
+ * ## Anti-rollback
30
+ *
31
+ * `revision` is an orderable server-monotone number, and the installed high-water mark persists with
32
+ * the snapshot in ONE atomic write: a replayed older snapshot (fresh `fetchedAtMs`, old content) is
33
+ * refused and disclosed — identity comparison alone cannot catch it, and an in-memory-only mark would
34
+ * accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
35
+ * separation ticket; the high-water mark is the floor that needs no key distribution.
36
+ */
37
+ import { type RuleScope } from "./permission-rule-model.js";
38
+ import { type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleOwner } from "./permission-rule-store.js";
39
+ /** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
40
+ export interface OrgPermissionRule {
41
+ rule: string;
42
+ behavior: "deny" | "ask";
43
+ }
44
+ /** The read-only versioned snapshot a client holds. */
45
+ export interface OrgRuleSnapshot {
46
+ /** Server-monotone orderable revision. A snapshot below the persisted high-water mark is refused. */
47
+ revision: number;
48
+ rules: OrgPermissionRule[];
49
+ /** Reserved for the privilege-separation ticket's signed-snapshot contract. Carried, not verified here. */
50
+ signature?: string;
51
+ /** Observation timestamp (epoch ms) — staleness input only, never adjudication input. */
52
+ fetchedAtMs: number;
53
+ }
54
+ /** Where the deployment fetches the current snapshot. `undefined` = the provider has none to give. */
55
+ export interface OrgRuleSnapshotProvider {
56
+ current(): Promise<OrgRuleSnapshot | undefined>;
57
+ }
58
+ /**
59
+ * The durable org state: last-known-good snapshot + the anti-rollback high-water mark, installed in one
60
+ * atomic write. SQL twin note (server/org ticket): `revision_high_water` and `installed_at_ms` are
61
+ * structured columns (`_ms` suffix for epoch milliseconds); the snapshot itself is a JSON blob — it is
62
+ * installed and read whole, never row-queried.
63
+ */
64
+ export interface PersistedOrgRuleState {
65
+ revisionHighWater: number;
66
+ snapshot: OrgRuleSnapshot;
67
+ installedAtMs: number;
68
+ }
69
+ /** The durable home of {@link PersistedOrgRuleState}. The file rule-store backend implements it over an
70
+ * org block published with the same O_EXCL/fsync/rename discipline as the rule file itself.
71
+ *
72
+ * CONTRACT: `installOrgState` must REFUSE (throw) an install that would LOWER the persisted
73
+ * `revisionHighWater` — the overlay serializes its own resolves, but two overlay instances over one
74
+ * durable home can interleave read-then-write, and the backend's atomic write is the one place the
75
+ * lowering can be caught. The file backend enforces this under its write serialization. */
76
+ export interface OrgRuleStatePersistence {
77
+ readOrgState(): Promise<PersistedOrgRuleState | undefined>;
78
+ installOrgState(state: PersistedOrgRuleState): Promise<void>;
79
+ }
80
+ /** Duck-typed discovery of the persistence face on a store backend, `writerOf` style. */
81
+ export declare function orgRuleStatePersistenceOf(store: PermissionRuleStore): OrgRuleStatePersistence | undefined;
82
+ /** The `decisionReason` the consuming gate stamps on the synthetic asks it mints while org adjudication
83
+ * is unavailable. Exported so the gate integration and its tests share one spelling. */
84
+ export declare const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
85
+ /** How far into the future a snapshot's `fetchedAtMs` may sit before it is refused — ordinary NTP-level
86
+ * clock skew passes; a far-future timestamp (which would satisfy the staleness bound INDEFINITELY,
87
+ * turning fail-closed into evergreen freshness) does not. */
88
+ export declare const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS: number;
89
+ export type OrgOverlayStatus = "fresh" | "last-known-good" | "unavailable";
90
+ /** What one resolution reports. `rules` is empty ⟺ status `unavailable` or the snapshot is empty —
91
+ * the two are distinguished by `status`, never by the emptiness of the list. */
92
+ export interface OrgOverlayResolution {
93
+ status: OrgOverlayStatus;
94
+ rules: OrgPermissionRule[];
95
+ revision?: number;
96
+ /** Honest-disclosure lines: provider failures, refused snapshots, rollback refusals. */
97
+ disclosures: string[];
98
+ }
99
+ export interface OrgRuleOverlay {
100
+ resolve(): Promise<OrgOverlayResolution>;
101
+ }
102
+ /**
103
+ * Construct the org overlay for a GOVERNED deployment. This constructor IS the boot gate:
104
+ * - `governed` must be literally `true` — the overlay does not exist for ungoverned deployments
105
+ * (their decision path stays byte-identical to a build without this module);
106
+ * - a governed declaration WITHOUT a provider is a configuration contradiction and refuses to boot —
107
+ * a silently ungoverned "governed" deployment is precisely the failure this bit exists to prevent;
108
+ * - `stalenessBoundMs` follows the knob discipline: a non-number, NaN, non-finite or non-positive
109
+ * bound is refused loudly, never coerced.
110
+ */
111
+ export declare function createOrgRuleOverlay(cfg: {
112
+ governed: true;
113
+ provider?: OrgRuleSnapshotProvider;
114
+ /** Durable LKG + high-water home. Omitted ⇒ process-local only, DISCLOSED on every resolution: a
115
+ * restart then forgets both the last-known-good snapshot and the anti-rollback mark. */
116
+ persistence?: OrgRuleStatePersistence;
117
+ stalenessBoundMs: number;
118
+ now?: () => number;
119
+ }): OrgRuleOverlay;
120
+ /**
121
+ * design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
122
+ * behavior the first textual match wins (reporting order only — all denies are the same one answer).
123
+ * Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
124
+ * interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
125
+ * A rule text the validator refuses never gets here — snapshot validation refuses the whole snapshot,
126
+ * so an unenforceable deny cannot install as policy; the skip below is a defensive floor only.
127
+ */
128
+ export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
129
+ tool: string;
130
+ command: string;
131
+ }): {
132
+ behavior: "deny" | "ask";
133
+ rule: string;
134
+ } | undefined;
135
+ /** The effective status of one personal rule under the current org overlay — computed, never stored. */
136
+ export interface EffectivePermissionRule {
137
+ rule: string;
138
+ scope: RuleScope;
139
+ status: "live" | "shadowed-by-org" | "removed";
140
+ }
141
+ /**
142
+ * design/182 §7.3 — the introspection face: every personal rule with its effective status under the org
143
+ * overlay. An org deny does not delete a personal rule — the personal rule is the user's approval
144
+ * record, and the org's power is to OVERRIDE it, not to forge its removal — so a shadowed rule reports
145
+ * `shadowed-by-org` and comes back by itself when the org deny is withdrawn. A rule whose every add is
146
+ * tombstoned reports `removed` (its tombstone identity is still visible in the store).
147
+ *
148
+ * The shadow predicate: an org DENY rule admits the personal rule's command pattern (for a prefix
149
+ * personal rule, the org deny admits its prefix body — a wider org deny shadows every narrower allow
150
+ * under it). org ASK rules do not shadow: the personal lane never consumes a `requiresRealApproval`
151
+ * ask, so the two never actually meet on one call.
152
+ */
153
+ export declare function effectivePermissionRules(opts: {
154
+ provider: PermissionRuleStoreProvider;
155
+ principal?: string;
156
+ owner?: RuleOwner;
157
+ orgSnapshot?: OrgRuleSnapshot;
158
+ }): Promise<EffectivePermissionRule[]>;
159
+ /** Does this store carry a write face? Re-exported convenience for org-ticket integrations that need to
160
+ * distinguish a read-only projection from a writable personal bucket without importing the writer seam. */
161
+ export declare function isWritablePermissionRuleStore(store: PermissionRuleStore): boolean;