@sema-agent/server 7.45.0 → 7.46.0-rc.2

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 (41) hide show
  1. package/USAGE.md +38 -0
  2. package/dist/approval-card.d.ts +106 -40
  3. package/dist/approval-card.js +45 -8
  4. package/dist/boot/coordinators.d.ts +1 -1
  5. package/dist/boot/memory-consolidation.d.ts +191 -0
  6. package/dist/boot/memory-consolidation.js +132 -0
  7. package/dist/boot/runner-deps.d.ts +1 -1
  8. package/dist/config-types.d.ts +48 -2
  9. package/dist/config-types.js +1 -0
  10. package/dist/config.d.ts +2 -2
  11. package/dist/config.js +53 -3
  12. package/dist/http/routes/capabilities.js +4 -0
  13. package/dist/http/routes/memory-compliance.d.ts +102 -0
  14. package/dist/http/routes/memory-compliance.js +113 -0
  15. package/dist/http/routes/memory-consolidation.d.ts +60 -0
  16. package/dist/http/routes/memory-consolidation.js +155 -0
  17. package/dist/http/routes/memory-origin.d.ts +124 -0
  18. package/dist/http/routes/memory-origin.js +193 -0
  19. package/dist/http/routes/rules.js +1 -1
  20. package/dist/http/routes/side-query.js +1 -0
  21. package/dist/http/server.d.ts +71 -2
  22. package/dist/http/server.js +27 -2
  23. package/dist/main.js +55 -2
  24. package/dist/memory-operator-faces.d.ts +211 -0
  25. package/dist/memory-operator-faces.js +76 -0
  26. package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
  27. package/dist/plugins/checkpoint-store-sql.js +31 -17
  28. package/dist/plugins/local-checkpoint-store.js +3 -3
  29. package/dist/plugins/permission-rule-store-file.d.ts +2 -2
  30. package/dist/plugins/permission-rule-store-file.js +41 -4
  31. package/dist/plugins/permission-rule-store-sql.d.ts +2 -2
  32. package/dist/plugins/permission-rule-store-sql.js +59 -18
  33. package/dist/plugins/store-backend.d.ts +1 -1
  34. package/dist/plugins/tidb-pool.js +3 -3
  35. package/dist/rules-consent.d.ts +30 -3
  36. package/dist/rules-consent.js +50 -7
  37. package/dist/task-cwd.d.ts +1 -1
  38. package/dist/tool-approval.d.ts +43 -10
  39. package/dist/tool-approval.js +105 -27
  40. package/dist/trace/core-keyset-guard.d.ts +2 -2
  41. package/package.json +3 -3
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
3
3
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
4
4
  import { redactDeep, redactSecrets } from "../trace/redact.js";
5
- import { MAX_RULE_SUGGESTIONS, RuleSuggestionRawSchema } from "../approval-card.js";
5
+ import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema } from "../approval-card.js";
6
6
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
7
7
  import { recordFailOpen } from "../observability/fail-open.js";
8
8
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
@@ -14,27 +14,41 @@ export const TERMINAL_GRACE_MS = 3_600_000;
14
14
  export function tokenFingerprint(token) {
15
15
  return "sha256:" + createHash("sha256").update(token).digest("hex").slice(0, 12);
16
16
  }
17
- const MAX_RULE_SUGGESTION_SCAN = 32;
18
- export function boundedRuleSuggestions(raw, opts = {}) {
17
+ const MAX_RULE_OFFER_SCAN = 32;
18
+ function preboundBatchMembers(item) {
19
+ if (typeof item !== "object" || item === null)
20
+ return item;
21
+ const candidate = item;
22
+ if (candidate.kind !== "batch" || !Array.isArray(candidate.rules) || candidate.rules.length <= MAX_RULE_OFFER_BATCH_MEMBERS)
23
+ return item;
24
+ return { ...candidate, rules: candidate.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS) };
25
+ }
26
+ export function boundedRuleOffers(raw, opts = {}) {
19
27
  if (!Array.isArray(raw))
20
28
  return undefined;
21
29
  const observe = opts.countRedactions ?? true;
30
+ const scrub = (text) => redactSecrets(text, { observe }).slice(0, MAX_RULE_TEXT_CHARS);
22
31
  const out = [];
23
- for (const item of raw.slice(0, MAX_RULE_SUGGESTION_SCAN)) {
24
- if (out.length >= MAX_RULE_SUGGESTIONS)
32
+ for (const item of raw.slice(0, MAX_RULE_OFFER_SCAN)) {
33
+ if (out.length >= MAX_RULE_OFFERS)
25
34
  break;
26
- const parsed = RuleSuggestionRawSchema.safeParse(item);
35
+ const parsed = RuleOfferRawSchema.safeParse(preboundBatchMembers(item));
27
36
  if (!parsed.success)
28
37
  continue;
29
- out.push({
30
- rule: redactSecrets(parsed.data.rule, { observe }).slice(0, MAX_RULE_TEXT_CHARS),
31
- match: parsed.data.match,
32
- command: redactSecrets(parsed.data.command, { observe }).slice(0, MAX_RULE_TEXT_CHARS),
33
- });
38
+ if (parsed.data.kind === "single") {
39
+ out.push({ kind: "single", rule: scrub(parsed.data.rule), match: parsed.data.match, command: scrub(parsed.data.command) });
40
+ }
41
+ else {
42
+ out.push({
43
+ kind: "batch",
44
+ rules: parsed.data.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS).map((r) => ({ rule: scrub(r.rule), match: r.match, command: scrub(r.command), segment: scrub(r.segment) })),
45
+ uncoveredSegments: parsed.data.uncoveredSegments,
46
+ });
47
+ }
34
48
  }
35
49
  return out.length > 0 ? out : undefined;
36
50
  }
37
- function readRuleSuggestionsCell(cell) {
51
+ function readRuleOffersCell(cell) {
38
52
  let parsed;
39
53
  try {
40
54
  parsed = parseJson(cell);
@@ -43,7 +57,7 @@ function readRuleSuggestionsCell(cell) {
43
57
  recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
44
58
  return undefined;
45
59
  }
46
- return boundedRuleSuggestions(parsed, { countRedactions: false });
60
+ return boundedRuleOffers(parsed, { countRedactions: false });
47
61
  }
48
62
  function readRiskDescriptorCell(cell) {
49
63
  try {
@@ -207,7 +221,7 @@ export class SqlCheckpointStore {
207
221
  const pa = cp.pendingAction;
208
222
  const version = checkpointVersionOf(cp);
209
223
  const toolInput = boundedToolInput(pa?.args);
210
- const ruleSuggestions = boundedRuleSuggestions(pa?.ruleSuggestions);
224
+ const ruleOffers = boundedRuleOffers(pa?.ruleOffers);
211
225
  try {
212
226
  await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
213
227
  "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
@@ -226,7 +240,7 @@ export class SqlCheckpointStore {
226
240
  cp.gate?.kind ?? null,
227
241
  pa?.boundInputHash ?? null,
228
242
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
229
- ruleSuggestions === undefined ? null : this.json(ruleSuggestions, "rule suggestions"),
243
+ ruleOffers === undefined ? null : this.json(ruleOffers, "rule offers"),
230
244
  ]);
231
245
  }
232
246
  catch (e) {
@@ -340,7 +354,7 @@ export class SqlCheckpointStore {
340
354
  const toolCallId = r.tool_call_id ?? null;
341
355
  const boundInputHash = r.bound_input_hash ?? null;
342
356
  const gateKind = r.gate_kind ?? null;
343
- const ruleSuggestions = readRuleSuggestionsCell(r.rule_suggestions);
357
+ const ruleOffers = readRuleOffersCell(r.rule_suggestions);
344
358
  return {
345
359
  sessionId: String(r.session_id),
346
360
  scope: String(r.scope),
@@ -354,7 +368,7 @@ export class SqlCheckpointStore {
354
368
  createdAt: Number(r.created_at_ms),
355
369
  deadline: r.deadline == null ? null : Number(r.deadline),
356
370
  riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
357
- ...(ruleSuggestions !== undefined ? { ruleSuggestions } : {}),
371
+ ...(ruleOffers !== undefined ? { ruleOffers } : {}),
358
372
  };
359
373
  });
360
374
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSyn
3
3
  import { join } from "node:path";
4
4
  import { isApprovalGateKind } from "../tool-approval.js";
5
5
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
6
- import { boundedRuleSuggestions, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
6
+ import { boundedRuleOffers, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
7
7
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
8
8
  function terminalAtOf(cp) {
9
9
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
@@ -156,8 +156,8 @@ export class LocalCheckpointStore {
156
156
  deadline: cp.deadline ?? null,
157
157
  riskDescriptor: gate?.riskDescriptor ?? null,
158
158
  ...(() => {
159
- const rs = boundedRuleSuggestions(pa?.ruleSuggestions);
160
- return rs !== undefined ? { ruleSuggestions: rs } : {};
159
+ const rs = boundedRuleOffers(pa?.ruleOffers);
160
+ return rs !== undefined ? { ruleOffers: rs } : {};
161
161
  })(),
162
162
  });
163
163
  }
@@ -1,4 +1,4 @@
1
- import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope } from "@sema-agent/core";
1
+ import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
2
2
  import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
3
3
  import type { PermissionRuleStoreBundle } from "../rules-consent.js";
4
4
  /**
@@ -19,7 +19,7 @@ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordSt
19
19
  * 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
20
20
  * 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
21
21
  close(): void;
22
- get(id: string): Promise<RuleApprovalRecord | undefined>;
22
+ get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
23
23
  create(record: RuleApprovalRecord): Promise<void>;
24
24
  cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
25
25
  /** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
@@ -9,21 +9,34 @@ const RuleScopeSchema = z.union([
9
9
  ]);
10
10
  const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
11
11
  const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
12
+ const RuleOffer2Schema = z.union([
13
+ z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
14
+ z
15
+ .object({
16
+ kind: z.literal("batch"),
17
+ candidates: z.array(z.number().int().nonnegative().safe()),
18
+ segments: z.array(z.string()).optional(),
19
+ uncoveredSegments: z.number().int().nonnegative().safe().optional(),
20
+ })
21
+ .strict(),
22
+ ]);
12
23
  const ApprovalRecordSchema = z
13
24
  .object({
14
25
  id: z.string().min(1),
15
26
  principal: z.string().optional(),
16
27
  owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
28
+ schema: z.literal(2),
17
29
  kind: z.enum(["card", "import", "starter"]),
18
30
  state: z.enum(["pending", "approved", "redeemed"]),
19
31
  candidates: z.array(RuleCandidateSchema),
32
+ offers: z.array(RuleOffer2Schema),
20
33
  createdAt: z.string(),
21
34
  toolCallId: z.string().optional(),
22
35
  boundInputHash: z.string().optional(),
23
36
  command: z.string().optional(),
24
37
  edited: z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict().optional(),
25
38
  rev: z.number().int().nonnegative().safe(),
26
- selectedCandidate: z.number().int().nonnegative().safe().optional(),
39
+ selectedOffer: z.number().int().nonnegative().safe().optional(),
27
40
  redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
28
41
  })
29
42
  .strict();
@@ -32,21 +45,40 @@ function toApprovalRecord(row) {
32
45
  id: row.id,
33
46
  ...(row.principal !== undefined ? { principal: row.principal } : {}),
34
47
  ...(row.owner !== undefined ? { owner: row.owner } : {}),
48
+ schema: row.schema,
35
49
  kind: row.kind,
36
50
  state: row.state,
37
51
  candidates: row.candidates,
52
+ offers: row.offers,
38
53
  createdAt: row.createdAt,
39
54
  ...(row.toolCallId !== undefined ? { toolCallId: row.toolCallId } : {}),
40
55
  ...(row.boundInputHash !== undefined ? { boundInputHash: row.boundInputHash } : {}),
41
56
  ...(row.command !== undefined ? { command: row.command } : {}),
42
57
  ...(row.edited !== undefined ? { edited: row.edited } : {}),
43
58
  rev: row.rev,
44
- ...(row.selectedCandidate !== undefined ? { selectedCandidate: row.selectedCandidate } : {}),
59
+ ...(row.selectedOffer !== undefined ? { selectedOffer: row.selectedOffer } : {}),
45
60
  ...(row.redeemedDots !== undefined
46
61
  ? { redeemedDots: Object.fromEntries(Object.entries(row.redeemedDots).map(([k, v]) => [Number(k), v])) }
47
62
  : {}),
48
63
  };
49
64
  }
65
+ const StaleApprovalRowSchema = z
66
+ .object({
67
+ id: z.string().min(1),
68
+ schema: z.unknown().optional(),
69
+ principal: z.string().optional(),
70
+ owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
71
+ })
72
+ .loose();
73
+ const StaleApprovalLineSchema = z.object({ op: z.literal("put"), record: StaleApprovalRowSchema }).loose();
74
+ function toStaleEnvelope(row) {
75
+ return {
76
+ staleSchema: true,
77
+ id: row.id,
78
+ ...(row.principal !== undefined ? { principal: row.principal } : {}),
79
+ ...(row.owner !== undefined ? { owner: row.owner } : {}),
80
+ };
81
+ }
50
82
  const ApprovalLineSchema = z.union([
51
83
  z.object({ op: z.literal("put"), record: ApprovalRecordSchema }).strict(),
52
84
  z.object({ op: z.literal("discard"), id: z.string().min(1) }).strict(),
@@ -100,6 +132,11 @@ export class FileRuleApprovalRecordStore {
100
132
  for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
101
133
  const parsed = ApprovalLineSchema.safeParse(raw);
102
134
  if (!parsed.success) {
135
+ const stale = StaleApprovalLineSchema.safeParse(raw);
136
+ if (stale.success && stale.data.record.schema !== 2) {
137
+ this.rows.set(stale.data.record.id, toStaleEnvelope(stale.data.record));
138
+ continue;
139
+ }
103
140
  this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
104
141
  continue;
105
142
  }
@@ -134,7 +171,7 @@ export class FileRuleApprovalRecordStore {
134
171
  throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
135
172
  }
136
173
  const cur = this.rows.get(id);
137
- if (cur === undefined || cur.rev !== expectRev)
174
+ if (cur === undefined || "staleSchema" in cur || cur.rev !== expectRev)
138
175
  return false;
139
176
  this.log.append({ op: "put", record: next }, true);
140
177
  this.rows.set(id, structuredClone(next));
@@ -143,7 +180,7 @@ export class FileRuleApprovalRecordStore {
143
180
  async discardPendingRecord(recordId) {
144
181
  this.gate.assertUsable();
145
182
  const cur = this.rows.get(recordId);
146
- if (cur === undefined || cur.state !== "pending")
183
+ if (cur === undefined || "staleSchema" in cur || cur.state !== "pending")
147
184
  return false;
148
185
  this.log.append({ op: "discard", id: recordId }, true);
149
186
  this.rows.delete(recordId);
@@ -1,4 +1,4 @@
1
- import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
1
+ import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { PgQueryFn } from "./pg-query.js";
4
4
  import { type SqlDriver } from "./sql-driver.js";
@@ -82,7 +82,7 @@ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordSto
82
82
  constructor(db: SqlDriver, now?: () => number);
83
83
  private q;
84
84
  private enc;
85
- get(id: string): Promise<RuleApprovalRecord | undefined>;
85
+ get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
86
86
  create(record: RuleApprovalRecord): Promise<void>;
87
87
  /**
88
88
  * 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
@@ -51,6 +51,17 @@ const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe(
51
51
  const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
52
52
  const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
53
53
  const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
54
+ const RuleOffer2Schema = z.union([
55
+ z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
56
+ z
57
+ .object({
58
+ kind: z.literal("batch"),
59
+ candidates: z.array(z.number().int().nonnegative().safe()),
60
+ segments: z.array(z.string()).optional(),
61
+ uncoveredSegments: z.number().int().nonnegative().safe().optional(),
62
+ })
63
+ .strict(),
64
+ ]);
54
65
  export const PERMISSION_RULE_TABLE = "permission_rule";
55
66
  export const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
56
67
  export const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
@@ -87,7 +98,19 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
87
98
  state VARCHAR(32) NOT NULL,
88
99
  rev BIGINT NOT NULL DEFAULT 0,
89
100
  candidates_json LONGTEXT NOT NULL,
90
- selected_candidate INT NULL,
101
+ -- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 2;OCC 守卫另在 rev,
102
+ -- 两轴禁混,schema-naming 门③ 白名单登记)。读侧对非 2 的行交还 design/375 §4.5 的
103
+ -- StaleRuleApprovalRecord 信封(identity 键 only)—— 本仓 SCHEMA POLICY 是删表重建,重建后的表
104
+ -- 不该有这种行,这一臂是「未来再换形」的防御位,绝不静默 widen 成一条本形担保不了的记录。
105
+ version INT NOT NULL,
106
+ -- offers_json:core \`RuleOffer2[]\`(design/375 §4.1)—— 按 index 引用 candidates 的 OPTION 结构,
107
+ -- **绝不存规则文本副本**(显示三元组由 core \`ruleOffersOfRecord\` 从候选文本投影;存副本=造一个
108
+ -- 与真会落地的规则漂移的面)。batch 臂上 segments/uncoveredSegments 是 CARD 记录的铸造时事实。
109
+ offers_json LONGTEXT NOT NULL,
110
+ -- selected_offer:确认转移记下的 **OFFER index**(design/375 §4.3,接替 selected_candidate 的席位)。
111
+ -- ⚠️ 两个 index 空间刻意分离:确认键 OFFER index(本列),而 ticket / redeemed_dots_json 仍键
112
+ -- CANDIDATE index —— 兑付/重放锚不随卡面 OPTION 结构漂。
113
+ selected_offer INT NULL,
91
114
  redeemed_dots_json LONGTEXT NULL,
92
115
  tool_call_id VARCHAR(255) NULL,
93
116
  bound_input_hash VARCHAR(255) NULL,
@@ -147,14 +170,16 @@ function isMissingColumnError(err, dialect) {
147
170
  }
148
171
  export async function assertPermissionRuleApprovalSchema(query, dialect) {
149
172
  try {
150
- await query(`SELECT command, edited_json FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
173
+ await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
151
174
  }
152
175
  catch (err) {
153
176
  if (!isMissingColumnError(err, dialect))
154
177
  throw err;
155
- throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} is missing the #340 card-edit columns (command / edited_json) — refusing to start. ` +
156
- `The approval card now accepts a person-EDITED rule text, and the engine's coverage gate reads the adjudicated ` +
157
- `command back off this record; the record also carries which candidate was edited. This repository ships no ` +
178
+ throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} is missing required columns — refusing to start. This build needs the ` +
179
+ `#340 card-edit columns (command / edited_json) and the design/375 record-form columns ` +
180
+ `(version / offers_json / selected_offer): the approval record now stores its OPTION structure and the ` +
181
+ `chosen offer, and the store refuses to read rows whose record-form version it cannot vouch for. ` +
182
+ `This repository ships no ` +
158
183
  `ALTER TABLE migrations, so the table must be recreated: run \`DROP TABLE ${PERMISSION_RULE_APPROVAL_TABLE};\` on the ` +
159
184
  `${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
160
185
  `The cost is bounded: this table holds pending/settled approval RECORDS (the audit trail of who said yes to which ` +
@@ -190,7 +215,13 @@ export async function ensurePgPermissionRuleSchema(q) {
190
215
  state VARCHAR(32) COLLATE "C" NOT NULL,
191
216
  rev BIGINT NOT NULL DEFAULT 0,
192
217
  candidates_json TEXT COLLATE "C" NOT NULL,
193
- selected_candidate INT,
218
+ -- version / offers_json / selected_offer:design/375 换形三列(MySQL 孪生的行内注写了理由:
219
+ -- version=数据格式版本恒 2,非 2 的行读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
220
+ -- 禁存文本副本;selected_offer=确认的 OFFER index,与 ticket/redeemed_dots 的 CANDIDATE index
221
+ -- 两空间分离)。
222
+ version INT NOT NULL,
223
+ offers_json TEXT COLLATE "C" NOT NULL,
224
+ selected_offer INT,
194
225
  redeemed_dots_json TEXT COLLATE "C",
195
226
  tool_call_id VARCHAR(255) COLLATE "C",
196
227
  bound_input_hash VARCHAR(255) COLLATE "C",
@@ -445,11 +476,18 @@ export class SqlRuleApprovalRecordStore {
445
476
  return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
446
477
  }
447
478
  async get(id) {
448
- const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
479
+ const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
449
480
  const row = rows[0];
450
481
  if (row === undefined)
451
482
  return undefined;
483
+ const ownerKind = String(row.owner_kind);
484
+ const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
485
+ const identity = ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } };
486
+ if (Number(row.version) !== 2) {
487
+ return { staleSchema: true, id: String(row.record_id), ...identity };
488
+ }
452
489
  const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
490
+ const offers = parseColumn(z.array(RuleOffer2Schema), row.offers_json, "offers_json", id);
453
491
  const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
454
492
  const edited = row.edited_json === null || row.edited_json === undefined ? undefined : parseColumn(RuleEditedSchema, row.edited_json, "edited_json", id);
455
493
  const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
@@ -457,40 +495,42 @@ export class SqlRuleApprovalRecordStore {
457
495
  if (!kind.success || !state.success) {
458
496
  throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} row ${id} carries an unreadable kind/state (${String(row.kind)}/${String(row.state)})`);
459
497
  }
460
- const ownerKind = String(row.owner_kind);
461
- const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
462
498
  const redeemedDots = dotsRaw === undefined ? undefined : Object.fromEntries(Object.entries(dotsRaw).map(([k, v]) => [Number(k), v]));
463
499
  return {
464
500
  id: String(row.record_id),
465
- ...(ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } }),
501
+ ...identity,
502
+ schema: 2,
466
503
  kind: kind.data,
467
504
  state: state.data,
468
505
  candidates,
506
+ offers,
469
507
  createdAt: String(row.created_at_iso),
470
508
  rev: Number(row.rev ?? 0),
471
509
  ...(row.tool_call_id !== null && row.tool_call_id !== undefined ? { toolCallId: String(row.tool_call_id) } : {}),
472
510
  ...(row.bound_input_hash !== null && row.bound_input_hash !== undefined ? { boundInputHash: String(row.bound_input_hash) } : {}),
473
511
  ...(row.command !== null && row.command !== undefined ? { command: String(row.command) } : {}),
474
512
  ...(edited !== undefined ? { edited } : {}),
475
- ...(row.selected_candidate !== null && row.selected_candidate !== undefined ? { selectedCandidate: Number(row.selected_candidate) } : {}),
513
+ ...(row.selected_offer !== null && row.selected_offer !== undefined ? { selectedOffer: Number(row.selected_offer) } : {}),
476
514
  ...(redeemedDots !== undefined ? { redeemedDots } : {}),
477
515
  };
478
516
  }
479
517
  async create(record) {
480
518
  const nowMs = this.now();
481
519
  const owner = record.owner ?? { kind: "principal", principal: record.principal ?? "" };
482
- await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
483
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
484
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`), [
520
+ await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
522
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`), [
485
523
  record.id,
486
524
  buildRuleOwnerKey(owner),
487
525
  owner.kind,
488
526
  owner.kind === "principal" ? owner.principal : null,
489
527
  record.kind,
490
528
  record.state,
529
+ record.schema,
491
530
  record.rev,
492
531
  this.enc(record.candidates, "permission_rule_approval.candidates_json"),
493
- record.selectedCandidate ?? null,
532
+ this.enc(record.offers, "permission_rule_approval.offers_json"),
533
+ record.selectedOffer ?? null,
494
534
  record.redeemedDots === undefined ? null : this.enc(record.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
495
535
  record.toolCallId ?? null,
496
536
  record.boundInputHash ?? null,
@@ -502,18 +542,19 @@ export class SqlRuleApprovalRecordStore {
502
542
  ]);
503
543
  }
504
544
  async discardPendingRecord(recordId) {
505
- const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending'`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending'`), [recordId]);
545
+ const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = 2`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = 2`), [recordId]);
506
546
  return affected === 1;
507
547
  }
508
548
  async cas(id, expectRev, next) {
509
549
  if (next.rev !== expectRev + 1) {
510
550
  throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
511
551
  }
512
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, selected_candidate = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, selected_candidate = $4, redeemed_dots_json = $5, edited_json = $6, updated_at_ms = $7 WHERE record_id = $8 AND rev = $9`), [
552
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = 2`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = 2`), [
513
553
  next.state,
514
554
  next.rev,
515
555
  this.enc(next.candidates, "permission_rule_approval.candidates_json"),
516
- next.selectedCandidate ?? null,
556
+ this.enc(next.offers, "permission_rule_approval.offers_json"),
557
+ next.selectedOffer ?? null,
517
558
  next.redeemedDots === undefined ? null : this.enc(next.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
518
559
  next.edited === undefined ? null : this.enc(next.edited, "permission_rule_approval.edited_json"),
519
560
  this.now(),
@@ -142,7 +142,7 @@ export interface StoreBackend {
142
142
  * 三面 —— 规则桶 provider / durable 审批记录 / server 自铸的 CC 导入票。
143
143
  *
144
144
  * 🔴 **可选**(与上面那些 REQUIRED 家族不同),缺席是一个真答案:core 的 `RunnerDeps.permissionRuleStore`
145
- * 本身就是 optional,缺席 ⇒ 引擎的 `permissionRules.storeWired` 如实报 `false`,ask 帧不投 `ruleSuggestions`
145
+ * 本身就是 optional,缺席 ⇒ 引擎的 `permissionRules.storeWired` 如实报 `false`,ask 帧不投 `ruleOffers`
146
146
  * (发一格按下去无处可兑的「不再询问」= wire 谎言,比缺席更坏)。
147
147
  *
148
148
  * `local` 车道**刻意不给 in-memory twin**:一条规则是「人授权过的持久事实」,进程内 Map 形会在重启时
@@ -250,10 +250,10 @@ export const SCHEMA_STATEMENTS = [
250
250
  -- triage-sort the supervisor inbox by severity DESC — WITHOUT parsing the checkpoint blob per row.
251
251
  -- NULL on gates with no descriptor.
252
252
  risk_descriptor TEXT NULL,
253
- -- rule_suggestions ([3683]-2/[3684]② mint 面归因):core 挂在 pendingAction.ruleSuggestions 上的规则
254
- -- 候选(design/179 §4;与同步腿 AskRequest.ruleSuggestions 同一条契约),在 put() 落列 —— 于是
253
+ -- rule_suggestions ([3683]-2/[3684]② mint 面归因):core 挂在 pendingAction.ruleOffers 上的规则
254
+ -- 候选(design/179 §4;与同步腿 AskRequest.ruleOffers 同一条契约;design/375 换形后载荷=判别联合,列名保留),在 put() 落列 —— 于是
255
255
  -- listPending 不必为一格展示材料去拖整只 checkpoint blob(那是整份 suspend 快照)。落列前过
256
- -- boundedRuleSuggestions:窄读(坏条丢弃)+ redactSecrets(候选文本由命令原文铸出,而运维队列跨租户
256
+ -- boundedRuleOffers:窄读(坏条丢弃)+ redactSecrets(候选文本由命令原文铸出,而运维队列跨租户
257
257
  -- 可见)+ 基数截断。NULL = 无供给(规则车道没武装 / 命令说不出规则 / 这只 ask 规则清不掉),读口
258
258
  -- 据此省键而不是铸空数组。
259
259
  rule_suggestions TEXT NULL,
@@ -1,4 +1,4 @@
1
- import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope } from "@sema-agent/core";
1
+ import { type RuleOwner, type ImportPreview, type RedeemedBatchMember, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope, type RuleOffer } from "@sema-agent/core";
2
2
  import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
3
3
  import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
4
4
  /**
@@ -80,12 +80,19 @@ export declare const RULE_IMPORT_RETRY_AFTER_SEC = 2;
80
80
  * 「几百次 SQL」而不是「几万次」。
81
81
  */
82
82
  export declare const MAX_IMPORT_CANDIDATES = 200;
83
- /** 一次导入 prepare 的产物(HTTP 200 体的素材),或**超帽**的拒绝(见 {@link MAX_IMPORT_CANDIDATES})。 */
83
+ /** 一次导入 prepare 的产物(HTTP 200 体的素材),或**超帽**的拒绝(见 {@link MAX_IMPORT_CANDIDATES})。
84
+ * 5.58(design/375):零 importable 候选 ⇒ core **不铸** `approvalId`(「absence says so」)⇒ 无票可铸,
85
+ * 第三臂把 preview(全 skipped 的理由就在里面)原样交给人看——不是错误,是「没有什么可确认」。 */
84
86
  export type RuleImportPrepared = {
85
87
  ok: true;
86
88
  preview: ImportPreview;
87
89
  ticket: string;
88
90
  expiresAtMs: number;
91
+ } | {
92
+ ok: true;
93
+ preview: ImportPreview;
94
+ ticket?: undefined;
95
+ expiresAtMs?: undefined;
89
96
  } | {
90
97
  ok: false;
91
98
  reason: "too-many-candidates";
@@ -95,7 +102,10 @@ export type RuleImportPrepared = {
95
102
  * 只为服务端日志/诊断。 */
96
103
  export type RuleImportRedeemed = {
97
104
  ok: true;
98
- result: ImportResult;
105
+ result: {
106
+ members: readonly RedeemedBatchMember[];
107
+ rev: number;
108
+ };
99
109
  }
100
110
  /** `retryable` = 这次失败**没有裁定任何事**(store 抛错 / CAS 冲突),认领已放回,属主原样重试即可。
101
111
  * 它与裁定性拒绝(载荷被篡改、记录不存在)分家是承重的:把两者塌成一格,一次数据库抖动就会被
@@ -118,14 +128,31 @@ export type CardRuleRedemption =
118
128
  | {
119
129
  kind: "edited";
120
130
  text: string;
131
+ }
132
+ /** design/377:人勾了**合取批**——传的是**行素材上人看到的那只 batch 整只**(不是裸 index),
133
+ * 等式两侧(看到的 / 重铸可兑的)在本车道一个函数里对齐(防漂等式,见 batch 分支行注)。 */
134
+ | {
135
+ kind: "batch";
136
+ offer: Extract<RuleOffer, {
137
+ kind: "batch";
138
+ }>;
121
139
  };
122
140
  /** 卡道兑付的结果。`canonical` = **真正落盘**的那条规则文本(编辑臂上它可能与人敲的原字节不同 ——
123
141
  * core 会把 `Bash(adb *)` 规范成 `Bash(adb:*)`;界面要回显的是这一份,不是输入框里的那一份)。 */
124
142
  export type CardRulePersisted = {
125
143
  ok: true;
144
+ kind: "single";
126
145
  rule: string;
127
146
  rev: number;
128
147
  alreadyRedeemed: boolean;
148
+ }
149
+ /** design/377 批臂:全体成员落地(persisted/deduped 都计——等价规则已在店=同意已生效)。
150
+ * `rules` = 规范文本,**展示序**(= batch offer 的成员序,壳逐条回显)。 */
151
+ | {
152
+ ok: true;
153
+ kind: "batch";
154
+ rules: readonly string[];
155
+ rev: number;
129
156
  } | {
130
157
  ok: false;
131
158
  reason: "no-candidates" | "unknown-candidate" | "confirm-refused" | "redeem-refused"