@sema-agent/core 5.56.0 → 5.58.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 (208) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/agents/cascade.d.ts +1 -1
  3. package/dist/agents/cumulative-stats.d.ts +1 -1
  4. package/dist/agents/observer.d.ts +2 -2
  5. package/dist/agents/peer-admission.d.ts +1 -1
  6. package/dist/agents/retain-ledger.d.ts +2 -2
  7. package/dist/agents/roster-store.d.ts +8 -8
  8. package/dist/agents/send-message-tool.d.ts +13 -2
  9. package/dist/agents/send-message-tool.js +34 -12
  10. package/dist/agents/subagent-steps.d.ts +1 -1
  11. package/dist/agents/subagent.d.ts +13 -13
  12. package/dist/agents/team.d.ts +15 -6
  13. package/dist/agents/team.js +1 -0
  14. package/dist/agents/tool-filter.d.ts +2 -2
  15. package/dist/agents/verify.d.ts +1 -1
  16. package/dist/bench/metrics.d.ts +35 -35
  17. package/dist/brain/anthropic.js +15 -5
  18. package/dist/brain/circuit-breaker.js +2 -1
  19. package/dist/brain/degrading.d.ts +1 -1
  20. package/dist/brain/degrading.js +4 -1
  21. package/dist/brain/errors.d.ts +3 -3
  22. package/dist/brain/failover.js +16 -1
  23. package/dist/brain/open-responses.js +15 -5
  24. package/dist/brain/openai.js +16 -5
  25. package/dist/brain/reasoning.d.ts +2 -2
  26. package/dist/brain/repetition.d.ts +1 -1
  27. package/dist/brain/request-params.d.ts +30 -27
  28. package/dist/brain/request-params.js +1 -7
  29. package/dist/brain/route-adjudicator.d.ts +190 -0
  30. package/dist/brain/route-adjudicator.js +189 -0
  31. package/dist/brain/route-conformance.d.ts +55 -0
  32. package/dist/brain/route-conformance.js +136 -0
  33. package/dist/brain/routing.js +8 -3
  34. package/dist/brain/status-sink.d.ts +2 -2
  35. package/dist/brain/stream-shared.d.ts +1 -1
  36. package/dist/config/catalog.d.ts +5 -5
  37. package/dist/core/arg-summary.d.ts +4 -4
  38. package/dist/core/ask-class.d.ts +2 -2
  39. package/dist/core/ask-question.d.ts +1 -1
  40. package/dist/core/auto-compaction.d.ts +15 -15
  41. package/dist/core/auto-mode.d.ts +5 -5
  42. package/dist/core/background-agent-store.d.ts +20 -20
  43. package/dist/core/background-shell.d.ts +4 -4
  44. package/dist/core/checkpoint-store.d.ts +35 -27
  45. package/dist/core/context-edit.d.ts +1 -1
  46. package/dist/core/context-guard.d.ts +1 -1
  47. package/dist/core/exec-output-tail.d.ts +6 -6
  48. package/dist/core/file-snapshot-store.d.ts +8 -8
  49. package/dist/core/git-worktree-env.d.ts +3 -3
  50. package/dist/core/governance-codes.js +2 -0
  51. package/dist/core/hooks.d.ts +73 -33
  52. package/dist/core/hooks.js +87 -25
  53. package/dist/core/image-downsample.d.ts +1 -1
  54. package/dist/core/locked-config.d.ts +1 -1
  55. package/dist/core/lsp.d.ts +1 -1
  56. package/dist/core/mailbox-store.d.ts +1 -1
  57. package/dist/core/mcp.d.ts +3 -3
  58. package/dist/core/mcp.js +4 -4
  59. package/dist/core/memory-engine/consolidation-driver.d.ts +207 -0
  60. package/dist/core/memory-engine/consolidation-driver.js +378 -0
  61. package/dist/core/memory-engine/consolidation.d.ts +46 -2
  62. package/dist/core/memory-engine/consolidation.js +1 -0
  63. package/dist/core/memory-engine/data-plane.d.ts +1 -1
  64. package/dist/core/memory-engine/distiller.d.ts +550 -0
  65. package/dist/core/memory-engine/distiller.js +598 -0
  66. package/dist/core/memory-engine/dual-root.d.ts +1 -1
  67. package/dist/core/memory-engine/engine.d.ts +62 -8
  68. package/dist/core/memory-engine/engine.js +40 -4
  69. package/dist/core/memory-engine/file-backend.d.ts +1 -1
  70. package/dist/core/memory-engine/index.d.ts +4 -2
  71. package/dist/core/memory-engine/index.js +4 -2
  72. package/dist/core/memory-engine/origin-clearance.d.ts +1 -1
  73. package/dist/core/memory-engine/scope-contract.d.ts +4 -4
  74. package/dist/core/memory-engine/sync-client.d.ts +16 -16
  75. package/dist/core/memory-engine/sync.d.ts +4 -4
  76. package/dist/core/memory-recall.d.ts +1 -1
  77. package/dist/core/memory.d.ts +2 -2
  78. package/dist/core/permission-rule-consent.d.ts +230 -36
  79. package/dist/core/permission-rule-consent.js +258 -54
  80. package/dist/core/permission-rule-model.d.ts +295 -97
  81. package/dist/core/permission-rule-model.js +132 -41
  82. package/dist/core/permission-rules.d.ts +9 -9
  83. package/dist/core/remote-env.d.ts +8 -8
  84. package/dist/core/roles.d.ts +3 -3
  85. package/dist/core/roles.js +1 -0
  86. package/dist/core/runner/assemble-result.d.ts +2 -2
  87. package/dist/core/runner/compaction-call-options.d.ts +3 -3
  88. package/dist/core/runner/memory-consolidation-driver.d.ts +49 -0
  89. package/dist/core/runner/memory-consolidation-driver.js +60 -0
  90. package/dist/core/runner/memory-consolidation.d.ts +1 -1
  91. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  92. package/dist/core/runner/prepare-task.d.ts +21 -21
  93. package/dist/core/runner/prepare-task.js +54 -18
  94. package/dist/core/runner/prepare-workspace-restore.d.ts +2 -2
  95. package/dist/core/runner/runtask.d.ts +15 -12
  96. package/dist/core/runner/runtask.js +48 -0
  97. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  98. package/dist/core/runner/teardown-bounded.d.ts +1 -1
  99. package/dist/core/runner/tool-disclosure.d.ts +2 -2
  100. package/dist/core/runner/turn-attachments.d.ts +11 -11
  101. package/dist/core/scheduler.d.ts +10 -5
  102. package/dist/core/secret-env.d.ts +1 -1
  103. package/dist/core/sensitive-path-policy.d.ts +1 -1
  104. package/dist/core/session-policy-store.d.ts +2 -2
  105. package/dist/core/session-reconcile.d.ts +2 -2
  106. package/dist/core/session-store.d.ts +3 -3
  107. package/dist/core/session.d.ts +1 -1
  108. package/dist/core/shutdown-debug.d.ts +2 -2
  109. package/dist/core/side-query.d.ts +14 -7
  110. package/dist/core/spec-contract.d.ts +1 -1
  111. package/dist/core/store-contracts/contract-harness.d.ts +2 -2
  112. package/dist/core/store-contracts/contract-kit-version.d.ts +2 -2
  113. package/dist/core/store-contracts/mailbox-store-contract.d.ts +1 -1
  114. package/dist/core/store-contracts/mailbox-store-contract.js +1 -1
  115. package/dist/core/task-notification.d.ts +5 -5
  116. package/dist/core/task-registry-agent.d.ts +12 -12
  117. package/dist/core/task-registry-monitor.d.ts +1 -1
  118. package/dist/core/task-registry-shared.d.ts +41 -41
  119. package/dist/core/task-registry.d.ts +12 -12
  120. package/dist/core/tool-detach.d.ts +2 -2
  121. package/dist/core/tool-errors.d.ts +3 -3
  122. package/dist/core/tool-policy.d.ts +55 -28
  123. package/dist/core/tool-result-budget.d.ts +1 -1
  124. package/dist/core/tool-result-store.d.ts +2 -2
  125. package/dist/core/tools.d.ts +1 -1
  126. package/dist/core/trace.d.ts +26 -23
  127. package/dist/core/types.d.ts +155 -70
  128. package/dist/core/untrusted-egress.d.ts +1 -1
  129. package/dist/core/untrusted-text.d.ts +7 -7
  130. package/dist/core/wiring-manifest.d.ts +5 -5
  131. package/dist/core/workflow-journal-store.d.ts +14 -14
  132. package/dist/core/workflow-run-store-contract.d.ts +1 -1
  133. package/dist/core/workflow-run-store-contract.js +1 -1
  134. package/dist/core/workflow-run-store.d.ts +4 -4
  135. package/dist/engine/compaction/compaction.d.ts +3 -3
  136. package/dist/engine/compaction/utils.d.ts +2 -2
  137. package/dist/engine/execution-env/kill-tree.d.ts +1 -1
  138. package/dist/engine/execution-env/node-execution-env.d.ts +8 -8
  139. package/dist/engine/harness/agent-harness.d.ts +6 -6
  140. package/dist/engine/harness/agent-harness.js +26 -1
  141. package/dist/engine/harness/messages.d.ts +1 -1
  142. package/dist/engine/harness/types.d.ts +15 -11
  143. package/dist/engine/llm/types.d.ts +79 -14
  144. package/dist/engine/loop/agent-loop.d.ts +3 -3
  145. package/dist/engine/loop/types.d.ts +4 -4
  146. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
  147. package/dist/engine/session/import-validate.d.ts +1 -1
  148. package/dist/engine/session/log-digest.d.ts +1 -1
  149. package/dist/engine/session/memory-repo.d.ts +2 -2
  150. package/dist/engine/session/session.d.ts +4 -4
  151. package/dist/fixtures/index.d.ts +4 -4
  152. package/dist/index.d.ts +8 -4
  153. package/dist/index.js +5 -2
  154. package/dist/internal/llm.d.ts +1 -1
  155. package/dist/orchestration/goal.d.ts +1 -1
  156. package/dist/orchestration/run-spec.d.ts +1 -1
  157. package/dist/orchestration/run-workflow-tool.d.ts +12 -12
  158. package/dist/orchestration/workflow-governance.d.ts +4 -4
  159. package/dist/orchestration/workflow-observe.d.ts +1 -1
  160. package/dist/orchestration/workflow-script-runner.d.ts +1 -1
  161. package/dist/orchestration/workflow-script-store.d.ts +9 -9
  162. package/dist/orchestration/workflow-size-guideline.d.ts +1 -1
  163. package/dist/orchestration/workflow-types.d.ts +5 -5
  164. package/dist/orchestration/workflow.d.ts +10 -10
  165. package/dist/prompt-assembly/artifact-store.d.ts +1 -1
  166. package/dist/prompt-assembly/artifact.d.ts +1 -1
  167. package/dist/prompt-assembly/assemble.d.ts +1 -1
  168. package/dist/prompt-assembly/composer.d.ts +2 -2
  169. package/dist/prompt-assembly/epoch.d.ts +2 -2
  170. package/dist/prompt-assembly/event-registry.d.ts +1 -1
  171. package/dist/prompt-assembly/explain.d.ts +3 -3
  172. package/dist/prompt-assembly/tool-catalog.d.ts +1 -1
  173. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -4
  174. package/dist/prompt-assembly/types.d.ts +12 -12
  175. package/dist/prompts/coordinator.d.ts +1 -1
  176. package/dist/prompts/default.d.ts +10 -10
  177. package/dist/prompts/default.js +2 -0
  178. package/dist/prompts/simple-sections.d.ts +3 -3
  179. package/dist/prompts/supervisor.d.ts +2 -2
  180. package/dist/scenarios/full-body.d.ts +3 -3
  181. package/dist/scenarios/scenario-registry.d.ts +6 -2
  182. package/dist/scenarios/scenario-registry.js +4 -2
  183. package/dist/stores/cc/sidecar-transcript.d.ts +3 -3
  184. package/dist/stores/file/fs-atomic.d.ts +2 -2
  185. package/dist/stores/file/index.d.ts +1 -1
  186. package/dist/stores/file/session-store.d.ts +2 -2
  187. package/dist/stores/file/workflow-journal-store.d.ts +4 -4
  188. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  189. package/dist/tools/fs/encoding.d.ts +4 -4
  190. package/dist/tools/fs/fs-bash.d.ts +3 -3
  191. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  192. package/dist/tools/fs/fs-shared.d.ts +6 -6
  193. package/dist/tools/fs/index.d.ts +2 -2
  194. package/dist/tools/fs/index.js +8 -1
  195. package/dist/tools/fs/notebook.d.ts +1 -1
  196. package/dist/tools/fs/pdf.d.ts +1 -1
  197. package/dist/tools/fs/read-deny.d.ts +1 -1
  198. package/dist/tools/fs/safety.d.ts +9 -9
  199. package/dist/tools/fs/search.d.ts +2 -2
  200. package/dist/tools/monitor.d.ts +3 -3
  201. package/dist/tools/scheduler-tools.js +28 -6
  202. package/dist/tools/task-list.d.ts +2 -2
  203. package/dist/tools/web.d.ts +19 -4
  204. package/dist/tools/web.js +8 -2
  205. package/dist/tools/worktree.d.ts +5 -5
  206. package/dist/tools/worktree.js +2 -2
  207. package/package.json +1 -1
  208. package/test/export-surface.snapshot.json +74 -3
@@ -1,11 +1,21 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { parseAllowRuleText, ruleAdmitsCommand, suggestRulesForCommand } from "./permission-rule-model.js";
2
+ import { hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, segmentCoverageOf, suggestRulesForCommand, } from "./permission-rule-model.js";
3
3
  import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
4
  export class InMemoryRuleApprovalRecordStore {
5
5
  rows = new Map();
6
6
  async get(id) {
7
7
  const r = this.rows.get(id);
8
- return r === undefined ? undefined : structuredClone(r);
8
+ if (r === undefined)
9
+ return undefined;
10
+ if (r.schema !== 2) {
11
+ return {
12
+ staleSchema: true,
13
+ id: r.id,
14
+ ...(typeof r.principal === "string" ? { principal: r.principal } : {}),
15
+ ...(r.owner !== undefined ? { owner: structuredClone(r.owner) } : {}),
16
+ };
17
+ }
18
+ return structuredClone(r);
9
19
  }
10
20
  async create(record) {
11
21
  if (this.rows.has(record.id))
@@ -80,6 +90,90 @@ function storeForOwner(provider, owner) {
80
90
  }
81
91
  return store;
82
92
  }
93
+ function isStale(rec) {
94
+ return rec.staleSchema === true;
95
+ }
96
+ const STALE_RECORD_MESSAGE = "this approval record predates the current record form, and the decision it carried is void — re-trigger the command to draw a fresh card";
97
+ function offerMembersOf(offer) {
98
+ return offer.kind === "single" ? [offer.candidate] : offer.candidates;
99
+ }
100
+ function approvalRecordDamageOf(rec) {
101
+ if (!Array.isArray(rec.candidates))
102
+ return "the record carries no candidate list";
103
+ if (!Array.isArray(rec.offers) || rec.offers.length === 0)
104
+ return "the record carries no offers";
105
+ const claimed = new Set();
106
+ for (const [at, offer] of rec.offers.entries()) {
107
+ if (typeof offer !== "object" || offer === null)
108
+ return `offer ${at} is not an offer object`;
109
+ if (offer.kind !== "single" && offer.kind !== "batch")
110
+ return `offer ${at} has an unknown kind`;
111
+ if (offer.kind === "batch" && (!Array.isArray(offer.candidates) || offer.candidates.length === 0)) {
112
+ return `batch offer ${at} has no members`;
113
+ }
114
+ const members = offerMembersOf(offer);
115
+ const seen = new Set();
116
+ for (const member of members) {
117
+ if (!Number.isInteger(member) || member < 0 || rec.candidates[member] === undefined) {
118
+ return `offer ${at} references candidate ${member}, which the record does not carry`;
119
+ }
120
+ const cand = rec.candidates[member];
121
+ if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") {
122
+ return `offer ${at} references candidate ${member}, which is not a candidate row`;
123
+ }
124
+ if (seen.has(member))
125
+ return `batch offer ${at} references candidate ${member} twice`;
126
+ seen.add(member);
127
+ if (claimed.has(member))
128
+ return `candidate ${member} belongs to more than one offer`;
129
+ claimed.add(member);
130
+ }
131
+ if (offer.kind === "batch") {
132
+ if (rec.kind === "card") {
133
+ if (!Array.isArray(offer.segments) || offer.segments.length !== offer.candidates.length || !offer.segments.every((s) => typeof s === "string")) {
134
+ return `card batch offer ${at} must carry exactly one source segment per member`;
135
+ }
136
+ if (!Number.isInteger(offer.uncoveredSegments) || offer.uncoveredSegments < 0) {
137
+ return `card batch offer ${at} must carry a non-negative uncovered-segment count`;
138
+ }
139
+ }
140
+ else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined) {
141
+ return `${rec.kind} batch offer ${at} must not carry segment metadata — there is no compound command behind it`;
142
+ }
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+ export function ruleOffersOfRecord(rec) {
148
+ const tripleOf = (index) => {
149
+ const text = rec.candidates[index]?.rule;
150
+ if (text === undefined)
151
+ throw new Error(`approval-record offer references candidate ${index}, which the record does not carry`);
152
+ const parsed = parseAllowRuleText(text);
153
+ if ("reject" in parsed) {
154
+ throw new Error(`approval-record candidate ${index} does not parse as a rule (${parsed.reject.code}) — refusing to render it as one`);
155
+ }
156
+ return { rule: parsed.rule.rule, match: parsed.rule.match, command: parsed.rule.command };
157
+ };
158
+ return rec.offers.map((offer) => {
159
+ if (offer.kind === "single")
160
+ return { kind: "single", ...tripleOf(offer.candidate) };
161
+ const segments = offer.segments;
162
+ if (segments === undefined || offer.uncoveredSegments === undefined) {
163
+ throw new Error("a batch offer without mint-time segment metadata has no card face to project — import/starter batches render as previews, not offer cards");
164
+ }
165
+ return {
166
+ kind: "batch",
167
+ rules: offer.candidates.map((candidate, i) => {
168
+ const segment = segments[i];
169
+ if (segment === undefined)
170
+ throw new Error(`batch offer member ${i} has no source segment recorded`);
171
+ return { ...tripleOf(candidate), segment };
172
+ }),
173
+ uncoveredSegments: offer.uncoveredSegments,
174
+ };
175
+ });
176
+ }
83
177
  export function mintRuleTicket(recordId, candidateIndex) {
84
178
  return `rt.${candidateIndex}.${recordId}`;
85
179
  }
@@ -99,30 +193,72 @@ export async function prepareCardApproval(opts) {
99
193
  if (opts.toolName !== CARD_RULE_TOOL)
100
194
  return undefined;
101
195
  const scope = opts.scope ?? { kind: "global" };
102
- const candidates = suggestRulesForCommand(opts.command).map((s) => ({ rule: s.rule, scope }));
196
+ let coverage;
197
+ try {
198
+ const listed = await storeForOwner(opts.deps.provider, owner).list();
199
+ coverage = segmentCoverageOf(opts.command, { persisted: listed.rules }, { tool: CARD_RULE_TOOL, cwd: opts.cwd });
200
+ }
201
+ catch {
202
+ coverage = undefined;
203
+ }
204
+ const suggested = suggestRulesForCommand(opts.command, coverage !== undefined ? { coverage } : undefined);
205
+ const candidates = [];
206
+ const claimedBy = new Map();
207
+ const claim = (text) => {
208
+ if (claimedBy.has(text))
209
+ throw new Error(`engine defect: rule "${text}" was minted into two offers on one card`);
210
+ const index = candidates.length;
211
+ candidates.push({ rule: text, scope });
212
+ claimedBy.set(text, index);
213
+ return index;
214
+ };
215
+ const offers = suggested.map((offer) => offer.kind === "single"
216
+ ? { kind: "single", candidate: claim(offer.rule) }
217
+ : {
218
+ kind: "batch",
219
+ candidates: offer.rules.map((r) => claim(r.rule)),
220
+ segments: offer.rules.map((r) => r.segment),
221
+ uncoveredSegments: offer.uncoveredSegments,
222
+ });
103
223
  if (candidates.length === 0)
104
224
  return undefined;
105
225
  const record = {
106
226
  id: mintId(opts.deps, "rar"),
107
227
  ...recordIdentityOf(owner),
228
+ schema: 2,
108
229
  kind: "card",
109
230
  state: "pending",
110
231
  rev: 0,
111
232
  candidates,
233
+ offers,
112
234
  command: opts.command,
113
235
  createdAt: nowIso(opts.deps),
114
236
  ...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
115
237
  ...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
116
238
  };
117
239
  await opts.deps.approvals.create(record);
118
- return { approvalId: record.id, tickets: candidates.map((_, i) => mintRuleTicket(record.id, i)), candidates };
240
+ return {
241
+ approvalId: record.id,
242
+ offers: ruleOffersOfRecord(record),
243
+ tickets: candidates.map((_, i) => mintRuleTicket(record.id, i)),
244
+ candidates,
245
+ };
119
246
  }
120
247
  const CARD_RULE_TOOL = "Bash";
121
248
  export async function confirmRuleApproval(opts) {
122
249
  const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
123
- const no = (reason) => ({ ok: false, reason });
124
- if (opts.editedCandidate !== undefined && opts.selectedCandidate !== undefined) {
125
- const e = new Error("confirmRuleApproval takes selectedCandidate OR editedCandidate, never both — one confirmation carries one choice");
250
+ const no = (reason, detail) => ({
251
+ ok: false,
252
+ reason,
253
+ ...(detail !== undefined ? { detail } : {}),
254
+ });
255
+ if (opts.editedCandidate !== undefined && opts.selectedOffer !== undefined) {
256
+ const e = new Error("confirmRuleApproval takes selectedOffer OR editedCandidate, never both — one confirmation carries one choice");
257
+ e.code = "config.invalid_argument";
258
+ throw e;
259
+ }
260
+ if ("selectedCandidate" in opts) {
261
+ const e = new Error("confirmRuleApproval no longer takes selectedCandidate — selection is by OFFER index (selectedOffer) against the prepared record's offers; re-fetch the card and choose an offer");
126
262
  e.code = "config.invalid_argument";
127
263
  throw e;
128
264
  }
@@ -130,28 +266,44 @@ export async function confirmRuleApproval(opts) {
130
266
  const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
131
267
  if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
132
268
  return no("record_not_found");
269
+ if (isStale(rec))
270
+ return no("record_schema_stale", { message: STALE_RECORD_MESSAGE });
271
+ const damage = approvalRecordDamageOf(rec);
272
+ if (damage !== undefined)
273
+ return no("record_malformed", { message: damage });
133
274
  if (opts.editedCandidate !== undefined)
134
275
  return await confirmEditedCandidate(rec, opts.editedCandidate, opts.deps);
135
- if (rec.kind === "card") {
136
- const chosen = opts.selectedCandidate;
137
- if (chosen === undefined)
138
- return no("selection_missing");
139
- if (!Number.isInteger(chosen) || rec.candidates[chosen] === undefined)
140
- return no("selection_invalid");
141
- if (rec.state === "approved" || rec.state === "redeemed") {
142
- return rec.selectedCandidate === chosen ? { ok: true } : no("selection_mismatch");
276
+ const chosen = opts.selectedOffer === undefined ? (rec.offers.length === 1 ? 0 : undefined) : opts.selectedOffer;
277
+ if (chosen === undefined)
278
+ return no("selection_missing");
279
+ if (!Number.isInteger(chosen) || rec.offers[chosen] === undefined)
280
+ return no("selection_invalid");
281
+ if (rec.state === "approved" || rec.state === "redeemed") {
282
+ const stored = rec.selectedOffer;
283
+ if (stored === undefined || !Number.isInteger(stored) || rec.offers[stored] === undefined) {
284
+ return no("record_malformed", { message: "the settled record does not name a valid chosen offer" });
143
285
  }
144
- const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedCandidate: chosen });
145
- return won ? { ok: true } : no("conflict");
286
+ return stored === chosen ? { ok: true } : no("selection_mismatch");
146
287
  }
147
- if (opts.selectedCandidate !== undefined)
148
- return no("batch_takes_no_selection");
149
- if (rec.state === "approved")
150
- return { ok: true };
151
288
  if (rec.state !== "pending")
152
289
  return no("not_pending");
153
- const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
154
- return won ? { ok: true } : no("conflict");
290
+ if (rec.selectedOffer !== undefined)
291
+ return no("record_malformed", { message: "a pending record must not already carry a chosen offer" });
292
+ const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedOffer: chosen });
293
+ if (won)
294
+ return { ok: true };
295
+ const again = await opts.deps.approvals.get(rec.id);
296
+ if (again === undefined)
297
+ return no("record_not_found");
298
+ if (isStale(again))
299
+ return no("record_schema_stale", { message: STALE_RECORD_MESSAGE });
300
+ if (again.state === "pending")
301
+ return no("conflict");
302
+ const settled = again.selectedOffer;
303
+ if (settled === undefined || !Number.isInteger(settled) || again.offers[settled] === undefined) {
304
+ return no("record_malformed", { message: "the settled record does not name a valid chosen offer" });
305
+ }
306
+ return settled === chosen ? { ok: true } : no("selection_mismatch");
155
307
  }
156
308
  function normalizeEditedSpelling(text) {
157
309
  const m = /^([A-Za-z][A-Za-z0-9_]*)\((.+) \*\)$/.exec(text);
@@ -163,6 +315,39 @@ function normalizeEditedSpelling(text) {
163
315
  return text;
164
316
  return `${head}(${body}:*)`;
165
317
  }
318
+ function parseSubmittedSpelling(text) {
319
+ return parseAllowRuleText(normalizeEditedSpelling(text));
320
+ }
321
+ function checkEditedRuleText(text, command) {
322
+ const parsed = parseSubmittedSpelling(text);
323
+ if ("reject" in parsed)
324
+ return { ok: false, code: parsed.reject.code, message: parsed.reject.message };
325
+ if (!ruleAdmitsCommand(parsed.rule, command)) {
326
+ return {
327
+ ok: false,
328
+ message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${command}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
329
+ };
330
+ }
331
+ return { ok: true, canonicalRule: parsed.rule.rule };
332
+ }
333
+ export function precheckEditedRuleText(text, command) {
334
+ if (typeof text !== "string") {
335
+ const e = new Error(`precheckEditedRuleText takes the edited rule text as a string (got ${typeof text}) — a non-string is a caller bug, not a rule a person could have typed`);
336
+ e.code = "config.invalid_argument";
337
+ throw e;
338
+ }
339
+ if (typeof command !== "string" || command === "") {
340
+ const e = new Error("precheckEditedRuleText takes the adjudicated command the card was drawn for — coverage cannot be judged without it, and reporting the absence as a rejected rule would blame the person's text");
341
+ e.code = "config.invalid_argument";
342
+ throw e;
343
+ }
344
+ if (hasUnrenderableCharacters(command)) {
345
+ const e = new Error("precheckEditedRuleText takes a command this lane can read — one carrying control or format characters is not a command any card was drawn for, and the refusal line naming it would carry those bytes onto a display surface");
346
+ e.code = "config.invalid_argument";
347
+ throw e;
348
+ }
349
+ return checkEditedRuleText(text, command);
350
+ }
166
351
  async function confirmEditedCandidate(rec, edit, deps) {
167
352
  const no = (reason, detail) => ({
168
353
  ok: false,
@@ -187,7 +372,7 @@ async function confirmEditedCandidate(rec, edit, deps) {
187
372
  return no("selection_mismatch");
188
373
  let hit = edit.text === rec.edited.text;
189
374
  if (!hit) {
190
- const reparsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
375
+ const reparsed = parseSubmittedSpelling(edit.text);
191
376
  hit = "rule" in reparsed && reparsed.rule.rule === canonical;
192
377
  }
193
378
  if (!hit)
@@ -208,33 +393,30 @@ async function confirmEditedCandidate(rec, edit, deps) {
208
393
  if (rec.command === undefined) {
209
394
  return no("edit_rejected", { message: "the record does not carry the adjudicated command (minted before card edits existed) — coverage cannot be verified, so the edit is refused" });
210
395
  }
211
- const parsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
212
- if ("reject" in parsed)
213
- return no("edit_rejected", { code: parsed.reject.code, message: parsed.reject.message });
214
- if (!ruleAdmitsCommand(parsed.rule, rec.command)) {
215
- return no("edit_rejected", {
216
- message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${rec.command}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
217
- });
396
+ const checked = checkEditedRuleText(edit.text, rec.command);
397
+ if (!checked.ok) {
398
+ return no("edit_rejected", { ...(checked.code !== undefined ? { code: checked.code } : {}), message: checked.message });
218
399
  }
219
400
  const index = rec.candidates.length;
220
401
  const next = {
221
402
  ...rec,
222
403
  rev: rec.rev + 1,
223
404
  state: "approved",
224
- candidates: [...rec.candidates, { rule: parsed.rule.rule, scope }],
225
- selectedCandidate: index,
405
+ candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
406
+ offers: [...rec.offers, { kind: "single", candidate: index }],
407
+ selectedOffer: rec.offers.length,
226
408
  edited: { index, text: edit.text, at: nowIso(deps) },
227
409
  };
228
410
  const won = await deps.approvals.cas(rec.id, rec.rev, next);
229
411
  if (!won) {
230
412
  const again = await deps.approvals.get(rec.id);
231
- if (again === undefined)
413
+ if (again === undefined || isStale(again))
232
414
  return no("record_not_found");
233
415
  if (again.state === "pending")
234
416
  return no("conflict");
235
417
  return await confirmEditedCandidate(again, edit, deps);
236
418
  }
237
- return { ok: true, mintedCandidate: { index, rule: parsed.rule.rule, ticket: mintRuleTicket(rec.id, index) } };
419
+ return { ok: true, mintedCandidate: { index, rule: checked.canonicalRule, ticket: mintRuleTicket(rec.id, index) } };
238
420
  }
239
421
  export async function redeemRuleTicket(opts) {
240
422
  const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
@@ -247,12 +429,22 @@ export async function redeemRuleTicket(opts) {
247
429
  const recOwner = ownerOfRecord(rec);
248
430
  if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
249
431
  return { status: "refused", reason: "the ticket belongs to a different principal" };
432
+ if (isStale(rec))
433
+ return { status: "refused", reason: `record_schema_stale: ${STALE_RECORD_MESSAGE}` };
250
434
  if (rec.state === "pending")
251
435
  return { status: "refused", reason: "the approval record has not been confirmed" };
436
+ const damage = approvalRecordDamageOf(rec);
437
+ if (damage !== undefined)
438
+ return { status: "refused", reason: `the approval record is malformed: ${damage}` };
252
439
  const candidate = rec.candidates[parsed.index];
253
440
  if (candidate === undefined)
254
441
  return { status: "refused", reason: "the ticket names no candidate on this record" };
255
- if (rec.kind === "card" && rec.selectedCandidate !== parsed.index) {
442
+ const chosen = rec.selectedOffer;
443
+ const chosenOffer = chosen !== undefined && Number.isInteger(chosen) ? rec.offers[chosen] : undefined;
444
+ if (chosenOffer === undefined) {
445
+ return { status: "refused", reason: "the approval record is malformed: it is settled but names no valid chosen offer" };
446
+ }
447
+ if (!offerMembersOf(chosenOffer).includes(parsed.index)) {
256
448
  return { status: "refused", reason: "the ticket names an option the person did not choose" };
257
449
  }
258
450
  const parsedRule = parseAllowRuleText(candidate.rule);
@@ -280,7 +472,7 @@ export async function redeemRuleTicket(opts) {
280
472
  const ok = await opts.deps.approvals.cas(rec.id, rec.rev, next);
281
473
  if (!ok) {
282
474
  const again = await opts.deps.approvals.get(rec.id);
283
- const theirs = again?.redeemedDots?.[parsed.index];
475
+ const theirs = again === undefined || isStale(again) ? undefined : again.redeemedDots?.[parsed.index];
284
476
  if (theirs === undefined)
285
477
  return { status: "refused", reason: "the approval record changed state concurrently" };
286
478
  return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
@@ -428,17 +620,22 @@ export async function prepareCcImport(opts) {
428
620
  candidates.push({ rule: parsed.rule.rule, scope });
429
621
  }
430
622
  }
623
+ const preview = { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } };
624
+ if (candidates.length === 0)
625
+ return { preview };
431
626
  const record = {
432
627
  id: mintId(opts.deps, "rar"),
433
628
  ...recordIdentityOf(owner),
629
+ schema: 2,
434
630
  kind: "import",
435
631
  state: "pending",
436
632
  rev: 0,
437
633
  candidates,
634
+ offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }],
438
635
  createdAt: nowIso(opts.deps),
439
636
  };
440
637
  await opts.deps.approvals.create(record);
441
- return { preview: { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } }, approvalId: record.id };
638
+ return { preview, approvalId: record.id };
442
639
  }
443
640
  export const STARTER_RULES = [
444
641
  "Bash(ls)",
@@ -463,10 +660,12 @@ export async function prepareStarterBatch(opts) {
463
660
  const record = {
464
661
  id: mintId(opts.deps, "rar"),
465
662
  ...recordIdentityOf(owner),
663
+ schema: 2,
466
664
  kind: "starter",
467
665
  state: "pending",
468
666
  rev: 0,
469
667
  candidates,
668
+ offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }],
470
669
  createdAt: nowIso(opts.deps),
471
670
  };
472
671
  await opts.deps.approvals.create(record);
@@ -480,14 +679,18 @@ export async function redeemRuleBatch(opts) {
480
679
  const recOwner = ownerOfRecord(rec);
481
680
  if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
482
681
  return { status: "refused", reason: "the record belongs to a different principal" };
483
- if (rec.kind === "card")
484
- return { status: "refused", reason: "a card record is redeemed by its chosen ticket, not as a batch" };
682
+ if (isStale(rec))
683
+ return { status: "refused", reason: `record_schema_stale: ${STALE_RECORD_MESSAGE}` };
485
684
  if (rec.state === "pending")
486
685
  return { status: "refused", reason: "the approval record has not been confirmed" };
487
- const persisted = [];
488
- const deduped = [];
489
- const skippedAtRedeem = [];
490
- let rev = 0;
686
+ const damage = approvalRecordDamageOf(rec);
687
+ if (damage !== undefined)
688
+ return { status: "refused", reason: `the approval record is malformed: ${damage}` };
689
+ const chosen = rec.selectedOffer;
690
+ const chosenOffer = chosen !== undefined && Number.isInteger(chosen) ? rec.offers[chosen] : undefined;
691
+ if (chosenOffer === undefined) {
692
+ return { status: "refused", reason: "the approval record is malformed: it is settled but names no valid chosen offer" };
693
+ }
491
694
  let store;
492
695
  try {
493
696
  store = storeForOwner(opts.deps.provider, caller);
@@ -495,8 +698,11 @@ export async function redeemRuleBatch(opts) {
495
698
  catch (err) {
496
699
  return { status: "refused", reason: errText(err) };
497
700
  }
498
- for (let i = 0; i < rec.candidates.length; i++) {
499
- const candidate = rec.candidates[i];
701
+ const members = [];
702
+ let rev = 0;
703
+ for (const candidateIndex of offerMembersOf(chosenOffer)) {
704
+ const candidate = rec.candidates[candidateIndex];
705
+ const row = { candidateIndex, rule: candidate.rule, scope: candidate.scope };
500
706
  let alreadyThere = false;
501
707
  try {
502
708
  const snap = await store.list();
@@ -504,22 +710,20 @@ export async function redeemRuleBatch(opts) {
504
710
  alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
505
711
  }
506
712
  catch (err) {
507
- return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
713
+ members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
714
+ continue;
508
715
  }
509
716
  const res = await redeemRuleTicket({
510
- ticket: mintRuleTicket(rec.id, i),
717
+ ticket: mintRuleTicket(rec.id, candidateIndex),
511
718
  ...(caller.kind === "principal" ? { principal: caller.principal } : { owner: caller }),
512
719
  deps: opts.deps,
513
720
  });
514
721
  if (res.status === "refused") {
515
- skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
722
+ members.push({ ...row, status: "refused", reason: res.reason });
516
723
  continue;
517
724
  }
518
725
  rev = res.rev;
519
- if (alreadyThere)
520
- deduped.push(candidate);
521
- else
522
- persisted.push(candidate);
726
+ members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
523
727
  }
524
- return { persisted, deduped, skippedAtRedeem, rev };
728
+ return { members, rev };
525
729
  }