@sema-agent/server 7.91.2 → 7.92.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 (71) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +8 -0
  3. package/dist/approval-ask-machine.d.ts +1 -1
  4. package/dist/approval-reconciler.d.ts +1 -1
  5. package/dist/approval.d.ts +1 -1
  6. package/dist/bench/s1/repair-oracle-adapter.d.ts +1 -1
  7. package/dist/bench/s1/runner-ctx.d.ts +1 -1
  8. package/dist/boot/runner-deps.d.ts +9 -9
  9. package/dist/capabilities/memory-notice.d.ts +27 -10
  10. package/dist/capabilities/memory-notice.js +21 -2
  11. package/dist/config-catalog.js +2 -1
  12. package/dist/config-center/types.d.ts +1 -1
  13. package/dist/config.d.ts +1 -1
  14. package/dist/env-facts.d.ts +1 -1
  15. package/dist/execution-lane-caps.d.ts +1 -1
  16. package/dist/file-history-disclosure.d.ts +2 -2
  17. package/dist/fleet/fleet-bus.d.ts +7 -1
  18. package/dist/fleet/fleet-bus.js +5 -3
  19. package/dist/http/admission.d.ts +1 -1
  20. package/dist/http/idempotency.d.ts +27 -0
  21. package/dist/http/route-ctx.d.ts +2 -5
  22. package/dist/http/routes/memory-compliance.d.ts +1 -1
  23. package/dist/http/routes/memory-origin.d.ts +2 -2
  24. package/dist/http/routes/rules.js +3 -1
  25. package/dist/http/routes/runs.js +3 -6
  26. package/dist/http/routes/session-sync.js +3 -2
  27. package/dist/http/routes/sessions.js +3 -2
  28. package/dist/http/routes/tasks.js +35 -19
  29. package/dist/http/wire-types.d.ts +29 -2
  30. package/dist/leader/wire.d.ts +1 -1
  31. package/dist/lsp/manager.d.ts +2 -2
  32. package/dist/memory-operator-faces.d.ts +13 -13
  33. package/dist/memory-scope.d.ts +2 -2
  34. package/dist/observability/fail-open.d.ts +1 -1
  35. package/dist/observability/fail-open.js +1 -1
  36. package/dist/observability/metrics.js +2 -1
  37. package/dist/orchestration/workflow-agent-session-index.d.ts +2 -2
  38. package/dist/peer-directory.d.ts +3 -2
  39. package/dist/permission-rule-vocab.d.ts +16 -1
  40. package/dist/plugins/file-run-store.d.ts +1 -1
  41. package/dist/plugins/local-session-store.d.ts +1 -1
  42. package/dist/plugins/memory-session-policy-store.d.ts +22 -3
  43. package/dist/plugins/memory-session-policy-store.js +11 -4
  44. package/dist/plugins/permission-rule-store-sql.js +16 -7
  45. package/dist/plugins/pg-pool.js +11 -0
  46. package/dist/plugins/session-policy-store-sql.d.ts +51 -3
  47. package/dist/plugins/session-policy-store-sql.js +77 -38
  48. package/dist/plugins/sql-errors.d.ts +25 -0
  49. package/dist/plugins/sql-errors.js +10 -0
  50. package/dist/plugins/store-backend.d.ts +5 -5
  51. package/dist/plugins/store-contracts.d.ts +2 -2
  52. package/dist/plugins/tidb-pool.d.ts +1 -1
  53. package/dist/plugins/tidb-pool.js +19 -0
  54. package/dist/rules-consent.d.ts +16 -9
  55. package/dist/rules-consent.js +14 -35
  56. package/dist/run-cancel-context.d.ts +1 -1
  57. package/dist/run-local.d.ts +1 -1
  58. package/dist/runtime-caps-resolver.d.ts +10 -10
  59. package/dist/runtime-governance.d.ts +2 -2
  60. package/dist/session-policy-wire.d.ts +25 -1
  61. package/dist/session-policy-wire.js +11 -0
  62. package/dist/spec-fields.d.ts +4 -4
  63. package/dist/task-cwd.d.ts +1 -1
  64. package/dist/task-settings.d.ts +2 -2
  65. package/dist/tool-approval.d.ts +1 -1
  66. package/dist/trace/core-keyset-guard.d.ts +1 -1
  67. package/dist/trace/project.d.ts +25 -1
  68. package/dist/trace/project.js +4 -0
  69. package/dist/trace/redact.d.ts +1 -1
  70. package/dist/trace/redact.js +8 -3
  71. package/package.json +3 -3
@@ -25,9 +25,57 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
25
25
  listBySession(sessionId: string): Promise<SessionRulesRecord[]>;
26
26
  /** Operator/owner write: CAS + tighten-only (unless operator) + rev bump, atomic in one txn; first-write race retried. */
27
27
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
28
- /** E21 purge — drop ALL policy rows for a session (every principal). Scoped by session_id (the route owner-gates).
29
- * Return shape aligned to core 1.423's optional interface seam (`Promise<void>`) — the affectedRows count was
30
- * incidental and unconsumed; the integration test asserts the post-state (listBySession empty) instead. */
28
+ /**
29
+ * The store's ONE retry arm: re-run the whole read-check-write transaction while the failure is an engine
30
+ * ARBITRATION artifact rather than an answer. Two such artifacts, and only two:
31
+ * · **dup-key** — a concurrent FIRST write won the PK (no row ⇒ `FOR UPDATE` locked nothing). The re-read takes
32
+ * the UPDATE/CAS path, which is core's serialized outcome; converting it to `conflict` in place would be a
33
+ * spurious answer the caller can't tell from a real CAS loss.
34
+ * · **deadlock** — the engine picked THIS transaction as the victim and already rolled it back. Which side gets
35
+ * picked is arbitrary, so propagating it hands the caller an internal coin flip. 🔴 This arm is NOT a licence
36
+ * for sloppy lock order: the order is still policy row → mark row on BOTH methods, and the oracle for it is
37
+ * `test/sql-lock-shape-innodb-integration.test.ts` (site ⑩, three engines). It exists because on REAL InnoDB
38
+ * (REPEATABLE READ) a `FOR UPDATE` read of a MISSING primary key takes a GAP lock, and gap locks are mutually
39
+ * compatible while both block the other side's INSERT — so N concurrent first writes on one key deadlock NO
40
+ * MATTER what order any table is touched in. Measured on the real engine (2026-09-20, S-529, 8-way race on
41
+ * `main`'s statement sequence, zero mark table involved): InnoDB 1 winner / 7 `ER_LOCK_DEADLOCK`, TiDB 0,
42
+ * PG 0 — i.e. this is a PRE-EXISTING MySQL-leg defect that S-529's lock-shape site made visible, not a new one.
43
+ * After the first insert commits, the retried writers find the row and take the UPDATE path (record lock,
44
+ * no gap) ⇒ the loop converges in one extra round per missing-row window; the bound is the suite's concurrency.
45
+ * Lock-WAIT-TIMEOUT is deliberately NOT retried ({@link isDeadlockError}'s own note: it is a latency signal,
46
+ * and retrying it multiplies an already-long wait).
47
+ */
48
+ private retrying;
49
+ /** E21 purge — drop ALL policy rows for a session (every principal), KEEPING each key's rev high-water mark.
50
+ * Scoped by session_id (the route owner-gates). Return shape aligned to core 1.423's optional interface seam
51
+ * (`Promise<void>`) — the affectedRows count was incidental and unconsumed; the integration test asserts the
52
+ * post-state (listBySession empty) instead.
53
+ *
54
+ * S-529 (`@contract session_policy.rev_survives_delete`): the rules ROW dies, the key's REV LINE does not, and the
55
+ * erase ALSO ENDS THE ROW'S LINEAGE (`gen` + 1 — the next write on this key belongs to another generation, which no
56
+ * reader treats as a continuation of the erased row whatever rev it wears) — one
57
+ * transaction, three statements, and BOTH the order and the locking verb are load-bearing:
58
+ * ① `SELECT … ORDER BY policy_key FOR UPDATE` — the rows to erase are read and X-LOCKED first, in a DETERMINISTIC
59
+ * order. This is the SAME lock order putRules uses (policy row → mark row), which is the whole point: the
60
+ * obvious shape (`INSERT … SELECT` the marks, then DELETE) locks the MARK first and takes only a SHARED lock
61
+ * on the policy rows it reads, i.e. a reversed order PLUS an S→X upgrade — the textbook deadlock this repo
62
+ * 已经吃过两次(retention-store-sql.ts ④a/④b 的 S-131 案:一边持 boundary 等哨兵,一边持哨兵等 boundary)。
63
+ * ② the marks (their revs come from ①'s already-locked rows) — `GREATEST` merge, so a mark only ever RISES and
64
+ * repeated erases never walk the line back.
65
+ * ③ the DELETE — scoped to ①'s POLICY_KEY SET, never re-scanned by `session_id`. 🔴 This is the whole of
66
+ * codex r1 [high] #1 (verified, not rebutted): both engines re-evaluate a `WHERE session_id = ?` DELETE
67
+ * against a CURRENT read (PG is pinned to READ COMMITTED, TiDB's DML is a current read), so a row for a
68
+ * DIFFERENT principal that a concurrent putRules commits BETWEEN ① and ③ would be deleted by a session-wide
69
+ * DELETE while ② never marked it — a silently reset rev line, exactly the defect this batch exists to close.
70
+ * Scoped to the key set, the invariant is exact and checkable: **every row this seam deletes leaves a mark**.
71
+ * A row born after ① is simply not part of this erase (same as one born a microsecond after ③ — the caller
72
+ * that needs "and nothing came back" re-issues; the seam is idempotent).
73
+ * No rows ⇒ return early: an erase of a session that never had rules writes NO mark (no line, nothing to keep),
74
+ * which is what makes a repeated/empty delete idempotent instead of resurrecting a mark.
75
+ * Why the mark is a separate table and not a tombstone row: see the DDL comment in tidb-pool.ts — a tombstone
76
+ * would keep `session_id` + the DENORMALIZED plaintext `principal` in the table after an E21 purge, i.e. the
77
+ * right-to-delete would degrade to buy rev ordering. The mark keeps only the one-way hash, the session id and
78
+ * an integer. */
31
79
  deleteBySession(sessionId: string): Promise<void>;
32
80
  /**
33
81
  * The store's one read-check-write transaction. Its load-bearing read is line ~106's
@@ -2,10 +2,22 @@ import { createHash } from "node:crypto";
2
2
  import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
3
3
  import { pgHasUnstorable } from "./pg-safe-json.js";
4
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
- import { isDupKeyError } from "./sql-errors.js";
5
+ import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
6
6
  export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
7
7
  const policyKey = policyKeyFor;
8
+ const RETRY_LIMIT = 8;
8
9
  const parseRules = (v) => (typeof v === "string" ? JSON.parse(v) : v);
10
+ function rowToStored(r, where) {
11
+ const rev = Number(r.rev);
12
+ if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
13
+ throw new SessionPolicyError("corrupt", `session rules row ${where} carries rev ${String(r.rev)} — a stored row's rev is always ≥ 1 (the stamp is floor+1), and rev 0 is the live reader's synthetic empty seed; refusing rather than serve it as rules`);
14
+ }
15
+ const gen = Number(r.gen ?? 0);
16
+ if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
17
+ throw new SessionPolicyError("corrupt", `session rules row ${where} carries lineage gen ${String(r.gen)} — a generation is a whole number ≥ 0; refusing rather than let a reader judge lineage by a value nothing minted`);
18
+ }
19
+ return { ...parseRules(r.rules), rev, gen };
20
+ }
9
21
  export class SqlSessionPolicyStore {
10
22
  db;
11
23
  constructor(db) {
@@ -18,62 +30,89 @@ export class SqlSessionPolicyStore {
18
30
  return isDupKeyError(this.db.dialect, e);
19
31
  }
20
32
  async getRules(sessionId, principal) {
21
- const { rows } = await this.db.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ?", "SELECT rules, rev FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
33
+ const { rows } = await this.db.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ?", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
22
34
  if (!rows[0])
23
35
  return null;
24
- return { ...parseRules(rows[0].rules), rev: Number(rows[0].rev) };
36
+ return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
25
37
  }
26
38
  async listBySession(sessionId) {
27
- const { rows } = await this.db.query(this.q("SELECT principal, rules, rev FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev FROM session_policy WHERE session_id = $1"), [sessionId]);
39
+ const { rows } = await this.db.query(this.q("SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = $1"), [sessionId]);
28
40
  return rows.map((r) => ({
29
41
  principal: r.principal == null ? undefined : String(r.principal),
30
- rules: { ...parseRules(r.rules), rev: Number(r.rev) },
42
+ rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
31
43
  }));
32
44
  }
33
45
  async putRules(sessionId, principal, rules, opts) {
34
46
  const key = policyKey(sessionId, principal);
35
- for (let attempt = 0;; attempt++) {
47
+ return this.retrying(() => this.tx(async (conn) => {
48
+ const { rows } = await conn.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
49
+ const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
50
+ const priorRev = prior?.rev ?? 0;
51
+ if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
52
+ throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
53
+ }
54
+ const { rows: markRows } = await conn.query(this.q("SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = ? FOR UPDATE", "SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = $1 FOR UPDATE"), [key]);
55
+ const markRev = markRows[0] ? Number(markRows[0].rev) : 0;
56
+ const markGen = markRows[0] ? Number(markRows[0].gen) : 0;
57
+ if (markRows[0] !== undefined && (!Number.isInteger(markGen) || markGen < 0 || !Number.isFinite(markRev) || markRev < 0)) {
58
+ throw new SessionPolicyError("corrupt", `the session rules rev mark for session ${JSON.stringify(sessionId)} is unreadable (rev ${String(markRows[0].rev)}, gen ${String(markRows[0].gen)}) — refusing rather than stamp a rev/lineage nothing proves`);
59
+ }
60
+ const clean = normalizeRules(stripRev(rules));
61
+ if (prior && opts?.operator !== true) {
62
+ const loosens = loosenReasons(prior, clean);
63
+ if (loosens.length > 0) {
64
+ throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
65
+ }
66
+ }
67
+ if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
68
+ throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
69
+ }
70
+ const nextRev = Math.max(priorRev, markRev) + 1;
71
+ const nextGen = prior?.gen ?? markGen;
72
+ const json = JSON.stringify(clean);
73
+ const now = new Date();
74
+ if (prior) {
75
+ const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, gen = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, gen = $4, updated_at = $5 WHERE policy_key = $6 AND rev = $7"), [principal ?? null, json, nextRev, nextGen, now, key, priorRev]);
76
+ if (upd.affected !== 1)
77
+ throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
78
+ }
79
+ else {
80
+ await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8)"), [key, sessionId, principal ?? null, json, nextRev, nextGen, now, now]);
81
+ }
82
+ return structuredClone({ ...clean, rev: nextRev, gen: nextGen });
83
+ }));
84
+ }
85
+ async retrying(attempt) {
86
+ for (let tries = 0;; tries++) {
36
87
  try {
37
- return await this.tx(async (conn) => {
38
- const { rows } = await conn.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
39
- const prior = rows[0] ? { rules: parseRules(rows[0].rules), rev: Number(rows[0].rev) } : undefined;
40
- const priorRev = prior?.rev ?? 0;
41
- if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
42
- throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
43
- }
44
- const clean = normalizeRules(stripRev(rules));
45
- if (prior && opts?.operator !== true) {
46
- const loosens = loosenReasons(prior.rules, clean);
47
- if (loosens.length > 0) {
48
- throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
49
- }
50
- }
51
- if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
52
- throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
53
- }
54
- const nextRev = priorRev + 1;
55
- const json = JSON.stringify(clean);
56
- const now = new Date();
57
- if (prior) {
58
- const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, updated_at = $4 WHERE policy_key = $5 AND rev = $6"), [principal ?? null, json, nextRev, now, key, priorRev]);
59
- if (upd.affected !== 1)
60
- throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
61
- }
62
- else {
63
- await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7)"), [key, sessionId, principal ?? null, json, nextRev, now, now]);
64
- }
65
- return structuredClone({ ...clean, rev: nextRev });
66
- });
88
+ return await attempt();
67
89
  }
68
90
  catch (e) {
69
- if (this.isDupKey(e) && attempt < 4)
91
+ if (tries < RETRY_LIMIT && (this.isDupKey(e) || isDeadlockError(this.db.dialect, e)))
70
92
  continue;
71
93
  throw e;
72
94
  }
73
95
  }
74
96
  }
75
97
  async deleteBySession(sessionId) {
76
- await this.db.query(this.q("DELETE FROM session_policy WHERE session_id = ?", "DELETE FROM session_policy WHERE session_id = $1"), [sessionId]);
98
+ await this.retrying(() => this.tx(async (conn) => {
99
+ const { rows } = await conn.query(this.q("SELECT policy_key, rev, gen FROM session_policy WHERE session_id = ? ORDER BY policy_key FOR UPDATE", "SELECT policy_key, rev, gen FROM session_policy WHERE session_id = $1 ORDER BY policy_key FOR UPDATE"), [sessionId]);
100
+ if (rows.length === 0)
101
+ return;
102
+ const now = new Date();
103
+ const keys = rows.map((r) => String(r.policy_key));
104
+ const params = [];
105
+ const tuples = rows.map((r) => {
106
+ params.push(String(r.policy_key), sessionId, Number(r.rev), Number(r.gen ?? 0) + 1, now);
107
+ const base = params.length - 5;
108
+ return this.db.dialect === "tidb" ? "(?,?,?,?,?)" : `($${base + 1},$${base + 2},$${base + 3},$${base + 4},$${base + 5})`;
109
+ });
110
+ await conn.query(this.q(`INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
111
+ " ON DUPLICATE KEY UPDATE rev = GREATEST(session_policy_rev_mark.rev, VALUES(rev)), gen = GREATEST(session_policy_rev_mark.gen + 1, VALUES(gen)), erased_at = VALUES(erased_at)", `INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
112
+ " ON CONFLICT (policy_key) DO UPDATE SET rev = GREATEST(session_policy_rev_mark.rev, EXCLUDED.rev), gen = GREATEST(session_policy_rev_mark.gen + 1, EXCLUDED.gen), erased_at = EXCLUDED.erased_at"), params);
113
+ const holes = keys.map((_, i) => (this.db.dialect === "tidb" ? "?" : `$${i + 1}`)).join(",");
114
+ await conn.query(`DELETE FROM session_policy WHERE policy_key IN (${holes})`, keys);
115
+ }));
77
116
  }
78
117
  async tx(fn) {
79
118
  const conn = await this.db.connect();
@@ -68,4 +68,29 @@ export declare const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATE
68
68
  * 它守的是一句**指向手工 DDL 的**错误指路,认不出的一律不是 CHECK 拒(原始错误原样上抛)。
69
69
  */
70
70
  export declare function isCheckViolationError(err: unknown, dialect: SqlDialect): boolean;
71
+ /** MySQL/TiDB `ER_LOCK_DEADLOCK`(引擎挑了本事务当牺牲者)的数字 errno。 */
72
+ export declare const MYSQL_ER_LOCK_DEADLOCK_ERRNO = 1213;
73
+ /** MySQL/TiDB `ER_LOCK_DEADLOCK` 的字符串错误码。 */
74
+ export declare const MYSQL_ER_LOCK_DEADLOCK_CODE = "ER_LOCK_DEADLOCK";
75
+ /** PostgreSQL `deadlock_detected` 的 SQLSTATE。 */
76
+ export declare const PG_DEADLOCK_DETECTED_SQLSTATE = "40P01";
77
+ /**
78
+ * 「这个错误**是**『引擎判了死锁、本事务被挑成牺牲者』吗」——唯一的**可重试**锁故障判据。
79
+ *
80
+ * 🔴 **只认死锁,不认锁等待超时**(`ER_LOCK_WAIT_TIMEOUT` / 语句超时**刻意不在**识别集里):死锁是
81
+ * 引擎的**仲裁产物** —— 被挑中的一方已经整事务回滚、锁全放,立刻重来通常就过去了,而**谁**当牺牲者
82
+ * 是引擎的任意选择,把它抛给调用方等于把一次内部仲裁当成用户的错。锁等待超时是**另一件事**:它说的是
83
+ * 「有人把锁握了太久」,那是一个延迟信号,重试只会把一次已经很长的等待乘上重试次数(InnoDB 缺省 50s),
84
+ * 所以它必须响亮上抛。
85
+ *
86
+ * ⚠️ 重试的**前提**在调用方,不在这里:只有整事务重来后语义等价(读-查-写全部重做)的腿才可以用它;
87
+ * 它**不是**「锁序可以随便写」的许可证 —— 锁序的属主仍是店(真值神谕 =
88
+ * `test/sql-lock-shape-innodb-integration.test.ts` 三引擎并发套)。
89
+ *
90
+ * 引擎事实(2026-09-20 真 InnoDB 实测,S-529 取证):同一把缺行主键上 N=8 路并发「`FOR UPDATE` 缺行读
91
+ * → `INSERT`」,InnoDB(REPEATABLE READ,gap lock + insert-intention)恒定 1 成 7 死锁;TiDB(悲观、无
92
+ * gap lock)与 PG(READ COMMITTED,缺行 `FOR UPDATE` 不取锁)各自零死锁。⇒ 这条谓词实际的受众是
93
+ * **MySQL 协议腿**,两方言都认只是为了让店不必自己判引擎。
94
+ */
95
+ export declare function isDeadlockError(dialect: SqlDialect, err: unknown): boolean;
71
96
  //# sourceMappingURL=sql-errors.d.ts.map
@@ -35,4 +35,14 @@ export function isCheckViolationError(err, dialect) {
35
35
  return keyOf(err, "code") === PG_CHECK_VIOLATION_SQLSTATE;
36
36
  return keyOf(err, "errno") === MYSQL_ER_CHECK_VIOLATED_ERRNO || keyOf(err, "code") === MYSQL_ER_CHECK_VIOLATED_CODE;
37
37
  }
38
+ export const MYSQL_ER_LOCK_DEADLOCK_ERRNO = 1213;
39
+ export const MYSQL_ER_LOCK_DEADLOCK_CODE = "ER_LOCK_DEADLOCK";
40
+ export const PG_DEADLOCK_DETECTED_SQLSTATE = "40P01";
41
+ export function isDeadlockError(dialect, err) {
42
+ if (err === null || typeof err !== "object")
43
+ return false;
44
+ if (dialect === "pg")
45
+ return keyOf(err, "code") === PG_DEADLOCK_DETECTED_SQLSTATE;
46
+ return keyOf(err, "errno") === MYSQL_ER_LOCK_DEADLOCK_ERRNO || keyOf(err, "code") === MYSQL_ER_LOCK_DEADLOCK_CODE;
47
+ }
38
48
  //# sourceMappingURL=sql-errors.js.map
@@ -125,7 +125,7 @@ export type RateLimiterStore = TiDBRateLimiter | PgRateLimiter;
125
125
  * 多一个**不指名平面**的缺省臂:`src()` = 「本部署记忆引擎自己那一只平面」。
126
126
  *
127
127
  * 🔴 为什么是工厂而不是一只实例(S-215 codex r1 [high] 验真后改形):core 的记忆平面**不止一个**
128
- * (`prepare-memory.js:162` 的 personal 平面自带 `derivePersonalControlDir(engineRoot)`,与 project 平面的
128
+ * (`prepare-memory.js` 的 personal 平面自带 `derivePersonalControlDir(engineRoot)`,与 project 平面的
129
129
  * controlDir 不是同一只)。返回单只实例 ⇒ server 把所有平面折到一只目录上,于是升级前落在 personal
130
130
  * 控制面的旧 opt-out 记录在升级后读作**缺席**=采集恢复(隐私面的静默 fail-open)。按平面取用后,
131
131
  * file 载体与 core 的缺省**逐字节同址**,唯一的变化是这个席位现在**被声明了**(core 的能力信号要的就是它)。
@@ -224,8 +224,8 @@ export interface StoreBackend {
224
224
  *
225
225
  * 🔴 修前 local 恒 `undefined` 且注称「缺席是一个真答案」—— 那句话在**事实层面**成立(单机的正确载体
226
226
  * 确实就是文件三腿)、在**接线层面**是假的:core 的 `captureCarrierUnsupported`
227
- * (core 7.13.0 `prepare-task.js:497`)判的是「这个席位在不在」而不是「文件形能不能用」,叠上
228
- * `isRemoteExecutionEnv` 的接口形鸭子判(`remote-env.js:21`)把本仓 host lane 一并算作 remote,
227
+ * (core 7.13.0 `prepare-task.js`)判的是「这个席位在不在」而不是「文件形能不能用」,叠上
228
+ * `isRemoteExecutionEnv` 的接口形鸭子判(`remote-env.js`)把本仓 host lane 一并算作 remote,
229
229
  * 于是单机默认部署上 `memoryCapture:"off"` 两条 ingress 结构性不可达(B-085)。
230
230
  * 刻意仍不给 local 一个**内存** twin:内存形会在重启时把一条隐私记录静默丢掉(比文件三腿严格更差)。 */
231
231
  sessionCaptureRecords(): SessionCaptureRecordSource | undefined;
@@ -246,8 +246,8 @@ export interface StoreBackend {
246
246
  *
247
247
  * ✅ WIRED (was "NOT YET WIRABLE, as of core 1.144.0" — that claim went STALE and sat here misleading readers
248
248
  * until a 2026-07-25 doc-rot sweep caught it). core 1.145.0 added the seam this comment was waiting for:
249
- * `RunnerDeps.workflowJournalStore` (now `@sema-agent/core` `dist/core/types.d.ts:708`) + `journalStore` on the
250
- * run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts:65`), and the boot chain consumes it
249
+ * `RunnerDeps.workflowJournalStore` (now `@sema-agent/core` `dist/core/types.d.ts`) + `journalStore` on the
250
+ * run_workflow tool deps (`dist/orchestration/run-workflow-tool.d.ts`), and the boot chain consumes it
251
251
  * end-to-end: built in `boot/workflow-orchestration.ts` (`const workflowJournalStore = …backend.workflowJournal()`),
252
252
  * threaded into RunnerDeps (`boot/runner-deps.ts` 的 `workflowJournalStore`), reaped on the maintenance tick
253
253
  * (`boot/reapers.ts` 同名字段), and read by `GET /v1/workflows/:id/journal` (`http/routes/workflows.ts`, [ref]). */
@@ -258,11 +258,11 @@ export declare function isParkedRunStatus(status: RunRecord["status"]): boolean;
258
258
  * · 读不懂的词(滚动升级里新副本写入的新状态词经裸 cast 读到本进程)同判 —— 与
259
259
  * {@link isTerminalRunStatus} 族的保守方向一致。
260
260
  * · **缺席怎么渲染是 core 的辖域,本仓一个字都不复述**(S-514-JU:此处原写着「core 对缺席渲染成 `idle`」
261
- * 并抄了当时 `list-agents-tool.js:58` 的旧三元 —— core 7.24.0 [ref] 把那一臂改成了一张 own-key 表 +
261
+ * 并抄了当时 `list-agents-tool.js` 的旧三元 —— core 7.24.0 [ref] 把那一臂改成了一张 own-key 表 +
262
262
  * `unknown` 兜底,于是那句复述当天就成了假话,而它在本仓被抄了六处)。本表只负责**铸不铸键**;
263
263
  * 渲染词由**跑真 core 的黑盒**钉住(`test/peer-lane-e2e.test.ts` 的 G1 格),core 哪天再改那张表,
264
264
  * 红的是那一格而不是一句注释。承重的不变量只有一条,且它与渲染词无关:**缺席绝不铸 `dead`** ——
265
- * `liveness` 才是 core 会拿来**整行隐藏**的那一位(7.24.2 dist 的 `list-agents-tool.js:60/62`),而任何 tempo 词
265
+ * `liveness` 才是 core 会拿来**整行隐藏**的那一位(7.24.2 dist 的 `list-agents-tool.js`),而任何 tempo 词
266
266
  * (含缺席)都照样列出。
267
267
  */
268
268
  export declare const SESSION_TEMPO_BY_RUN_CLASS: {
@@ -14,7 +14,7 @@ export declare function tidbPoolOptions(tidb: NonNullable<ServiceConfig["tidb"]>
14
14
  /** ── [ref] 同查结论:mysql2 与 pg-pool **不同病**,本池不需要(也不应)挂进程保命监听 ────────────────
15
15
  * pg 侧的病是「idle client 错误 emit 到 Pool 且无监听 ⇒ 未捕获 ⇒ 整进程 exit 1」(修在 pg-pool.ts
16
16
  * createPgPool 头注)。mysql2@3.22.6 亲读(不信文档,以实现为准):
17
- * · `lib/pool_connection.js:14-16` —— PoolConnection 构造器**恒挂** `once('error', () =>
17
+ * · `lib/pool_connection.js` —— PoolConnection 构造器**恒挂** `once('error', () =>
18
18
  * this._removeFromPool())`:每条池连接天生有 error 监听(**借出期同在**——不像 pg-pool 借出时
19
19
  * 摘监听,mysql2 的 checked-out 连接也无裸奔窗),EventEmitter 不会把 idle 死转 throw,
20
20
  * 坏连接就地淘汰;
@@ -364,10 +364,29 @@ export const SCHEMA_STATEMENTS = [
364
364
  principal VARCHAR(190) NULL,
365
365
  rules JSON NOT NULL,
366
366
  rev BIGINT NOT NULL,
367
+ -- gen (core 7.25.0 StoredSessionRules.gen, S-529 二期):这一行的**血统**。rev 只在一条血统内计数,
368
+ -- deleteBySession 终结血统并把下一代的号留在 session_policy_rev_mark.gen 上,于是活规则读者能把
369
+ -- 「被抹掉又重建的行」与「它正在执行的那一行的延续」分开 —— 无论重建行的 rev 因高水位爬到多大。
370
+ -- DEFAULT 0 = **第 0 代**(本列到货前写下的行,以及从没被抹除过的键):core 契约逐字「Absent = generation 0」。
371
+ gen BIGINT NOT NULL DEFAULT 0,
367
372
  created_at DATETIME(3) NOT NULL,
368
373
  updated_at DATETIME(3) NOT NULL,
369
374
  PRIMARY KEY (policy_key),
370
375
  KEY idx_session_policy_session (session_id)
376
+ ) COLLATE utf8mb4_bin`,
377
+ `CREATE TABLE IF NOT EXISTS session_policy_rev_mark (
378
+ policy_key CHAR(64) NOT NULL,
379
+ -- 被删会话。记号的读写一律按 policy_key 单键;这一列是**运维可读性 + 未来按会话/按龄回收**的那条腿
380
+ -- 的抓手(今天没有任何生产查询按它选行 —— 留存腿与收编腿都不碰本表,理由见上面那段)。
381
+ session_id VARCHAR(64) NOT NULL,
382
+ -- 抹除时刻该键带过的最高 rev(GREATEST 合并:记号只升不降)。
383
+ rev BIGINT NOT NULL,
384
+ -- 这把键的**下一行从第几代开始**(每次抹除 +1;与 core File 侧车 <stem>.hwm.json 的 __gen 同义)。
385
+ -- 血统必须比它终结的那一行活得久,理由与 rev 完全一样:留在行里就等于跟着行一起消失。
386
+ -- DEFAULT 0 = 本列到货前落下的记号(那些键的行也都是第 0 代 ⇒ 下一次抹除把它们抬到 1,前后自洽)。
387
+ gen BIGINT NOT NULL DEFAULT 0,
388
+ erased_at DATETIME(3) NOT NULL,
389
+ PRIMARY KEY (policy_key)
371
390
  ) COLLATE utf8mb4_bin`,
372
391
  `CREATE TABLE IF NOT EXISTS snapshot_blob (
373
392
  blob_hash CHAR(64) NOT NULL,
@@ -513,7 +513,7 @@ export declare function parseRuleScope(text: string): DurableRuleScope | undefin
513
513
  * 🔴 **三元组,不是 `(rule, scope)` 二元组**(异源复核 [medium],验真后修):core 的 `sameRuleIdentity`
514
514
  * 头注逐字 ——「a comparison that forgot the behavior member would let a delete aimed at an allow take the
515
515
  * same-text deny」。漏掉 behavior 的比较在 wire 上的表现不是「删错行」,是**报错行的死活**:撤一条从来
516
- * 没有过的 `allow` 会因为看见同文本的兄弟 `deny` 而答 `stillLive: true`,那句话说的是另一条规则。
516
+ * 没有过的 `allow` 会因为看见同文本的兄弟 `deny` 而答 `stillLive: "yes"`,那句话说的是另一条规则。
517
517
  *
518
518
  * 凡「这份清单里有没有这条规则」一律走这一只(撤销口的存活回读 / 单步写口的写前问与写后读回)——
519
519
  * 同一个问题有两个说话人,迟早会给两个答案。
@@ -590,10 +590,12 @@ export type RuleWritten =
590
590
  /** `persisted` = 这一次**真的往店里写了**;`no-op` = 这条身份**此前就站在店里**,本次调用**一个字都没写**
591
591
  * (幂等重写)。两支都带 `row` —— 它是**从店里读出来的**那一行,不是我方按入参拼出来的回声。
592
592
  *
593
- * 🔴 两个词的判别位是「**这次调用有没有写**」,不是 core 的 `deduped`(codex 对抗复审 F1,验真后修)。
594
- * core 的 `deduped` 只说「等价规则此前已站」,它**仍然落了一枚新的 dot**(契约逐字:`deduped` counts
595
- * as LANDED;亲读 dist 的成员循环:`res.dot` 无条件进 members)。把它当 `no-op` 上 wire 是一句假话:
596
- * 一个照常重试的客户端会让 `adds` 台账无界增长、桶的 rev 逐次推进(在翻页的治理面全体重列)。 */
593
+ * 🔴 两个词的判别位是「**这次调用有没有写**」(codex 对抗复审 F1,验真后修)。
594
+ * S-511 时这条口借的是导入面的兑付腿,而那侧的 `deduped` 只说「等价规则此前已站」、**仍然落了一枚新的 dot**
595
+ * ⇒ 那一格必须如实报 `persisted`,否则一个照常重试的客户端会让 `adds` 台账无界增长、桶的 rev 逐次推进
596
+ * (在翻页的治理面全体重列)。**S-525 换成 core 的直写接缝之后这条歧义没了**:`addPersistedRule` 的 `no-op`
597
+ * 契约逐字是「等价规则已经在这只桶里活着,**没有铸第二枚 dot**」——与本口 `no-op` 承诺的那句话逐字同一件事,
598
+ * 所以现在两个词与 core 的两个词一一对应,不再需要翻译。 */
597
599
  {
598
600
  status: "persisted" | "no-op";
599
601
  rev: number;
@@ -649,8 +651,11 @@ export interface RuleConsentLane {
649
651
  * 是**无墓碑硬删**:sync 的 join 会让它复活,而运维以为自己收回了权限。
650
652
  *
651
653
  * 返回值逐字是 core 的 `RemoveResult` 三态(removed / no-op / failed),**不塌**:
652
- * `removed.stillLive:true` 意思是「墓碑落了,但本次调用期间又落了一次新的批准,规则按 add-wins
653
- * 仍然活着」——那是一个**有名字的真结果**,不是异常,更不是「撤销完成」。
654
+ * `removed.stillLive` 自 core 7.25.0 起是**三词**(`"yes"` / `"no"` / `"unknown"`)而不是布尔 ——
655
+ * `"yes"` 意思是「墓碑落了,但本次调用期间又落了一次新的批准,规则按 add-wins 仍然活着」(一个**有名字的
656
+ * 真结果**,不是异常,更不是「撤销完成」);`"unknown"` 意思是**回答这一问的那次读回没做成**,它与 `"no"`
657
+ * 的区别正是这个域唯一不肯说的那句话:「我们看不见规则」永远不等于「没有规则」。本车道**原样**交出去、
658
+ * 不折叠(折 `unknown` 进 `no` 就是把「没看见」渲染成「不在了」)。
654
659
  *
655
660
  * `principal` 裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形让
656
661
  * local-owner 桶**真可撤**(core 5.24.0 起 `removePersistedRule` 的入参 union 扩,[ref] 收官件)。
@@ -669,8 +674,10 @@ export interface RuleConsentLane {
669
674
  * 方向在类型上就到不了这里:`behavior` 收窄成 {@link DirectWritableRuleBehavior}。
670
675
  *
671
676
  * 🔴 **一行都不自造**:行的 tool/match/command 三格是规则文本的**派生事实**,由 core 的
672
- * `parseRuleText` 铸;落店走的是导入面那条**同一条**兑付腿(逐行判据 → core 的记录铸造口 → confirm
673
- * → 批兑付),于是本口与导入口对「一条规则能不能进店」永远给同一个答案。
677
+ * `parseRuleText` 铸;**内容判据**与导入面走**同一个函数**({@link screenLocalImportRow}),于是本口与
678
+ * 导入口对「一条规则能不能进店」永远给同一个答案。**落店**(S-525 / core 7.25.0)走 core 的**记录级
679
+ * 直写接缝** `addPersistedRule` —— 一步、无票、core 自己铸 `origin:"user"`、`allow` 在类型上不可拼;
680
+ * 此前借的是导入面的两跳票仪式,代价是出处词恒 `imported-cc` + 每次成功的写白落两行台账。
674
681
  *
675
682
  * `principal` 是裸串(与 prepare/redeem 两腿同形);local-owner 桶**不在本口**上 —— 那只桶没有
676
683
  * 「谁在写」这个主体,而本口的授权基础就是已验明的 principal。
@@ -5,7 +5,7 @@ import { createLogger } from "./observability/logger.js";
5
5
  import { MAX_CWD_CHARS } from "./task-cwd.js";
6
6
  import { isDirectWritableRuleBehavior } from "./permission-rule-vocab.js";
7
7
  import { CONTROL_AND_BIDI_CHARS, hasBidiControls } from "./text-bidi.js";
8
- import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
8
+ import { confirmRuleApproval, RULE_BEHAVIORS, parseRuleText, ruleReachesProgramRun, ruleToolGrammarOf, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, addPersistedRule, removePersistedRule, effectiveOrThrow, readRowBehavior, } from "@sema-agent/core";
9
9
  import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
10
10
  export const RULE_IMPORT_TICKET_TTL_MS = 10 * 60_000;
11
11
  export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
@@ -740,45 +740,24 @@ export function createRuleConsentLane(stores, opts) {
740
740
  const standing = rowOf(before.rows);
741
741
  if (standing !== undefined)
742
742
  return { status: "no-op", rev: before.rev, row: standing };
743
- const prepared = await lane.prepareLocalImport(input.principal, [row]);
744
- if (!prepared.ok) {
745
- throw new Error(`a single-rule write hit the batch ceiling (${prepared.reason}) — the write door and the batch caps are out of step`);
746
- }
747
- if (prepared.ticket === undefined) {
748
- const member = prepared.preview.members[0];
749
- if (member === undefined || member.admitted) {
750
- throw new Error("the rule screening reported neither an admitted row nor a refusal for this write — refusing to guess which of the two happened");
751
- }
752
- return { status: "rejected", reason: member.reason, detail: member.detail };
753
- }
754
- const redeemed = await lane.redeemLocalImport(input.principal, prepared.ticket);
755
- if (!redeemed.ok) {
756
- localImportLogger.warn("direct_rule_write_ticket_refused", {
757
- reason: redeemed.reason,
758
- detail: redeemed.detail,
759
- note: "a self-minted, immediately redeemed ticket was refused — the outcome of this write is not known",
760
- });
761
- return { status: "indeterminate", detail: `${redeemed.reason}: ${redeemed.detail}` };
762
- }
763
- const member = redeemed.outcome.members[0];
764
- if (member === undefined) {
765
- throw new Error("the redemption of a single-rule write came back with no member — refusing to report an outcome nobody computed");
766
- }
767
- switch (member.outcome) {
768
- case "persisted":
743
+ const screened = screenLocalImportRow(row);
744
+ if ("reason" in screened)
745
+ return { status: "rejected", reason: screened.reason, detail: screened.detail };
746
+ const added = await addPersistedRule({ behavior: input.behavior, rule: written.rule.rule, scope: input.scope, principal: input.principal, provider: stores.provider });
747
+ switch (added.status) {
748
+ case "added":
769
749
  break;
770
750
  case "no-op":
771
751
  break;
772
- case "indeterminate":
773
- return { status: "indeterminate", detail: member.detail ?? member.reason ?? "the store did not say whether this rule landed" };
774
- case "refused":
775
- return { status: "indeterminate", detail: member.detail ?? "the store refused this rule without a machine-readable reason" };
752
+ case "failed":
753
+ return { status: "indeterminate", detail: added.error };
776
754
  default: {
777
- const unknown = member.outcome;
778
- throw new Error(`unknown single-rule write outcome: ${JSON.stringify(unknown)}`);
755
+ const unknown = added;
756
+ throw new Error(`unknown addPersistedRule outcome: ${JSON.stringify(unknown)}`);
779
757
  }
780
758
  }
781
- const landed = rowOf((await lane.listRules(input.principal)).rows);
759
+ const readBack = await lane.listRules(input.principal);
760
+ const landed = rowOf(readBack.rows);
782
761
  if (landed === undefined) {
783
762
  localImportLogger.warn("direct_rule_write_vanished_before_read_back", {
784
763
  behavior: input.behavior,
@@ -787,7 +766,7 @@ export function createRuleConsentLane(stores, opts) {
787
766
  });
788
767
  return { status: "indeterminate", detail: "this rule landed and was already gone when it was read back — a revocation landed in between" };
789
768
  }
790
- return { status: "persisted", rev: redeemed.outcome.rev, row: landed };
769
+ return { status: added.status === "no-op" ? "no-op" : "persisted", rev: added.rev, row: landed };
791
770
  },
792
771
  };
793
772
  return lane;
@@ -31,7 +31,7 @@ export declare const CANCELLED_CODE = "cancelled";
31
31
  * 发 `done`,`terminal` 是一条**不带码**的 `failed`。这里在**它**之上补标,不另造一份结果(另造会把
32
32
  * 引擎已经算出的 `stats` / `result` / `editedFiles` 全丢掉)。
33
33
  * · **缺席**:根本没读到 done —— 客户端断连后迭代器早收,或**开跑之前**就已经立起的旗
34
- * (亲核 core 7.11.2 `dist/core/runner/runtask.js:623-626`:`runLocked` 入口对已 aborted 的 signal
34
+ * (亲核 core 7.11.2 `dist/core/runner/runtask.js`:`runLocked` 入口对已 aborted 的 signal
35
35
  * 抛 `AbortError`)⇒ 合成一条诚实的取消终局。
36
36
  *
37
37
  * ## 「只补空座」纪律(与 core 的 `amendTerminal` 同规),而且**补码与补快照同进同退**
@@ -113,7 +113,7 @@ export interface LocalApproverIo {
113
113
  * 复刻一份 = 同源谎(两处判据日后各改各的)。裁决归 core,座位归这里。
114
114
  *
115
115
  * 🔴 非 TTY 臂答 `false`(真 deny),**绝不答 `"unavailable"`**:后者被 core 读成「席位够不到」并**改道
116
- * `suspendAsk`**(dist `core/hooks.js:296` 的 `approverUnavailable` 重投)。今天本腿无店 ⇒ 那条改道无处
116
+ * `suspendAsk`**(dist `core/hooks.js` 的 `approverUnavailable` 重投)。今天本腿无店 ⇒ 那条改道无处
117
117
  * 可去(fail-closed 拒),但这一条仍是本席的契约:答案是「不批」,不是「找不到人」。
118
118
  */
119
119
  export declare function createLocalApprover(io: LocalApproverIo): OnAsk;
@@ -9,23 +9,23 @@
9
9
  * (engine-can ∧ center-may ∧ shell-show): core calls it with `spec.principal`.
10
10
  *
11
11
  * ⚠️ **调用次数不是「每任务一次」**([ref]① 随 codex r1-[medium] 亲验后纠:旧句写「ONCE per task at
12
- * prepare time」):prepare 期解析一次(prepare-task.js:1826),**并且** capture-opt-out 的 flip 动词每被打一次
13
- * 就再解析一次(prepare-memory.js:572 的 `flip` → :538 `resolveCaptureEntitlementFresh` → :542 本 resolver)。
12
+ * prepare time」):prepare 期解析一次(prepare-task.js),**并且** capture-opt-out 的 flip 动词每被打一次
13
+ * 就再解析一次(prepare-memory.js 的 `flip` → 同文件 `resolveCaptureEntitlementFresh` → 本 resolver)。
14
14
  * 所以本 resolver 必须是**可重入、可多次调用**的,缓存的 TTL 语义对 flip 腿同样生效;把它当「每任务一次的
15
15
  * 快照」来实现(例如在里面攒 per-task 状态)会在 flip 腿上出错。
16
16
  *
17
17
  * ⚠️ [ref]① 纠失实:核心那句曾写作「引擎只执法它能执法的**两**个 cap」—— 早已不属实。core 7.2.0
18
18
  * 装树 dist **亲读**的消费点是**六**个(本仓 node_modules 实测;下列坐标除注明外均在 `dist/core/runner/` 下,
19
19
  * `agents/subagent.js` 那一条已就地标出 —— 说「均在 runner 下」是 r1 抓到的第二处过头话):
20
- * · `allowWorkflows` — prepare-task.js:1917 `runtimeCaps?.allowWorkflows !== false`(deny 自编排;:1918-1919 另发 onError)
21
- * · `allowFork` — prepare-task.js:1867 `forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork)`(+:2418 拒由,agents/subagent.js:1575 拒词)
22
- * · `allowObservers` — prepare-task.js:1868 `=== true`(**极性与 allow* 相反**:显式 opt-in,默认 OFF)
23
- * · `forceDurableGate`— prepare-task.js:1644/1646 的 durable/content mandate、:2337 活 content-ask 抑制、
24
- * :2365 durable question face(该行合取 `resolveCheckpointStore(spec, deps) !== undefined`
20
+ * · `allowWorkflows` — prepare-task.js `runtimeCaps?.allowWorkflows !== false`(deny 自编排;同处另发一条 onError)
21
+ * · `allowFork` — prepare-task.js `forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork)`(拒由同处铸出,拒词在 agents/subagent.js)
22
+ * · `allowObservers` — prepare-task.js `=== true`(**极性与 allow* 相反**:显式 opt-in,默认 OFF)
23
+ * · `forceDurableGate`— prepare-task.js 的 durable/content mandate、活 content-ask 抑制、
24
+ * durable question face(该处合取 `resolveCheckpointStore(spec, deps) !== undefined`
25
25
  * ⇒ **无 checkpoint 店的部署上这一面本就不成立**,该键在那里 inert)
26
- * · `autoMode` — prepare-task.js:1869 `=== true` **∧** `deps.autoMode !== undefined`(部署需先具备分类器;
26
+ * · `autoMode` — prepare-task.js `=== true` **∧** `deps.autoMode !== undefined`(部署需先具备分类器;
27
27
  * 显式 opt-in 极性,且 [ref] 起本侧传的是三态 org deny 位,见 toCoreRuntimeCaps 头注)
28
- * · `allowMemoryOptOut` — prepare-memory.js:542-543 `resolveCaptureEntitlementFresh`(+prepare-task.js:2474-2475
28
+ * · `allowMemoryOptOut` — prepare-memory.js `resolveCaptureEntitlementFresh`(+prepare-task.js
29
29
  * 的 value/faulted 投影);**极性反转,但只在 `open` 姿态下**——判决全表在
30
30
  * `dist/core/runner/memory-capture-optout.js` 的 `adjudicateCaptureOptOut`,按
31
31
  * `RunnerDeps.memoryCapturePolicy` 三姿态分叉(codex r1-[medium] 亲验后纠:旧句无条件
@@ -112,7 +112,7 @@ export interface EntitlementsResolverOpts {
112
112
  * 武装,真实用户诊断的病根)。
113
113
  * 🔴 **上面那条武装式是 [ref] 的目标形,不是「本仓现在装的那版 core 的形」——引用它之前先看装的是哪版**
114
114
  * ([ref]① 随 codex r2-[high] 补:我方在 WIRE 附录 F 照抄这一句,写成了「7.2.0 的行为」,而 npm **7.2.0
115
- * 不含 [ref]** —— 它的 `dist/core/runner/prepare-task.js:1869` 逐字仍是 `runtimeCaps?.autoMode === true &&
115
+ * 不含 [ref]** —— 它的 `dist/core/runner/prepare-task.js` 逐字仍是 `runtimeCaps?.autoMode === true &&
116
116
  * deps.autoMode !== undefined`,即旧「org 授予」极性、**缺键 ⇒ 不武装**)。判「现在是哪套极性」的唯一判据 =
117
117
  * 安装包探针 `CORE_ARMS_AUTO_ON_INTENT`(`src/auto-mode-face.ts`;实测本树 7.2.0 ⇒ **false** = 旧式臂),
118
118
  * 代码侧已按它分臂,失实的只是散落各处的散文。⚠️ 同形措辞在本文件 :209 / :362-364 / :548 与
@@ -42,8 +42,8 @@ export declare function isDelegationToolName(name: string): boolean;
42
42
  * tool at all.
43
43
  *
44
44
  * 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
45
- * (`dist/agents/subagent.js:1501` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
46
- * 腿 `:405` 同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
45
+ * (`dist/agents/subagent.js` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
46
+ * resume 腿同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
47
47
  * 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
48
48
  * 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
49
49
  * 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
@@ -27,7 +27,7 @@
27
27
  * 「常量锚」):句子收进本文件**单一属主**,冻结职责搬到冻结门的**常量锚格** —— 那一格直接断言这两只
28
28
  * 渲染器对每一种判词的输出字节,且对判词闭集穷尽(加一种判词 ⇒ 那一格编译红)。文案一个字节未改。
29
29
  */
30
- import type { SessionPermissionRules, SessionRulesRecord } from "@sema-agent/core";
30
+ import type { SessionPermissionRules, SessionPolicyError, SessionRulesRecord } from "@sema-agent/core";
31
31
  /** core `SessionPermissionRules` 的桶名全集(派生,不是手抄)。 */
32
32
  export declare const SESSION_PERMISSION_RULE_FIELDS: readonly (keyof SessionPermissionRules)[];
33
33
  /** 一条规则体的形问题。判词而非文案(见文件头注的「文案属主」一段)。 */
@@ -100,4 +100,28 @@ export declare function readSessionRulesRecords(raw: unknown): SessionRulesRecor
100
100
  export declare function describeSessionPermissionRulesProblem(p: SessionPermissionRulesProblem): string;
101
101
  /** `policy[]` 判词 → 人话(sync/import 腿在它前面拼 `policy: `)。闭集穷尽同上。 */
102
102
  export declare function describeSessionRulesRecordProblem(p: SessionRulesRecordProblem): string;
103
+ /**
104
+ * 🔴 **`SessionPolicyError` 的闭集 → HTTP 的**唯一**映射(core 7.25.0 提货批新建)。**
105
+ *
106
+ * 此前这张映射在两条路由上各写了一个**二元三元表达式**:`sessions.ts` 写 `conflict ? 409 : 403`、
107
+ * `session-sync.ts` 写 `loosen_forbidden ? 403 : 409`。两句话在两个词的世界里都对,而 core 这一版给闭集
108
+ * 加了第三个词 `corrupt`(店读不出自己写的记号/行 ⇒ 拒写,而不是按 0 续号)—— 于是同一个新词在两条路由上
109
+ * 拿到**两个不同的、都不对的**答案:一边 403「你没权限」,一边 409「有人先改了」,而它其实是一次
110
+ * **数据完整性故障**。这正是 [ref] 词表纪律说的那一形:闭集必须穷尽 `switch`,未知词是**编译**错,
111
+ * 不是运行期落进某个恰好存在的臂。
112
+ *
113
+ * ⇒ 映射搬到这里(本文件已是 policy wire 形的单一读者/属主,S-496 先例),两条路由同调它。
114
+ * 新增一个词 ⇒ 本 switch 编译红,由人裁定它的状态码与码位,而不是让它静默继承邻居的。
115
+ *
116
+ * 逐词:
117
+ * · `conflict`(409)—— CAS 输了,重读 `rev` 再来。**码位与状态逐字不变**。
118
+ * · `loosen_forbidden`(403)—— 非 operator 想放松。**码位与状态逐字不变**。
119
+ * · `corrupt`(**500**)—— 店读不出它自己该读的那份字节(记号/行被列外篡改或撕裂)。
120
+ * 不是 4xx:调用方没做错任何事,它也改不了这件事;不是 503:重试改不了判(core 成文的修复路径是
121
+ * **人**去改回或删掉那一行,而删记号要交出它握着的那个号)。
122
+ */
123
+ export declare function sessionPolicyErrorHttp(code: SessionPolicyError["code"]): {
124
+ status: number;
125
+ errorCode: string;
126
+ };
103
127
  //# sourceMappingURL=session-policy-wire.d.ts.map