@sema-agent/core 5.27.0 → 5.28.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 (39) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/core/hooks.d.ts +22 -0
  3. package/dist/core/hooks.js +22 -3
  4. package/dist/core/memory-engine/engine.js +2 -4
  5. package/dist/core/memory-engine/file-backend.d.ts +66 -7
  6. package/dist/core/memory-engine/file-backend.js +69 -27
  7. package/dist/core/memory-engine/layout.d.ts +31 -2
  8. package/dist/core/memory-engine/layout.js +132 -8
  9. package/dist/core/memory-engine/types.d.ts +6 -5
  10. package/dist/core/permission-rule-consent.d.ts +82 -8
  11. package/dist/core/permission-rule-consent.js +92 -1
  12. package/dist/core/permission-rule-model.d.ts +17 -1
  13. package/dist/core/permission-rule-model.js +21 -0
  14. package/dist/core/permission-rule-org.d.ts +22 -3
  15. package/dist/core/permission-rule-org.js +67 -20
  16. package/dist/core/permission-rule-store.js +2 -2
  17. package/dist/core/permission-rule-sync.d.ts +15 -1
  18. package/dist/core/permission-rule-sync.js +89 -47
  19. package/dist/core/runner/prepare-task.js +8 -3
  20. package/dist/core/runner/runtask.js +8 -1
  21. package/dist/core/task-registry-agent.d.ts +9 -0
  22. package/dist/core/task-registry-agent.js +51 -21
  23. package/dist/core/task-registry-monitor.js +1 -1
  24. package/dist/core/task-registry-shared.d.ts +9 -0
  25. package/dist/core/tool-policy.d.ts +35 -2
  26. package/dist/core/tool-policy.js +37 -3
  27. package/dist/core/tool-result-store.d.ts +108 -7
  28. package/dist/core/tool-result-store.js +95 -15
  29. package/dist/core/types.d.ts +80 -10
  30. package/dist/core/types.js +30 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/stores/file/tool-result-store.d.ts +41 -1
  33. package/dist/stores/file/tool-result-store.js +107 -19
  34. package/dist/tools/fs/fs-bash.d.ts +7 -0
  35. package/dist/tools/fs/fs-shared.d.ts +5 -0
  36. package/dist/tools/fs/fs-shared.js +11 -7
  37. package/dist/tools/fs/index.d.ts +6 -0
  38. package/dist/tools/fs/index.js +2 -0
  39. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
2
2
  import { sameScope, writerOf } from "./permission-rule-store.js";
3
3
  export function orgRuleStatePersistenceOf(store) {
4
4
  const s = store;
@@ -46,7 +46,7 @@ export function createOrgRuleOverlay(cfg) {
46
46
  throw new Error("org-governed is declared but no OrgRuleSnapshotProvider is wired — a governed deployment without an org source is a configuration contradiction; refusing to boot rather than running silently ungoverned");
47
47
  }
48
48
  if (typeof cfg.stalenessBoundMs !== "number" || !Number.isFinite(cfg.stalenessBoundMs) || cfg.stalenessBoundMs <= 0) {
49
- throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${String(cfg.stalenessBoundMs)})`);
49
+ throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${escapeForDisclosure(cfg.stalenessBoundMs)})`);
50
50
  }
51
51
  const provider = cfg.provider;
52
52
  const now = cfg.now ?? Date.now;
@@ -75,11 +75,11 @@ export function createOrgRuleOverlay(cfg) {
75
75
  let fetched;
76
76
  let fetchFailed = false;
77
77
  try {
78
- fetched = await provider.current();
78
+ fetched = structuredClone(await provider.current());
79
79
  }
80
80
  catch (err) {
81
81
  fetchFailed = true;
82
- disclosures.push(`org snapshot provider failed: ${err instanceof Error ? err.message : String(err)}`);
82
+ disclosures.push(`org snapshot provider failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
83
83
  }
84
84
  return await serialize(async () => {
85
85
  const nowMs = now();
@@ -88,7 +88,7 @@ export function createOrgRuleOverlay(cfg) {
88
88
  return await readState();
89
89
  }
90
90
  catch (err) {
91
- disclosures.push(`org state ${context} read failed: ${err instanceof Error ? err.message : String(err)}`);
91
+ disclosures.push(`org state ${context} read failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
92
92
  return undefined;
93
93
  }
94
94
  };
@@ -101,8 +101,12 @@ export function createOrgRuleOverlay(cfg) {
101
101
  disclosures.push(`org snapshot refused: ${invalid}; the previously installed snapshot (if any) remains in force`);
102
102
  return persisted;
103
103
  }
104
- if (persisted !== undefined && fetched.revision < persisted.revisionHighWater) {
105
- disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${persisted.revisionHighWater} — refusing the rollback; the current deny set is unchanged`);
104
+ const persistedFloor = persisted === undefined ? undefined : antiRollbackFloor(persisted);
105
+ if (persisted !== undefined && !persistedMarkIsCoherent(persisted)) {
106
+ disclosures.push(`the durable org state carries a high-water mark (${escapeForDisclosure(persisted.revisionHighWater)}) that is not a finite number at or above its own snapshot revision (${escapeForDisclosure(persisted.snapshot?.revision)}) — the anti-rollback fence is being held at ${escapeForDisclosure(persistedFloor)} instead, and the durable state should be repaired`);
107
+ }
108
+ if (persistedFloor !== undefined && fetched.revision < persistedFloor) {
109
+ disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${escapeForDisclosure(persistedFloor)} — refusing the rollback; the current deny set is unchanged`);
106
110
  return persisted;
107
111
  }
108
112
  if (persisted !== undefined && fetched.revision === persisted.snapshot.revision && !sameOrgPolicyContent(fetched, persisted.snapshot)) {
@@ -110,7 +114,7 @@ export function createOrgRuleOverlay(cfg) {
110
114
  return persisted;
111
115
  }
112
116
  const state = {
113
- revisionHighWater: Math.max(persisted?.revisionHighWater ?? 0, fetched.revision),
117
+ revisionHighWater: Math.max(persistedFloor !== undefined && Number.isFinite(persistedFloor) ? persistedFloor : 0, fetched.revision),
114
118
  snapshot: fetched,
115
119
  installedAtMs: nowMs,
116
120
  };
@@ -119,11 +123,11 @@ export function createOrgRuleOverlay(cfg) {
119
123
  }
120
124
  catch (err) {
121
125
  const reread = await guardedRead("reconciliation");
122
- if (reread !== undefined && reread.revisionHighWater >= state.revisionHighWater) {
123
- disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${reread.snapshot.revision} — continuing on the newer installed state`);
126
+ if (reread !== undefined && antiRollbackFloor(reread) >= state.revisionHighWater) {
127
+ disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${escapeForDisclosure(reread.snapshot.revision)} — continuing on the newer installed state`);
124
128
  return reread;
125
129
  }
126
- disclosures.push(`org snapshot install failed (${err instanceof Error ? err.message : String(err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
130
+ disclosures.push(`org snapshot install failed (${escapeForDisclosure(err instanceof Error ? err.message : err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
127
131
  return undefined;
128
132
  }
129
133
  return state;
@@ -132,7 +136,12 @@ export function createOrgRuleOverlay(cfg) {
132
136
  const withinBound = (snap) => decisionMs - snap.fetchedAtMs <= cfg.stalenessBoundMs && snap.fetchedAtMs <= decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS;
133
137
  if (installed !== undefined) {
134
138
  if (installed.snapshot.fetchedAtMs > decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
135
- disclosures.push(`the installed org snapshot claims a FUTURE observation time (${installed.snapshot.fetchedAtMs} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
139
+ disclosures.push(`the installed org snapshot claims a FUTURE observation time (${escapeForDisclosure(installed.snapshot.fetchedAtMs)} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
140
+ return { status: "unavailable", rules: [], disclosures };
141
+ }
142
+ const servingRefusal = validateOrgSnapshot(installed.snapshot, decisionMs);
143
+ if (servingRefusal !== undefined) {
144
+ disclosures.push(`the installed org snapshot (revision ${escapeForDisclosure(installed.snapshot.revision)}) does not meet this version's validator: ${servingRefusal} — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
136
145
  return { status: "unavailable", rules: [], disclosures };
137
146
  }
138
147
  if (withinBound(installed.snapshot)) {
@@ -156,16 +165,50 @@ export function createOrgRuleOverlay(cfg) {
156
165
  };
157
166
  return { resolve };
158
167
  }
168
+ function antiRollbackFloor(state) {
169
+ const mark = typeof state.revisionHighWater === "number" && Number.isFinite(state.revisionHighWater) ? state.revisionHighWater : undefined;
170
+ const own = typeof state.snapshot?.revision === "number" && Number.isFinite(state.snapshot.revision) ? state.snapshot.revision : undefined;
171
+ if (mark === undefined)
172
+ return own ?? Number.NEGATIVE_INFINITY;
173
+ return own === undefined ? mark : Math.max(mark, own);
174
+ }
175
+ function persistedMarkIsCoherent(state) {
176
+ return (typeof state.revisionHighWater === "number" &&
177
+ Number.isFinite(state.revisionHighWater) &&
178
+ typeof state.snapshot?.revision === "number" &&
179
+ Number.isFinite(state.snapshot.revision) &&
180
+ state.revisionHighWater >= state.snapshot.revision);
181
+ }
159
182
  function sameOrgPolicyContent(a, b) {
160
183
  if (a.rules.length !== b.rules.length)
161
184
  return false;
162
185
  return a.rules.every((r, i) => b.rules[i]?.rule === r.rule && b.rules[i]?.behavior === r.behavior);
163
186
  }
187
+ const DISCLOSED_UNENFORCEABLE_RULES = 20;
188
+ export function unenforceableOrgRules(rules) {
189
+ const out = [];
190
+ for (const r of rules) {
191
+ if (typeof r?.rule !== "string" || r.rule === "")
192
+ continue;
193
+ const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
194
+ if ("reject" in parsed)
195
+ out.push({ rule: r.rule, code: parsed.reject.code });
196
+ }
197
+ return out;
198
+ }
199
+ function describeUnenforceable(unenforceable) {
200
+ const named = unenforceable
201
+ .slice(0, DISCLOSED_UNENFORCEABLE_RULES)
202
+ .map((u) => `"${escapeForDisclosure(u.rule)}" (${u.code})`)
203
+ .join(", ");
204
+ const more = unenforceable.length - DISCLOSED_UNENFORCEABLE_RULES;
205
+ return more > 0 ? `${named} and ${more} more` : named;
206
+ }
164
207
  function validateOrgSnapshot(s, nowMs) {
165
208
  if (typeof s.revision !== "number" || !Number.isFinite(s.revision))
166
- return `revision is not a finite number (${String(s.revision)})`;
209
+ return `revision is not a finite number (${escapeForDisclosure(s.revision)})`;
167
210
  if (typeof s.fetchedAtMs !== "number" || !Number.isFinite(s.fetchedAtMs))
168
- return `fetchedAtMs is not a finite number (${String(s.fetchedAtMs)})`;
211
+ return `fetchedAtMs is not a finite number (${escapeForDisclosure(s.fetchedAtMs)})`;
169
212
  if (s.fetchedAtMs > nowMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
170
213
  return `fetchedAtMs is ${s.fetchedAtMs - nowMs}ms in the future (allowance ${ORG_FETCHED_AT_SKEW_ALLOWANCE_MS}ms) — a future-dated observation would satisfy the staleness bound indefinitely`;
171
214
  }
@@ -174,11 +217,15 @@ function validateOrgSnapshot(s, nowMs) {
174
217
  for (const r of s.rules) {
175
218
  if (typeof r?.rule !== "string" || r.rule === "")
176
219
  return "a rule entry carries no rule text";
177
- if (r.behavior !== "deny" && r.behavior !== "ask")
178
- return `rule "${r.rule}" carries behavior "${String(r.behavior)}" — the org layer has no allow bucket`;
179
- const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
180
- if ("reject" in parsed)
181
- return `rule "${r.rule}" does not parse (${parsed.reject.code}) — an unenforceable ${r.behavior} must not install as policy`;
220
+ if (r.behavior !== "deny" && r.behavior !== "ask") {
221
+ return `rule "${escapeForDisclosure(r.rule)}" carries behavior "${escapeForDisclosure(r.behavior)}" — the org layer has no allow bucket`;
222
+ }
223
+ }
224
+ const unenforceable = unenforceableOrgRules(s.rules);
225
+ const first = unenforceable[0];
226
+ if (first !== undefined) {
227
+ const rest = unenforceable.length > 1 ? ` (${unenforceable.length} in all — ${describeUnenforceable(unenforceable)})` : "";
228
+ return `rule "${escapeForDisclosure(first.rule)}" does not parse (${first.code}) — an unenforceable deny/ask must not stand as policy${rest}`;
182
229
  }
183
230
  return undefined;
184
231
  }
@@ -231,7 +278,7 @@ function resolveIntrospectionStore(opts) {
231
278
  return store;
232
279
  }
233
280
  if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
234
- throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
281
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
235
282
  }
236
283
  const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
237
284
  return opts.provider.forPrincipal(principal);
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { canonicalize } from "./canonical-json.js";
3
- import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
+ import { escapeForDisclosure, isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
4
4
  export function sameRuleOwner(a, b) {
5
5
  return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
6
6
  }
@@ -191,7 +191,7 @@ export function applySyncJoin(cur, delta, nowMs) {
191
191
  }
192
192
  const canonicalTomb = parseAllowRuleText(t.rule);
193
193
  if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
194
- 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
+ throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${escapeForDisclosure(t.rule)}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
195
195
  }
196
196
  }
197
197
  const droppedInbound = [];
@@ -25,7 +25,7 @@
25
25
  * privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
26
26
  * level they are buying.
27
27
  */
28
- import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
28
+ import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
30
  import { type TracerHook } from "./trace.js";
31
31
  /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
@@ -125,6 +125,15 @@ interface ParsedRuleSyncResponse {
125
125
  dot: RuleDot;
126
126
  reason: string;
127
127
  }>;
128
+ /** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
129
+ * outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
130
+ * parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
131
+ textRefusedTombstones: Array<{
132
+ rule: string;
133
+ scope: RuleScope;
134
+ dot: RuleDot;
135
+ code: RuleRejectCode;
136
+ }>;
128
137
  warnings: string[];
129
138
  }
130
139
  /**
@@ -134,6 +143,11 @@ interface ParsedRuleSyncResponse {
134
143
  * reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
135
144
  * tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
136
145
  * below is PICKED off the response, the object itself is discarded.
146
+ *
147
+ * A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
148
+ * that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
149
+ * on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
150
+ * validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
137
151
  */
138
152
  export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
139
153
  export {};
@@ -1,4 +1,4 @@
1
- import { parseAllowRuleText } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
2
2
  import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
3
  import { emitTrace } from "./trace.js";
4
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
@@ -9,12 +9,13 @@ function refuseLocalOwnerSync() {
9
9
  throw e;
10
10
  }
11
11
  const SYNC_MAX_ATTEMPTS = 8;
12
+ const DISCLOSED_REFUSED_TOMBSTONES = 5;
12
13
  export async function syncPermissionRules(opts) {
13
14
  if (opts.owner !== undefined) {
14
15
  if (opts.owner.kind === "local-owner")
15
16
  refuseLocalOwnerSync();
16
17
  if (opts.owner.principal !== opts.principal) {
17
- throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
18
+ throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
18
19
  }
19
20
  }
20
21
  if (typeof opts.principal !== "string" || opts.principal === "") {
@@ -83,6 +84,18 @@ export async function syncPermissionRules(opts) {
83
84
  if (tombstoneWithholding) {
84
85
  warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
85
86
  }
87
+ for (const t of response.textRefusedTombstones) {
88
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
89
+ }
90
+ if (response.textRefusedTombstones.length > 0) {
91
+ const named = response.textRefusedTombstones
92
+ .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
93
+ .map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
94
+ .join(", ");
95
+ const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
96
+ warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
97
+ "They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
98
+ }
86
99
  const inboundRules = [];
87
100
  if (!tombstoneWithholding) {
88
101
  for (const r of response.merged.rules) {
@@ -120,7 +133,7 @@ export async function syncPermissionRules(opts) {
120
133
  dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
121
134
  }
122
135
  else {
123
- warnings.push(`the server dropped an inbound-side record (${d.rule}, dot ${d.dot.actor}#${d.dot.counter}): ${d.reason}`);
136
+ warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
124
137
  }
125
138
  }
126
139
  const clean = !tombstoneWithholding;
@@ -147,7 +160,7 @@ export async function syncPermissionRules(opts) {
147
160
  resurrected: [],
148
161
  dropped,
149
162
  rev: current.rev,
150
- warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
163
+ warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
151
164
  });
152
165
  }
153
166
  if ("conflict" in res) {
@@ -205,9 +218,11 @@ export async function syncPermissionRules(opts) {
205
218
  warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
206
219
  });
207
220
  }
208
- function isDot(v) {
221
+ function pickDot(v) {
209
222
  const d = v;
210
- return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
223
+ const actor = d?.actor;
224
+ const counter = d?.counter;
225
+ return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
211
226
  }
212
227
  function pickScope(v) {
213
228
  const s = v;
@@ -219,13 +234,16 @@ function pickScope(v) {
219
234
  }
220
235
  function pickAdd(v) {
221
236
  const a = v;
222
- if (!isDot(a?.dot))
237
+ const dot = pickDot(a?.dot);
238
+ const origin = a?.origin;
239
+ const createdAt = a?.createdAt;
240
+ if (dot === undefined)
223
241
  return undefined;
224
- if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
242
+ if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
225
243
  return undefined;
226
- if (typeof a.createdAt !== "string")
244
+ if (typeof createdAt !== "string")
227
245
  return undefined;
228
- return { dot: { actor: a.dot.actor, counter: a.dot.counter }, origin: a.origin, createdAt: a.createdAt };
246
+ return { dot, origin, createdAt };
229
247
  }
230
248
  function pickFrontier(v) {
231
249
  if (v === null || typeof v !== "object" || Array.isArray(v))
@@ -243,35 +261,44 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
243
261
  throw new Error("the sync response is not an object — protocol error, nothing landed");
244
262
  }
245
263
  const r = raw;
246
- if (typeof r.principal !== "string") {
264
+ const echoedPrincipal = r.principal;
265
+ if (typeof echoedPrincipal !== "string") {
247
266
  throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
248
267
  }
249
- if (r.principal !== expectedPrincipal) {
250
- throw new Error(`the sync response echoes principal "${r.principal}" but this round synced "${expectedPrincipal}" — the transport is wired to the wrong bucket; refusing the whole round`);
268
+ if (echoedPrincipal !== expectedPrincipal) {
269
+ throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
251
270
  }
252
271
  const merged = r.merged;
253
- if (merged === null || typeof merged !== "object" || !Array.isArray(merged.rules) || !Array.isArray(merged.tombstones)) {
272
+ const mergedRules = merged?.rules;
273
+ const mergedTombstones = merged?.tombstones;
274
+ if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
254
275
  throw new Error("the sync response carries no merged state — protocol error, nothing landed");
255
276
  }
277
+ const rawGcFrontier = r.gcFrontier;
256
278
  let gcFrontier;
257
- if (r.gcFrontier !== undefined) {
258
- gcFrontier = pickFrontier(r.gcFrontier);
279
+ if (rawGcFrontier !== undefined) {
280
+ gcFrontier = pickFrontier(rawGcFrontier);
259
281
  if (gcFrontier === undefined) {
260
282
  throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
261
283
  }
262
284
  }
263
285
  const warnings = [];
264
286
  const rules = [];
265
- for (const entry of merged.rules) {
287
+ for (const entry of mergedRules) {
266
288
  const e = entry;
267
289
  const scope = pickScope(e?.scope);
268
- if (typeof e?.rule !== "string" || scope === undefined || !Array.isArray(e.adds) || typeof e.command !== "string" || typeof e.tool !== "string" || typeof e.match !== "string") {
290
+ const eRule = e?.rule;
291
+ const eCommand = e?.command;
292
+ const eTool = e?.tool;
293
+ const eMatch = e?.match;
294
+ const eAdds = e?.adds;
295
+ if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
269
296
  warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
270
297
  continue;
271
298
  }
272
299
  const adds = [];
273
300
  let addDamaged = false;
274
- for (const a of e.adds) {
301
+ for (const a of eAdds) {
275
302
  const picked = pickAdd(a);
276
303
  if (picked === undefined) {
277
304
  addDamaged = true;
@@ -280,64 +307,79 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
280
307
  adds.push(picked);
281
308
  }
282
309
  if (addDamaged)
283
- warnings.push(`rule "${e.rule}" carried at least one unshapeable add — skipped those adds`);
310
+ warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
284
311
  if (adds.length === 0)
285
312
  continue;
286
313
  rules.push({
287
- rule: e.rule,
288
- tool: e.tool,
289
- match: e.match,
290
- command: e.command,
314
+ rule: eRule,
315
+ tool: eTool,
316
+ match: eMatch,
317
+ command: eCommand,
291
318
  scope,
292
319
  adds,
293
320
  });
294
321
  }
295
322
  const tombstones = [];
296
- for (const entry of merged.tombstones) {
323
+ const textRefusedTombstones = [];
324
+ for (const entry of mergedTombstones) {
297
325
  const t = entry;
298
326
  const scope = pickScope(t?.scope);
299
- const canonicalTomb = typeof t?.rule === "string" ? parseAllowRuleText(t.rule) : undefined;
300
- if (typeof t?.rule !== "string" ||
301
- canonicalTomb === undefined ||
302
- "reject" in canonicalTomb ||
303
- canonicalTomb.rule.rule !== t.rule ||
304
- scope === undefined ||
305
- !isDot(t.deletedBy) ||
306
- !Array.isArray(t.removedDots) ||
307
- t.removedDots.length === 0 ||
308
- !t.removedDots.every(isDot)) {
327
+ const ruleText = t?.rule;
328
+ const deletedBy = pickDot(t?.deletedBy);
329
+ const removedRaw = t?.removedDots;
330
+ const removedDots = [];
331
+ if (Array.isArray(removedRaw)) {
332
+ for (const d of removedRaw) {
333
+ const dot = pickDot(d);
334
+ if (dot === undefined) {
335
+ removedDots.length = 0;
336
+ break;
337
+ }
338
+ removedDots.push(dot);
339
+ }
340
+ }
341
+ if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
309
342
  tombstones.push(undefined);
310
343
  continue;
311
344
  }
312
- tombstones.push({
313
- rule: t.rule,
314
- scope,
315
- removedDots: t.removedDots.map((d) => ({ actor: d.actor, counter: d.counter })),
316
- deletedBy: { actor: t.deletedBy.actor, counter: t.deletedBy.counter },
317
- });
345
+ const canonicalTomb = parseAllowRuleText(ruleText);
346
+ if ("reject" in canonicalTomb) {
347
+ textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
348
+ continue;
349
+ }
350
+ if (canonicalTomb.rule.rule !== ruleText) {
351
+ tombstones.push(undefined);
352
+ continue;
353
+ }
354
+ tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
318
355
  }
319
356
  const dropped = [];
320
- if (r.dropped !== undefined) {
321
- if (!Array.isArray(r.dropped)) {
357
+ const rawDropped = r.dropped;
358
+ if (rawDropped !== undefined) {
359
+ if (!Array.isArray(rawDropped)) {
322
360
  warnings.push("the response carried a non-array dropped field — ignored");
323
361
  }
324
362
  else {
325
- for (const entry of r.dropped) {
363
+ for (const entry of rawDropped) {
326
364
  const d = entry;
327
365
  const scope = pickScope(d?.scope);
328
- if (typeof d?.rule !== "string" || scope === undefined || !isDot(d.dot) || typeof d.reason !== "string") {
366
+ const dRule = d?.rule;
367
+ const dDot = pickDot(d?.dot);
368
+ const dReason = d?.reason;
369
+ if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
329
370
  warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
330
371
  continue;
331
372
  }
332
- dropped.push({ rule: d.rule, scope, dot: { actor: d.dot.actor, counter: d.dot.counter }, reason: d.reason });
373
+ dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
333
374
  }
334
375
  }
335
376
  }
336
377
  return {
337
- principal: r.principal,
378
+ principal: echoedPrincipal,
338
379
  merged: { rules, tombstones },
339
380
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
340
381
  dropped,
382
+ textRefusedTombstones,
341
383
  warnings,
342
384
  };
343
385
  }
@@ -89,10 +89,14 @@ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOI
89
89
  import { boundInputHashOf } from "../canonical-json.js";
90
90
  import { countElicitOptIns, deriveAskEffective, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam } from "../wiring-manifest.js";
91
91
  import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
92
+ import { deliverEngineNotice } from "../types.js";
92
93
  const announcedMaterializeEnv = new Set();
93
94
  export function __resetMaterializeEnvAnnouncements() {
94
95
  announcedMaterializeEnv.clear();
95
96
  }
97
+ function emitMaterializeEnvNotice(onNotice, message, detail) {
98
+ deliverEngineNotice(onNotice, { code: "config.materialize_env_discarded", message, detail });
99
+ }
96
100
  const RECONCILE_MAX_RETRIES = 3;
97
101
  const DEFAULT_MAX_SUSPENDS = 5;
98
102
  const TASK_LIMIT_KEY_DICT = {
@@ -1659,6 +1663,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1659
1663
  ...(sessionId !== undefined ? { sessionId } : {}),
1660
1664
  ...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}),
1661
1665
  ...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}),
1666
+ ...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}),
1662
1667
  pdfModelCapabilities: pdfModelCapabilitiesOf(model),
1663
1668
  ...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}),
1664
1669
  ...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
@@ -2606,7 +2611,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2606
2611
  const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(raw)} is not "swap" or "static" — inert on this task (no deferred tools), but a deferring task WITHOUT an explicit spec strategy will refuse to prepare under it (an explicit legal spec outranks and discards it, loudly). Fix or unset the flag.`;
2607
2612
  if (!announcedMaterializeEnv.has(line)) {
2608
2613
  announcedMaterializeEnv.add(line);
2609
- console.warn(line);
2614
+ emitMaterializeEnvNotice(deps.onNotice, line, { raw });
2610
2615
  }
2611
2616
  }
2612
2617
  }
@@ -2628,7 +2633,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2628
2633
  const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(rawEnvStrategy)} was ignored — not "swap" or "static", and the task spec pins toolMaterializeStrategy=${JSON.stringify(spec.toolMaterializeStrategy)} which outranks it. Fix or unset the env flag.`;
2629
2634
  if (!announcedMaterializeEnv.has(line)) {
2630
2635
  announcedMaterializeEnv.add(line);
2631
- console.warn(line);
2636
+ emitMaterializeEnvNotice(deps.onNotice, line, { raw: rawEnvStrategy, specStrategy: spec.toolMaterializeStrategy });
2632
2637
  }
2633
2638
  }
2634
2639
  const envStrategy = envStrategyInvalid ? undefined : rawEnvStrategy;
@@ -4731,7 +4736,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4731
4736
  ...(offloadStore
4732
4737
  ? {
4733
4738
  offload: {
4734
- persist: createOffloadPersist(offloadStore, sessionId),
4739
+ persist: createOffloadPersist(offloadStore, sessionId, deps.onNotice),
4735
4740
  },
4736
4741
  }
4737
4742
  : {}),
@@ -131,7 +131,10 @@ const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call t
131
131
  function toolEndBodyFrom(result, isError, settledBy) {
132
132
  const o = toolOutputFrom(result);
133
133
  const st = structuredFrom(result);
134
- const code = isError ? result?.details?.code : undefined;
134
+ const det = isError ? result?.details : undefined;
135
+ const codeRaw = det?.code;
136
+ const kindRaw = det?.errorKind;
137
+ const code = typeof codeRaw === "string" ? codeRaw : typeof kindRaw === "string" ? kindRaw : undefined;
135
138
  return {
136
139
  ...(o !== undefined ? { output: o.output, ...(o.truncated ? { truncated: true } : {}), ...(o.totalChars !== undefined ? { totalChars: o.totalChars } : {}) } : {}),
137
140
  ...(st !== undefined ? { structured: st } : {}),
@@ -1412,6 +1415,10 @@ export class Runner {
1412
1415
  return runSideQuery(spec, { brain: this.deps.brain, models: this.deps.models, roles: this.deps.roles });
1413
1416
  }
1414
1417
  runTaskStream(spec, resume, internals) {
1418
+ if (resume !== undefined && (typeof resume !== "object" || resume.outcome === undefined)) {
1419
+ throw new TypeError("runTaskStream: `resume` must be a ResumeRun carrying `outcome` — got a value without one. " +
1420
+ "(Note the parameter order: runTaskStream(spec, resume?, internals?) — internals is the THIRD parameter.)");
1421
+ }
1415
1422
  const entryActor = spec.actor === undefined ? undefined : snapshotActorAssertion(spec.actor);
1416
1423
  const queue = new PushQueue();
1417
1424
  const detachHub = new ToolDetachHub();
@@ -471,6 +471,15 @@ export declare function serveDurableAgentRowLane(row: BackgroundAgentRecord): Un
471
471
  *
472
472
  * No store configured (a deployment that never wired a `toolResultStore`) ⇒ returns `clipped` UNCHANGED
473
473
  * — the legal degrade design/158 §2.2 calls for, byte-identical to pre-S1 behavior.
474
+ *
475
+ * Backlog #169 — a FAILING `put` (throwing or rejecting; the store contract admits both dialects) is
476
+ * NOT fatal to the poll: the clipped text is still served, with {@link AGENT_SPILL_FAILED_NOTE} in
477
+ * place of the ref disclosure (advertising a ref nobody can read back would be a false promise), and
478
+ * `handle.spillFailed` latches so later polls of the same cycle repeat the honest note instead of
479
+ * retrying the write — see the catch below for why latch-not-retry. Same "store failure is never
480
+ * fatal" posture as the monitor/offload/budget/compaction write sites. A put that settles AFTER a
481
+ * revive (stale cycle) is generation-guarded: it answers its own caller honestly but never writes
482
+ * spill state onto the new cycle (see `mintCycle` below).
474
483
  */
475
484
  export declare function spillClippedAgentResult(handle: BackgroundAgentTaskHandle, full: string, clipped: string, store: ToolResultStore | undefined, sessionId: string | undefined): Promise<string>;
476
485
  export declare function pollBackgroundAgentLane(handle: BackgroundAgentTaskHandle, deadline?: number, signal?: AbortSignal,