@sema-agent/server 7.93.3 → 7.93.5

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.
@@ -1,6 +1,7 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { canAskTransition } from "../approval-ask-machine.js";
3
3
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
4
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn } from "./sql-json-column.js";
4
5
  import { ensureIndexes } from "./ensure-index.js";
5
6
  export function assertIntegralEpochMs(field, value) {
6
7
  if (!Number.isSafeInteger(value)) {
@@ -12,6 +13,18 @@ export function assertIdempotencyKeyShape(key) {
12
13
  throw new Error(`invalid idempotency key: must be 1-255 chars with no leading/trailing whitespace (got ${JSON.stringify(key.slice(0, 32))}${key.length > 32 ? "…" : ""}, length ${key.length})`);
13
14
  }
14
15
  }
16
+ export const MAX_SETTLED_BY_CHARS = 16;
17
+ export function assertDecideSettledBy(decision) {
18
+ const v = decision.settledBy;
19
+ if (v === undefined)
20
+ return;
21
+ if (v.length === 0 || v.length > MAX_SETTLED_BY_CHARS) {
22
+ throw new Error(`invalid settledBy: the settlement source is one word from the engine's closed set and lands in a VARCHAR(${MAX_SETTLED_BY_CHARS}) column — must be 1-${MAX_SETTLED_BY_CHARS} chars (got ${JSON.stringify(v.slice(0, 32))}${v.length > 32 ? "…" : ""}, length ${v.length})`);
23
+ }
24
+ if (decision.decision !== "deny") {
25
+ throw new Error(`invalid settledBy: a settlement source is only representable on a deny row (got decision="${decision.decision}") — only a person approves an action that EXECUTES, so an approve row carrying a non-human settlement source is a row no replay could honestly hand back to the engine`);
26
+ }
27
+ }
15
28
  export class ApprovalAskReadAbortedError extends Error {
16
29
  constructor(where) {
17
30
  super(`approval-ask read ${where} aborted by caller`);
@@ -47,6 +60,7 @@ export function projectAskTerminalClaimTruth(row) {
47
60
  rev: row.rev,
48
61
  decision: row.decision,
49
62
  ...(row.updatedInput !== undefined ? { updatedInput: row.updatedInput } : {}),
63
+ settledBy: row.settledBy,
50
64
  };
51
65
  }
52
66
  export const APPROVAL_ASK_TABLE = "approval_ask";
@@ -87,6 +101,12 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
87
101
  decision VARCHAR(16) NULL,
88
102
  decision_actor TEXT NULL,
89
103
  decision_note TEXT NULL,
104
+ -- settled_by(S-558 / #1016 持久半场):这条决议的结算来源词(NULL = 人答的)。语义、两态读法与
105
+ -- 「为什么是自己一列而不是塞进 decision_actor」的全文见 AskRow.settledBy 顶注。
106
+ -- VARCHAR(16):词表属主是 core(AskOutcome.settledBy,今天最长 7 字符 "timeout"),宽度与同表
107
+ -- decision 同尺同理由 —— 闭集词列,不是自由文本。截断面由入店形门堵死(assertDecideSettledBy),
108
+ -- 不靠「列够宽就不会截」这种运气(MySQL 非严格模式静默截、PG 直接报错 = 两方言不同答)。
109
+ settled_by VARCHAR(16) NULL,
90
110
  -- updated_input(S-26 / #304,A-054.15 收口):人批准时带的 ctrl+g 编辑实参,JSON 文本,与决议同一条
91
111
  -- UPDATE 原子落列;NULL = 没带编辑(与 JSON "null" = 编辑成 null 分家)。LONGTEXT 照 card_json 先例:
92
112
  -- 编辑实参是整只工具入参,VARCHAR/TEXT 会造静默截断面。语义三态与读法见 AskRow.updatedInput 顶注。
@@ -166,6 +186,8 @@ export async function ensurePgApprovalAskSchema(q) {
166
186
  decision VARCHAR(16) COLLATE "C",
167
187
  decision_actor TEXT COLLATE "C",
168
188
  decision_note TEXT COLLATE "C",
189
+ -- settled_by(S-558)—— MySQL 孪生逐字同形(宽度/语义/形门的理由写在那侧的行内注)。
190
+ settled_by VARCHAR(16) COLLATE "C",
169
191
  -- updated_input(S-26 / #304)—— MySQL 孪生逐字同形(语义/类型理由见那侧行内注;PG TEXT 无长度上限)。
170
192
  updated_input TEXT COLLATE "C",
171
193
  decided_at_ms BIGINT,
@@ -190,7 +212,7 @@ export async function ensurePgApprovalAskSchema(q) {
190
212
  }
191
213
  export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
192
214
  try {
193
- await query(`SELECT rule_command, rule_scope_root, updated_input FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
215
+ await query(`SELECT rule_command, rule_scope_root, ${jsonColumnText(dialect, "updated_input")} FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
194
216
  }
195
217
  catch (err) {
196
218
  if (!isMissingColumnError(err, dialect))
@@ -210,14 +232,21 @@ export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
210
232
  `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
211
233
  }
212
234
  }
213
- const ASK_COLS = "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, " +
214
- "provisional, rev, decision, decision_actor, decision_note, updated_input, decided_at_ms, denied_reason, gate_token, " +
215
- "gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, " +
216
- "created_at_ms, updated_at_ms";
217
- const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
218
- function parseJsonColumn(v) {
219
- return JSON.parse(String(v));
235
+ function askCols(dialect) {
236
+ return [
237
+ "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state",
238
+ "provisional, rev, decision",
239
+ jsonColumnText(dialect, "decision_actor"),
240
+ "decision_note, settled_by",
241
+ jsonColumnText(dialect, "updated_input"),
242
+ "decided_at_ms, denied_reason, gate_token",
243
+ "gate_bound_call_id, gate_bound_input_hash, idempotency_key",
244
+ jsonColumnText(dialect, "card_json"),
245
+ "rule_command, rule_scope_root, schema_version, expires_at_ms",
246
+ "created_at_ms, updated_at_ms",
247
+ ].join(", ");
220
248
  }
249
+ const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
221
250
  function toBool(v) {
222
251
  return Boolean(Number(v));
223
252
  }
@@ -228,6 +257,15 @@ function strOrNull(v) {
228
257
  return v === null || v === undefined ? null : String(v);
229
258
  }
230
259
  function mapAskRow(r) {
260
+ const askId = String(r.ask_id);
261
+ try {
262
+ return mapAskRowBody(r, askId);
263
+ }
264
+ catch (e) {
265
+ throw foldJsonColumn(e, (m) => new Error(`approval-ask-store: ${m} — refusing rather than serve an ask row nobody can read`));
266
+ }
267
+ }
268
+ function mapAskRowBody(r, askId) {
231
269
  return {
232
270
  askId: String(r.ask_id),
233
271
  taskId: String(r.task_id),
@@ -243,16 +281,17 @@ function mapAskRow(r) {
243
281
  provisional: toBool(r.provisional),
244
282
  rev: Number(r.rev),
245
283
  decision: strOrNull(r.decision) ?? null,
246
- decisionActor: r.decision_actor == null ? null : parseJsonColumn(r.decision_actor),
284
+ decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
247
285
  decisionNote: strOrNull(r.decision_note),
248
- ...(r.updated_input == null ? {} : { updatedInput: parseJsonColumn(r.updated_input) }),
286
+ settledBy: strOrNull(r.settled_by),
287
+ ...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readAnyJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
249
288
  decidedAtMs: numOrNull(r.decided_at_ms),
250
289
  deniedReason: strOrNull(r.denied_reason),
251
290
  gateToken: strOrNull(r.gate_token),
252
291
  gateBoundCallId: strOrNull(r.gate_bound_call_id),
253
292
  gateBoundInputHash: strOrNull(r.gate_bound_input_hash),
254
293
  idempotencyKey: strOrNull(r.idempotency_key),
255
- cardJson: parseJsonColumn(r.card_json),
294
+ cardJson: decodeJsonColumn(r.card_json, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.card_json for ask ${askId}`),
256
295
  ruleCommand: strOrNull(r.rule_command),
257
296
  ruleScopeRoot: strOrNull(r.rule_scope_root),
258
297
  schemaVersion: Number(r.schema_version),
@@ -315,7 +354,7 @@ export class SqlApprovalAskStore {
315
354
  return isDupKeyError(this.db.dialect, e);
316
355
  }
317
356
  async getAskOn(conn, askId) {
318
- const { rows } = await conn.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [askId]);
357
+ const { rows } = await conn.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [askId]);
319
358
  return rows[0] ? mapAskRow(rows[0]) : null;
320
359
  }
321
360
  async getBatchOn(conn, batchId) {
@@ -331,9 +370,9 @@ export class SqlApprovalAskStore {
331
370
  try {
332
371
  await conn.begin();
333
372
  await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES ($1, $2, 'OPEN', 0, $3, $4) ON CONFLICT (batch_id) DO NOTHING`), [row.batchId, row.taskId, now, now]);
334
- const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
335
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
336
- "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
373
+ const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
374
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
375
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
337
376
  row.askId,
338
377
  row.taskId,
339
378
  row.sourceTaskId,
@@ -389,6 +428,7 @@ export class SqlApprovalAskStore {
389
428
  async decideAsk(askId, batchId, decision) {
390
429
  if (decision.idempotencyKey != null)
391
430
  assertIdempotencyKeyShape(decision.idempotencyKey);
431
+ assertDecideSettledBy(decision);
392
432
  const now = Date.now();
393
433
  const dialect = this.db.dialect;
394
434
  const conn = await this.db.connect();
@@ -401,10 +441,11 @@ export class SqlApprovalAskStore {
401
441
  }
402
442
  let askRes;
403
443
  try {
404
- askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, updated_input=${ph(dialect, 4)}, decided_at_ms=${ph(dialect, 5)}, idempotency_key=${ph(dialect, 6)}, rev=rev+1, updated_at_ms=${ph(dialect, 7)} WHERE ${pendingClaimWhere(dialect, 8)}`, [
444
+ askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, settled_by=${ph(dialect, 4)}, updated_input=${ph(dialect, 5)}, decided_at_ms=${ph(dialect, 6)}, idempotency_key=${ph(dialect, 7)}, rev=rev+1, updated_at_ms=${ph(dialect, 8)} WHERE ${pendingClaimWhere(dialect, 9)}`, [
405
445
  decision.decision,
406
446
  decision.decisionActor == null ? null : this.json(decision.decisionActor),
407
447
  decision.decisionNote ?? null,
448
+ decision.settledBy ?? null,
408
449
  decision.updatedInput === undefined ? null : this.json(decision.updatedInput),
409
450
  now,
410
451
  decision.idempotencyKey ?? null,
@@ -461,7 +502,7 @@ export class SqlApprovalAskStore {
461
502
  }
462
503
  const askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [to, now, askId, batchId]);
463
504
  if (askRes.affected !== 1) {
464
- const sel = await conn.query(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
505
+ const sel = await conn.query(`SELECT ${askCols(dialect)} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
465
506
  await conn.rollback();
466
507
  return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
467
508
  }
@@ -590,32 +631,32 @@ export class SqlApprovalAskStore {
590
631
  }
591
632
  }
592
633
  async listByState(state, limit) {
593
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
634
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
594
635
  return rows.map(mapAskRow);
595
636
  }
596
637
  async listPendingByTask(taskId, signal) {
597
638
  if (signal?.aborted === true)
598
639
  throw abortedReadError("listPendingByTask");
599
- const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
640
+ const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
600
641
  return rows.map(mapAskRow);
601
642
  }
602
643
  async listPendingBySession(sessionId, owner, signal) {
603
644
  if (signal?.aborted === true)
604
645
  throw abortedReadError("listPendingBySession");
605
646
  const { rows } = await withReadSignal(this.db.query(owner === null
606
- ? this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
607
- : this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner = $2 ORDER BY created_at_ms ASC`), owner === null ? [sessionId] : [sessionId, owner]), signal, "listPendingBySession");
647
+ ? this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
648
+ : this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner = $2 ORDER BY created_at_ms ASC`), owner === null ? [sessionId] : [sessionId, owner]), signal, "listPendingBySession");
608
649
  return rows.map(mapAskRow);
609
650
  }
610
651
  async getAsk(askId) {
611
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [
652
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [
612
653
  askId,
613
654
  ]);
614
655
  return rows[0] ? mapAskRow(rows[0]) : null;
615
656
  }
616
657
  async getByIdempotencyKey(taskId, key) {
617
658
  assertIdempotencyKeyShape(key);
618
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
659
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
619
660
  return rows[0] ? mapAskRow(rows[0]) : null;
620
661
  }
621
662
  async deferReconcile(askId, expectedState, expectedRev, nowMs) {
@@ -186,6 +186,16 @@ export declare class SqlDeviceStore implements DeviceStore {
186
186
  appendAudit(input: AppendAuditInput): Promise<void>;
187
187
  /** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */
188
188
  private insertAuditOn;
189
+ /**
190
+ * 审计列表。
191
+ *
192
+ * 🔴 **一行读不出形 ⇒ 整页拒,刻意不做逐行隔离**(S-550 修复验证轮,主会话裁定后入档):方向由设计
193
+ * §2「失败方向 = 拒(fail-closed),全店同向」定。逐行隔离(坏行跳过 / 占位)在**审计**面上尤其危险 ——
194
+ * 它把「这一页少了一条」变得和「本来就没有那一条」同形,而审计面的全部意义就是「发生过的事都在」。
195
+ * 一页 5xx 是响亮的运维事件,一条静默消失不是。
196
+ *
197
+ * ⇒ 存量库里旧写腿留下的坏字节由**一次性迁移**清掉(`one-time-migrations.ts` M4),不靠读侧容忍。
198
+ */
189
199
  listAudit(filter?: ListAuditFilter): Promise<DeviceAuditRow[]>;
190
200
  }
191
201
  /** 方言绑定薄壳(A12 形:ctor 收池,行为全在基类)。 */
@@ -2,6 +2,7 @@ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
3
3
  import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
4
4
  import { ensureIndexes } from "./ensure-index.js";
5
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
5
6
  export const DEVICES_TABLE = "device";
6
7
  export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
7
8
  export const DEVICE_SESSIONS_TABLE = "device_session";
@@ -237,7 +238,7 @@ function sessionCols(dialect) {
237
238
  return ["root_session_id", "device_id", "owner_tenant", "owner_subject", ms(dialect, "bound_at", "bound_at_ms"), "rev"].join(", ");
238
239
  }
239
240
  function auditCols(dialect) {
240
- return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", "detail"].join(", ");
241
+ return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", jsonColumnText(dialect, "detail")].join(", ");
241
242
  }
242
243
  function bufToStr(v) {
243
244
  return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
@@ -295,9 +296,6 @@ function mapSessionRow(r) {
295
296
  rev: Number(r.rev),
296
297
  };
297
298
  }
298
- function parseJsonColumn(v) {
299
- return JSON.parse(v);
300
- }
301
299
  function mapAuditRow(r) {
302
300
  const tenant = strOrNull(r.owner_tenant);
303
301
  const subject = strOrNull(r.owner_subject);
@@ -308,9 +306,17 @@ function mapAuditRow(r) {
308
306
  event: parseDeviceAuditEvent(bufToStr(r.event)),
309
307
  deviceId: strOrNull(r.device_id),
310
308
  owner: tenant !== null && subject !== null ? { tenant, subject } : null,
311
- detail: rawDetail === null || rawDetail === undefined ? null : typeof rawDetail === "string" ? parseJsonColumn(rawDetail) : rawDetail,
309
+ detail: rawDetail === null || rawDetail === undefined ? null : readAuditDetail(rawDetail, String(r.id)),
312
310
  };
313
311
  }
312
+ function readAuditDetail(raw, id) {
313
+ try {
314
+ return decodeJsonColumn(raw, readJsonObjectColumn, `${DEVICE_AUDIT_TABLE}.detail for audit row ${id}`);
315
+ }
316
+ catch (e) {
317
+ throw foldJsonColumn(e, (m) => new Error(`device-store: ${m} — refusing rather than serve an audit row whose detail nobody can read`));
318
+ }
319
+ }
314
320
  function ph(dialect, n) {
315
321
  return dialect === "tidb" ? "?" : `$${n}`;
316
322
  }
@@ -637,7 +643,7 @@ export class SqlDeviceStore {
637
643
  assertDeviceIdShape(input.deviceId);
638
644
  if (input.owner)
639
645
  assertDeviceOwnerShape(input.owner);
640
- await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail === undefined ? null : this.json(input.detail)]);
646
+ await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail == null ? null : this.json(input.detail)]);
641
647
  }
642
648
  async listAudit(filter) {
643
649
  const dialect = this.db.dialect;
@@ -75,8 +75,10 @@ export interface ForegroundReapReading {
75
75
  * 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
76
76
  * 是 uncaught 出口。
77
77
  *
78
- * **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是收尾链的
79
- * `process.once("exit")` 钩 —— `exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
78
+ * **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是退出边界装配器
79
+ * (`boot/exit-boundary.ts`)那只 `process.once("exit")` 钩 —— S-560 起它由**两条车道共用**(HTTP 服务的
80
+ * 收尾链 + `run-local` 一次性 CLI;后者另装 SIGINT/SIGTERM 处理器,因为前台 CLI 的 Ctrl-C 若走默认终止,
81
+ * `exit` 钩不触发)。`exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
80
82
  * 时刻上(`hardShutdown` 终点那次早按只是优化,见 `boot/shutdown.ts` 的两处注)。上界 = 簿上条数 ×
81
83
  * 一次 `kill(-pid)` syscall(簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
82
84
  *
@@ -13,6 +13,9 @@ export interface OneTimeMigrationReport {
13
13
  readonly retiredSteerSeatStaleRows: number;
14
14
  /** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
15
15
  readonly askOriginRowsMigrated: number;
16
+ /** M4:`device_audit.detail` 里 JSON 文本 `null` 被改成列 NULL 的行数(表不在 ⇒ 恒 0)。
17
+ * 非零 = 这次迁移真改写了存量字节,必须让运维看见(与 M1 的 `retiredSnapshotRows` 同一条纪律)。 */
18
+ readonly auditDetailJsonNullRows: number;
16
19
  }
17
20
  /** 三条迁移都不适用的载体(local 车道没有 SQL 表)。 */
18
21
  export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated">;
@@ -1,16 +1,20 @@
1
1
  import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
2
  import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
+ import { jsonColumnTextExpr } from "./sql-json-column.js";
3
4
  export const NO_SQL_MIGRATIONS = {
4
5
  retiredSnapshotTable: "absent",
5
6
  retiredSnapshotRows: 0,
6
- retiredSteerSeatColumn: "absent",
7
7
  retiredSteerSeatStaleRows: 0,
8
+ retiredSteerSeatColumn: "absent",
9
+ auditDetailJsonNullRows: 0,
8
10
  };
9
11
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
10
12
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
11
13
  const DELIVERABLE_STEER_SEAT_WHERE = "(status = 'pending' OR (status = 'resolved' AND execution_outcome IS NULL))";
12
14
  const RETIRED_ASK_ORIGIN = { from: "unresolvable", to: "ancestor_marked" };
13
15
  const GATE_BEARING_LEDGER_EVENT_TYPE = "tool_end";
16
+ const AUDIT_TABLE = "device_audit";
17
+ const AUDIT_DETAIL_COLUMN = "detail";
14
18
  export class RetiredSteerSeatError extends Error {
15
19
  rows;
16
20
  code = "checkpoint.pending_steer_seat_retired";
@@ -130,6 +134,11 @@ async function migrateOn(db, dialect) {
130
134
  `WHERE type = '${GATE_BEARING_LEDGER_EVENT_TYPE}' AND data->'gate'->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`)) +
131
135
  (await db.exec(`UPDATE checkpoint SET execution_outcome = jsonb_set(execution_outcome, '{origin}', '"${RETIRED_ASK_ORIGIN.to}"') ` +
132
136
  `WHERE execution_outcome->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`));
137
+ const auditTablePresent = await tableExists(db, dialect, AUDIT_TABLE);
138
+ const auditDetailJsonNullRows = !auditTablePresent
139
+ ? 0
140
+ : await db.exec(`UPDATE ${AUDIT_TABLE} SET ${AUDIT_DETAIL_COLUMN} = NULL ` +
141
+ `WHERE ${AUDIT_DETAIL_COLUMN} IS NOT NULL AND ${jsonColumnTextExpr(dialect, AUDIT_DETAIL_COLUMN)} = 'null'`);
133
142
  let retiredSnapshotTable = "absent";
134
143
  let retiredSnapshotRows = 0;
135
144
  let retiredSteerSeatColumn = "absent";
@@ -160,7 +169,7 @@ async function migrateOn(db, dialect) {
160
169
  throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
161
170
  `report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
162
171
  }
163
- return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated };
172
+ return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows };
164
173
  }
165
174
  export function migrateRetiredAskOrigin(type, data) {
166
175
  if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
@@ -177,7 +186,7 @@ export function migrateRetiredAskOrigin(type, data) {
177
186
  return { ...d, gate: { ...g, origin: RETIRED_ASK_ORIGIN.to } };
178
187
  }
179
188
  export function logOneTimeMigrations(log, backend, report) {
180
- const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0;
189
+ const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0;
181
190
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
182
191
  backend,
183
192
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -185,8 +194,11 @@ export function logOneTimeMigrations(log, backend, report) {
185
194
  retiredSteerSeatColumn: report.retiredSteerSeatColumn,
186
195
  retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
187
196
  askOriginRowsMigrated: report.askOriginRowsMigrated,
197
+ auditDetailJsonNullRows: report.auditDetailJsonNullRows,
188
198
  note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
189
- "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place). This " +
199
+ "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place; audit " +
200
+ "details written as the JSON text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly " +
201
+ "one on-disk spelling). This " +
190
202
  "whole migration step is removed in the next minor — start this version at least once before upgrading past it.",
191
203
  });
192
204
  }
@@ -157,6 +157,7 @@ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordSto
157
157
  private q;
158
158
  private enc;
159
159
  get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
160
+ private readApprovalRow;
160
161
  create(record: RuleApprovalRecord): Promise<void>;
161
162
  /**
162
163
  * 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
@@ -6,6 +6,7 @@ import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
7
  import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
8
8
  import { ensureIndexes } from "./ensure-index.js";
9
+ import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
9
10
  export function writerOfSqlRuleStore(store) {
10
11
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
11
12
  if (w === null || typeof w !== "object")
@@ -211,7 +212,7 @@ export async function ensureTiDBPermissionRuleSchema(pool) {
211
212
  }
212
213
  export async function assertPermissionRuleApprovalSchema(query, dialect) {
213
214
  try {
214
- await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
215
+ await query(`SELECT command, ${jsonColumnText(dialect, "edited_json")}, version, ${jsonColumnText(dialect, "offers_json")}, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
215
216
  }
216
217
  catch (err) {
217
218
  if (!isMissingColumnError(err, dialect))
@@ -231,7 +232,7 @@ export async function assertPermissionRuleApprovalSchema(query, dialect) {
231
232
  }
232
233
  export async function assertPermissionRuleTicketSchema(query, dialect) {
233
234
  try {
234
- await query(`SELECT redeemed_outcome, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
235
+ await query(`SELECT ${jsonColumnText(dialect, "redeemed_outcome")}, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
235
236
  }
236
237
  catch (err) {
237
238
  if (!isMissingColumnError(err, dialect))
@@ -332,21 +333,21 @@ export function buildRuleBucketChecksum(body) {
332
333
  }
333
334
  const NEXT_DOT_MAX_ATTEMPTS = 8;
334
335
  const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
335
- function parseColumn(schema, raw, column, ownerKey) {
336
- const text = typeof raw === "string" ? raw : raw === null || raw === undefined ? undefined : String(raw);
337
- if (text === undefined)
338
- throw new Error(`permission_rule.${column} is absent for bucket ${ownerKey}; refusing to guess its content`);
339
- let json;
336
+ const readRulesJson = createZodColumnReader(z.array(PersistedRuleRowSchema));
337
+ const readTombstonesJson = createZodColumnReader(z.array(RuleTombstoneSchema));
338
+ const readQuarantinedJson = createZodColumnReader(z.array(QuarantinedRuleAddRowSchema));
339
+ const readObservedVectorJson = createZodColumnReader(FrontierSchema);
340
+ const readCandidatesJson = createZodColumnReader(z.array(RuleCandidateSchema));
341
+ const readOffersJson = createZodColumnReader(z.array(RuleOffer2Schema));
342
+ const readRedeemedDotsJson = createZodColumnReader(RedeemedDotsSchema);
343
+ const readEditedJson = createZodColumnReader(RuleEditedSchema);
344
+ async function refusingUnreadableColumns(what, fn) {
340
345
  try {
341
- json = JSON.parse(text);
346
+ return await fn();
342
347
  }
343
- catch (err) {
344
- throw new Error(`permission_rule.${column} for bucket ${ownerKey} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
348
+ catch (e) {
349
+ throw foldJsonColumn(e, (m) => new Error(`${m} — the ${what} does not carry a readable shape; refusing the whole of it rather than report a partial one`));
345
350
  }
346
- const parsed = schema.safeParse(json);
347
- if (!parsed.success)
348
- throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
349
- return parsed.data;
350
351
  }
351
352
  class SqlDurableRulePartition {
352
353
  db;
@@ -368,14 +369,18 @@ class SqlDurableRulePartition {
368
369
  return this.db.dialect === "tidb" ? tidbSql : pgSql;
369
370
  }
370
371
  async read() {
371
- const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
372
+ return refusingUnreadableColumns(`bucket ${this.ownerKey}`, () => this.readBucketRow());
373
+ }
374
+ async readBucketRow() {
375
+ const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, ${jsonColumnText("tidb", "rules_json")}, ${jsonColumnText("tidb", "tombstones_json")}, ${jsonColumnText("tidb", "quarantined_json")}, ${jsonColumnText("tidb", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, ${jsonColumnText("pg", "rules_json")}, ${jsonColumnText("pg", "tombstones_json")}, ${jsonColumnText("pg", "quarantined_json")}, ${jsonColumnText("pg", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
372
376
  const row = rows[0];
373
377
  if (row === undefined)
374
378
  return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
375
- const rulesRaw = parseColumn(z.array(PersistedRuleRowSchema), row.rules_json, "rules_json", this.ownerKey);
376
- const tombstonesRaw = parseColumn(z.array(RuleTombstoneSchema), row.tombstones_json, "tombstones_json", this.ownerKey);
377
- const quarantinedRaw = parseColumn(z.array(QuarantinedRuleAddRowSchema), row.quarantined_json, "quarantined_json", this.ownerKey);
378
- const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : parseColumn(FrontierSchema, row.observed_vector_json, "observed_vector_json", this.ownerKey);
379
+ const at = `${PERMISSION_RULE_TABLE} bucket ${this.ownerKey}`;
380
+ const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
381
+ const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
382
+ const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
383
+ const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
379
384
  const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
380
385
  if (String(row.checksum ?? "") !== expected) {
381
386
  throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
@@ -548,7 +553,10 @@ export class SqlRuleApprovalRecordStore {
548
553
  return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
549
554
  }
550
555
  async get(id) {
551
- 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]);
556
+ return refusingUnreadableColumns(`approval record ${id}`, () => this.readApprovalRow(id));
557
+ }
558
+ async readApprovalRow(id) {
559
+ const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("tidb", "candidates_json")}, ${jsonColumnText("tidb", "offers_json")}, selected_offer, ${jsonColumnText("tidb", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("tidb", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("pg", "candidates_json")}, ${jsonColumnText("pg", "offers_json")}, selected_offer, ${jsonColumnText("pg", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("pg", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
552
560
  const row = rows[0];
553
561
  if (row === undefined)
554
562
  return undefined;
@@ -558,10 +566,11 @@ export class SqlRuleApprovalRecordStore {
558
566
  if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
559
567
  return { staleSchema: true, id: String(row.record_id), ...identity };
560
568
  }
561
- const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
562
- const offers = parseColumn(z.array(RuleOffer2Schema), row.offers_json, "offers_json", id);
563
- const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
564
- const edited = row.edited_json === null || row.edited_json === undefined ? undefined : parseColumn(RuleEditedSchema, row.edited_json, "edited_json", id);
569
+ const at = `${PERMISSION_RULE_APPROVAL_TABLE} row ${id}`;
570
+ const candidates = decodeJsonColumn(row.candidates_json, readCandidatesJson, `${at}.candidates_json`);
571
+ const offers = decodeJsonColumn(row.offers_json, readOffersJson, `${at}.offers_json`);
572
+ const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : decodeJsonColumn(row.redeemed_dots_json, readRedeemedDotsJson, `${at}.redeemed_dots_json`);
573
+ const edited = row.edited_json === null || row.edited_json === undefined ? undefined : decodeJsonColumn(row.edited_json, readEditedJson, `${at}.edited_json`);
565
574
  const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
566
575
  const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
567
576
  if (!kind.success || !state.success) {
@@ -716,10 +725,10 @@ export class SqlRuleImportTicketStore {
716
725
  if (row.redeemedOutcome !== undefined) {
717
726
  let outcome;
718
727
  try {
719
- outcome = JSON.parse(row.redeemedOutcome);
728
+ outcome = decodeJsonColumn(row.redeemedOutcome, readJsonObjectColumn, `${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId}`);
720
729
  }
721
- catch (err) {
722
- throw new Error(`${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
730
+ catch (e) {
731
+ throw foldJsonColumn(e, (m) => new Error(`${m} — refusing rather than read a settled ticket as still open (which re-runs a redemption that already ran)`));
723
732
  }
724
733
  return { state: "settled", outcome };
725
734
  }
@@ -738,7 +747,7 @@ export class SqlRuleImportTicketStore {
738
747
  return affected === 1 ? { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId } : { ok: false };
739
748
  }
740
749
  async readRow(ticketId) {
741
- const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
750
+ const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("tidb", "redeemed_outcome")}, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("pg", "redeemed_outcome")}, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
742
751
  const row = rows[0];
743
752
  if (row === undefined)
744
753
  return undefined;
@@ -1,6 +1,7 @@
1
1
  import { type SessionPolicyStore, type SessionPermissionRules, type StoredSessionRules, type SessionRulesRecord, type PutRulesOptions } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
+ import { type JsonColumnShape } from "./sql-json-column.js";
4
5
  import { type SqlDriver } from "./sql-driver.js";
5
6
  /** `session_policy.policy_key` 的**唯一铸造点**(core 复合键的 sha256)。
6
7
  *
@@ -8,6 +9,29 @@ import { type SqlDriver } from "./sql-driver.js";
8
9
  * 逐行重算这把键(身份在 hash 的**原像**里,等值 UPDATE 够不着)。两处各写一遍 sha256 表达式的下场是
9
10
  * 某天有人在这里改了 key 公式而收编腿还在按旧式算 —— 迁完的行从此谁也读不到,且**静默**。 */
10
11
  export declare const policyKeyFor: (sessionId: string, principal: string | undefined) => string;
12
+ /**
13
+ * 一行的 **`rules` 体**(已按文本取出的那一串)→ 收窄好的 `SessionPermissionRules`(C1)。读不出形 ⇒
14
+ * `SessionPolicyError("corrupt")`,与 rev / gen 两道拒同口径、同一个码。
15
+ *
16
+ * 形判据复用**现成的那一只**读者 `readSessionPermissionRules`(五桶 `string[]` 的单一属主;未知键照旧忽略,
17
+ * 与 core 的 `normalizeRules` 同口径),不在这里铸第二份键集。
18
+ *
19
+ * 🔴 **恰好解一次码,而这是由读腿的投影保证的**(codex 对抗复审两轮,两条读法各自实测被推翻后的终形):
20
+ * · 「体是什么就直接判形」——**错**:MariaDB(本仓 README 声明支持的 MySQL 协议后端之一)的 `JSON` 是
21
+ * `LONGTEXT` 的**别名**,驱动只对真 JSON 传输类型自动解码 ⇒ 那台机器上每一条**合法**规则都以文本到达,
22
+ * 会被判成 `corrupt`(三个读点全挂,一台好机器变砖);
23
+ * · 「体是串就再解一次」——**也错**:`JSON`/`JSONB` 列上驱动已经解过一次,于是一行体为 `"{}"`(一个把
24
+ * `JSON.stringify` 多调了一次的迁移脚本的典型产物)会被解成 `{}` 并当作**空规则**端出去 —— 与本道拒
25
+ * 要关的 `null` 洞逐字同形,只是换了个入口。
26
+ * 两条读法错在**同一个地方**:从值本身分不出「驱动解过了」还是「没解过」。⇒ 让这个歧义在**构造上**不存在:
27
+ * 三个读点都把这一列**按文本**投影(`CAST(rules AS CHAR)` / `::text`),于是到这里恒是一只串、恰好解一次。
28
+ * JSON 列里的字符串标量按文本取出来**带着引号**(真库实测两方言同形:`"\"{}\""`)⇒ 解一次得到一只串 ⇒ 判坏;
29
+ * 文本承载的列取出来就是它自己 ⇒ 解一次得到对象 ⇒ 好体。**一条规则,两种承载,零方言分叉,不依赖驱动行为。**
30
+ *
31
+ * 🔴 S-550:上面这一整段现在是**整仓**的口径,实现搬进唯一属主 `sql-json-column.ts`(投影 `jsonColumnText`
32
+ * + 恰好解一次 `decodeJsonColumn` + 折码 `foldJsonColumn`);本店只留「形判据是哪一只」与「坏体折成哪个码」。
33
+ */
34
+ export declare function readSessionRulesBody(body: unknown): JsonColumnShape<SessionPermissionRules>;
11
35
  /** Dual-dialect durable SessionPolicyStore. See the file header for the dialect-delta ledger. */
12
36
  export declare class SqlSessionPolicyStore implements SessionPolicyStore {
13
37
  private readonly db;