@sema-agent/core 5.57.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 (183) hide show
  1. package/CHANGELOG.md +48 -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 +2 -2
  9. package/dist/agents/subagent-steps.d.ts +1 -1
  10. package/dist/agents/subagent.d.ts +13 -13
  11. package/dist/agents/team.d.ts +5 -5
  12. package/dist/agents/tool-filter.d.ts +2 -2
  13. package/dist/agents/verify.d.ts +1 -1
  14. package/dist/bench/metrics.d.ts +35 -35
  15. package/dist/brain/degrading.d.ts +1 -1
  16. package/dist/brain/errors.d.ts +3 -3
  17. package/dist/brain/reasoning.d.ts +2 -2
  18. package/dist/brain/repetition.d.ts +1 -1
  19. package/dist/brain/status-sink.d.ts +2 -2
  20. package/dist/brain/stream-shared.d.ts +1 -1
  21. package/dist/config/catalog.d.ts +5 -5
  22. package/dist/core/arg-summary.d.ts +4 -4
  23. package/dist/core/ask-class.d.ts +2 -2
  24. package/dist/core/ask-question.d.ts +1 -1
  25. package/dist/core/auto-compaction.d.ts +15 -15
  26. package/dist/core/auto-mode.d.ts +5 -5
  27. package/dist/core/background-agent-store.d.ts +20 -20
  28. package/dist/core/background-shell.d.ts +4 -4
  29. package/dist/core/checkpoint-store.d.ts +35 -27
  30. package/dist/core/context-edit.d.ts +1 -1
  31. package/dist/core/context-guard.d.ts +1 -1
  32. package/dist/core/exec-output-tail.d.ts +6 -6
  33. package/dist/core/file-snapshot-store.d.ts +8 -8
  34. package/dist/core/git-worktree-env.d.ts +3 -3
  35. package/dist/core/governance-codes.js +2 -0
  36. package/dist/core/hooks.d.ts +73 -33
  37. package/dist/core/hooks.js +87 -25
  38. package/dist/core/image-downsample.d.ts +1 -1
  39. package/dist/core/locked-config.d.ts +1 -1
  40. package/dist/core/lsp.d.ts +1 -1
  41. package/dist/core/mailbox-store.d.ts +1 -1
  42. package/dist/core/mcp.d.ts +3 -3
  43. package/dist/core/memory-engine/consolidation-driver.d.ts +207 -0
  44. package/dist/core/memory-engine/consolidation-driver.js +378 -0
  45. package/dist/core/memory-engine/consolidation.d.ts +46 -2
  46. package/dist/core/memory-engine/consolidation.js +1 -0
  47. package/dist/core/memory-engine/data-plane.d.ts +1 -1
  48. package/dist/core/memory-engine/distiller.d.ts +550 -0
  49. package/dist/core/memory-engine/distiller.js +598 -0
  50. package/dist/core/memory-engine/dual-root.d.ts +1 -1
  51. package/dist/core/memory-engine/engine.d.ts +47 -3
  52. package/dist/core/memory-engine/engine.js +37 -3
  53. package/dist/core/memory-engine/file-backend.d.ts +1 -1
  54. package/dist/core/memory-engine/index.d.ts +4 -2
  55. package/dist/core/memory-engine/index.js +4 -2
  56. package/dist/core/memory-engine/origin-clearance.d.ts +1 -1
  57. package/dist/core/memory-engine/scope-contract.d.ts +4 -4
  58. package/dist/core/memory-engine/sync-client.d.ts +16 -16
  59. package/dist/core/memory-engine/sync.d.ts +4 -4
  60. package/dist/core/memory-recall.d.ts +1 -1
  61. package/dist/core/memory.d.ts +2 -2
  62. package/dist/core/permission-rule-consent.d.ts +185 -36
  63. package/dist/core/permission-rule-consent.js +219 -44
  64. package/dist/core/permission-rule-model.d.ts +194 -31
  65. package/dist/core/permission-rule-model.js +93 -35
  66. package/dist/core/permission-rules.d.ts +9 -9
  67. package/dist/core/remote-env.d.ts +8 -8
  68. package/dist/core/roles.d.ts +3 -3
  69. package/dist/core/roles.js +1 -0
  70. package/dist/core/runner/assemble-result.d.ts +2 -2
  71. package/dist/core/runner/compaction-call-options.d.ts +3 -3
  72. package/dist/core/runner/memory-consolidation-driver.d.ts +49 -0
  73. package/dist/core/runner/memory-consolidation-driver.js +60 -0
  74. package/dist/core/runner/memory-consolidation.d.ts +1 -1
  75. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  76. package/dist/core/runner/prepare-task.d.ts +21 -21
  77. package/dist/core/runner/prepare-task.js +21 -14
  78. package/dist/core/runner/prepare-workspace-restore.d.ts +2 -2
  79. package/dist/core/runner/runtask.d.ts +11 -11
  80. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  81. package/dist/core/runner/teardown-bounded.d.ts +1 -1
  82. package/dist/core/runner/tool-disclosure.d.ts +2 -2
  83. package/dist/core/runner/turn-attachments.d.ts +11 -11
  84. package/dist/core/scheduler.d.ts +5 -5
  85. package/dist/core/secret-env.d.ts +1 -1
  86. package/dist/core/sensitive-path-policy.d.ts +1 -1
  87. package/dist/core/session-policy-store.d.ts +2 -2
  88. package/dist/core/session-reconcile.d.ts +2 -2
  89. package/dist/core/session-store.d.ts +3 -3
  90. package/dist/core/session.d.ts +1 -1
  91. package/dist/core/shutdown-debug.d.ts +2 -2
  92. package/dist/core/side-query.d.ts +2 -2
  93. package/dist/core/spec-contract.d.ts +1 -1
  94. package/dist/core/store-contracts/contract-harness.d.ts +2 -2
  95. package/dist/core/store-contracts/contract-kit-version.d.ts +2 -2
  96. package/dist/core/store-contracts/mailbox-store-contract.d.ts +1 -1
  97. package/dist/core/store-contracts/mailbox-store-contract.js +1 -1
  98. package/dist/core/task-notification.d.ts +5 -5
  99. package/dist/core/task-registry-agent.d.ts +12 -12
  100. package/dist/core/task-registry-monitor.d.ts +1 -1
  101. package/dist/core/task-registry-shared.d.ts +41 -41
  102. package/dist/core/task-registry.d.ts +12 -12
  103. package/dist/core/tool-detach.d.ts +2 -2
  104. package/dist/core/tool-errors.d.ts +3 -3
  105. package/dist/core/tool-policy.d.ts +55 -28
  106. package/dist/core/tool-result-budget.d.ts +1 -1
  107. package/dist/core/tool-result-store.d.ts +2 -2
  108. package/dist/core/tools.d.ts +1 -1
  109. package/dist/core/trace.d.ts +26 -23
  110. package/dist/core/types.d.ts +123 -70
  111. package/dist/core/untrusted-egress.d.ts +1 -1
  112. package/dist/core/untrusted-text.d.ts +7 -7
  113. package/dist/core/wiring-manifest.d.ts +5 -5
  114. package/dist/core/workflow-journal-store.d.ts +14 -14
  115. package/dist/core/workflow-run-store-contract.d.ts +1 -1
  116. package/dist/core/workflow-run-store-contract.js +1 -1
  117. package/dist/core/workflow-run-store.d.ts +4 -4
  118. package/dist/engine/compaction/compaction.d.ts +3 -3
  119. package/dist/engine/compaction/utils.d.ts +2 -2
  120. package/dist/engine/execution-env/kill-tree.d.ts +1 -1
  121. package/dist/engine/execution-env/node-execution-env.d.ts +8 -8
  122. package/dist/engine/harness/agent-harness.d.ts +6 -6
  123. package/dist/engine/harness/messages.d.ts +1 -1
  124. package/dist/engine/harness/types.d.ts +10 -10
  125. package/dist/engine/llm/types.d.ts +14 -14
  126. package/dist/engine/loop/agent-loop.d.ts +3 -3
  127. package/dist/engine/loop/types.d.ts +4 -4
  128. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
  129. package/dist/engine/session/import-validate.d.ts +1 -1
  130. package/dist/engine/session/log-digest.d.ts +1 -1
  131. package/dist/engine/session/memory-repo.d.ts +2 -2
  132. package/dist/engine/session/session.d.ts +4 -4
  133. package/dist/fixtures/index.d.ts +4 -4
  134. package/dist/index.d.ts +5 -4
  135. package/dist/index.js +3 -2
  136. package/dist/orchestration/goal.d.ts +1 -1
  137. package/dist/orchestration/run-spec.d.ts +1 -1
  138. package/dist/orchestration/run-workflow-tool.d.ts +12 -12
  139. package/dist/orchestration/workflow-governance.d.ts +4 -4
  140. package/dist/orchestration/workflow-observe.d.ts +1 -1
  141. package/dist/orchestration/workflow-script-runner.d.ts +1 -1
  142. package/dist/orchestration/workflow-script-store.d.ts +9 -9
  143. package/dist/orchestration/workflow-size-guideline.d.ts +1 -1
  144. package/dist/orchestration/workflow-types.d.ts +5 -5
  145. package/dist/orchestration/workflow.d.ts +10 -10
  146. package/dist/prompt-assembly/artifact-store.d.ts +1 -1
  147. package/dist/prompt-assembly/artifact.d.ts +1 -1
  148. package/dist/prompt-assembly/assemble.d.ts +1 -1
  149. package/dist/prompt-assembly/composer.d.ts +2 -2
  150. package/dist/prompt-assembly/epoch.d.ts +2 -2
  151. package/dist/prompt-assembly/event-registry.d.ts +1 -1
  152. package/dist/prompt-assembly/explain.d.ts +3 -3
  153. package/dist/prompt-assembly/tool-catalog.d.ts +1 -1
  154. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -4
  155. package/dist/prompt-assembly/types.d.ts +12 -12
  156. package/dist/prompts/coordinator.d.ts +1 -1
  157. package/dist/prompts/default.d.ts +8 -8
  158. package/dist/prompts/simple-sections.d.ts +3 -3
  159. package/dist/prompts/supervisor.d.ts +2 -2
  160. package/dist/scenarios/full-body.d.ts +3 -3
  161. package/dist/scenarios/scenario-registry.d.ts +1 -1
  162. package/dist/stores/cc/sidecar-transcript.d.ts +3 -3
  163. package/dist/stores/file/fs-atomic.d.ts +2 -2
  164. package/dist/stores/file/index.d.ts +1 -1
  165. package/dist/stores/file/session-store.d.ts +2 -2
  166. package/dist/stores/file/workflow-journal-store.d.ts +4 -4
  167. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  168. package/dist/tools/fs/encoding.d.ts +4 -4
  169. package/dist/tools/fs/fs-bash.d.ts +3 -3
  170. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  171. package/dist/tools/fs/fs-shared.d.ts +6 -6
  172. package/dist/tools/fs/index.d.ts +2 -2
  173. package/dist/tools/fs/notebook.d.ts +1 -1
  174. package/dist/tools/fs/pdf.d.ts +1 -1
  175. package/dist/tools/fs/read-deny.d.ts +1 -1
  176. package/dist/tools/fs/safety.d.ts +9 -9
  177. package/dist/tools/fs/search.d.ts +2 -2
  178. package/dist/tools/monitor.d.ts +3 -3
  179. package/dist/tools/task-list.d.ts +2 -2
  180. package/dist/tools/web.d.ts +4 -4
  181. package/dist/tools/worktree.d.ts +5 -5
  182. package/package.json +1 -1
  183. package/test/export-surface.snapshot.json +56 -3
@@ -1,11 +1,21 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { hasUnrenderableCharacters, 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);
@@ -251,13 +403,14 @@ async function confirmEditedCandidate(rec, edit, deps) {
251
403
  rev: rec.rev + 1,
252
404
  state: "approved",
253
405
  candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
254
- selectedCandidate: index,
406
+ offers: [...rec.offers, { kind: "single", candidate: index }],
407
+ selectedOffer: rec.offers.length,
255
408
  edited: { index, text: edit.text, at: nowIso(deps) },
256
409
  };
257
410
  const won = await deps.approvals.cas(rec.id, rec.rev, next);
258
411
  if (!won) {
259
412
  const again = await deps.approvals.get(rec.id);
260
- if (again === undefined)
413
+ if (again === undefined || isStale(again))
261
414
  return no("record_not_found");
262
415
  if (again.state === "pending")
263
416
  return no("conflict");
@@ -276,12 +429,22 @@ export async function redeemRuleTicket(opts) {
276
429
  const recOwner = ownerOfRecord(rec);
277
430
  if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
278
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}` };
279
434
  if (rec.state === "pending")
280
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}` };
281
439
  const candidate = rec.candidates[parsed.index];
282
440
  if (candidate === undefined)
283
441
  return { status: "refused", reason: "the ticket names no candidate on this record" };
284
- 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)) {
285
448
  return { status: "refused", reason: "the ticket names an option the person did not choose" };
286
449
  }
287
450
  const parsedRule = parseAllowRuleText(candidate.rule);
@@ -309,7 +472,7 @@ export async function redeemRuleTicket(opts) {
309
472
  const ok = await opts.deps.approvals.cas(rec.id, rec.rev, next);
310
473
  if (!ok) {
311
474
  const again = await opts.deps.approvals.get(rec.id);
312
- const theirs = again?.redeemedDots?.[parsed.index];
475
+ const theirs = again === undefined || isStale(again) ? undefined : again.redeemedDots?.[parsed.index];
313
476
  if (theirs === undefined)
314
477
  return { status: "refused", reason: "the approval record changed state concurrently" };
315
478
  return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
@@ -457,17 +620,22 @@ export async function prepareCcImport(opts) {
457
620
  candidates.push({ rule: parsed.rule.rule, scope });
458
621
  }
459
622
  }
623
+ const preview = { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } };
624
+ if (candidates.length === 0)
625
+ return { preview };
460
626
  const record = {
461
627
  id: mintId(opts.deps, "rar"),
462
628
  ...recordIdentityOf(owner),
629
+ schema: 2,
463
630
  kind: "import",
464
631
  state: "pending",
465
632
  rev: 0,
466
633
  candidates,
634
+ offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }],
467
635
  createdAt: nowIso(opts.deps),
468
636
  };
469
637
  await opts.deps.approvals.create(record);
470
- return { preview: { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } }, approvalId: record.id };
638
+ return { preview, approvalId: record.id };
471
639
  }
472
640
  export const STARTER_RULES = [
473
641
  "Bash(ls)",
@@ -492,10 +660,12 @@ export async function prepareStarterBatch(opts) {
492
660
  const record = {
493
661
  id: mintId(opts.deps, "rar"),
494
662
  ...recordIdentityOf(owner),
663
+ schema: 2,
495
664
  kind: "starter",
496
665
  state: "pending",
497
666
  rev: 0,
498
667
  candidates,
668
+ offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }],
499
669
  createdAt: nowIso(opts.deps),
500
670
  };
501
671
  await opts.deps.approvals.create(record);
@@ -509,14 +679,18 @@ export async function redeemRuleBatch(opts) {
509
679
  const recOwner = ownerOfRecord(rec);
510
680
  if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
511
681
  return { status: "refused", reason: "the record belongs to a different principal" };
512
- if (rec.kind === "card")
513
- 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}` };
514
684
  if (rec.state === "pending")
515
685
  return { status: "refused", reason: "the approval record has not been confirmed" };
516
- const persisted = [];
517
- const deduped = [];
518
- const skippedAtRedeem = [];
519
- 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
+ }
520
694
  let store;
521
695
  try {
522
696
  store = storeForOwner(opts.deps.provider, caller);
@@ -524,8 +698,11 @@ export async function redeemRuleBatch(opts) {
524
698
  catch (err) {
525
699
  return { status: "refused", reason: errText(err) };
526
700
  }
527
- for (let i = 0; i < rec.candidates.length; i++) {
528
- 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 };
529
706
  let alreadyThere = false;
530
707
  try {
531
708
  const snap = await store.list();
@@ -533,22 +710,20 @@ export async function redeemRuleBatch(opts) {
533
710
  alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
534
711
  }
535
712
  catch (err) {
536
- 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;
537
715
  }
538
716
  const res = await redeemRuleTicket({
539
- ticket: mintRuleTicket(rec.id, i),
717
+ ticket: mintRuleTicket(rec.id, candidateIndex),
540
718
  ...(caller.kind === "principal" ? { principal: caller.principal } : { owner: caller }),
541
719
  deps: opts.deps,
542
720
  });
543
721
  if (res.status === "refused") {
544
- skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
722
+ members.push({ ...row, status: "refused", reason: res.reason });
545
723
  continue;
546
724
  }
547
725
  rev = res.rev;
548
- if (alreadyThere)
549
- deduped.push(candidate);
550
- else
551
- persisted.push(candidate);
726
+ members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
552
727
  }
553
- return { persisted, deduped, skippedAtRedeem, rev };
728
+ return { members, rev };
554
729
  }