import { type SessionPolicyStore, type SessionPermissionRules, type StoredSessionRules, type SessionRulesRecord, type PutRulesOptions } from "@sema-agent/core"; import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SqlDriver } from "./sql-driver.js"; /** `session_policy.policy_key` 的**唯一铸造点**(core 复合键的 sha256)。 * * 🔴 导出而不是让收编腿再抄一份(clay 宪法「schema 单一属主禁复制」):[ref] form b 的身份重绑要 * 逐行重算这把键(身份在 hash 的**原像**里,等值 UPDATE 够不着)。两处各写一遍 sha256 表达式的下场是 * 某天有人在这里改了 key 公式而收编腿还在按旧式算 —— 迁完的行从此谁也读不到,且**静默**。 */ export declare const policyKeyFor: (sessionId: string, principal: string | undefined) => string; /** Dual-dialect durable SessionPolicyStore. See the file header for the dialect-delta ledger. */ export declare class SqlSessionPolicyStore implements SessionPolicyStore { private readonly db; constructor(db: SqlDriver); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; /** 判据属主 = `sql-errors.ts`([ref] P1-①;本站点旧形已是并集形,收编后语义不变)。 */ private isDupKey; /** Read the stored rules for (session, principal-owner); null when none (parity with InMemory). */ getRules(sessionId: string, principal?: string): Promise; /** 2c session-sync: every (principal, rules) record for a session across ALL principals — getRules is * per-(session,principal), but a cross-backend session EXPORT must bundle the WHOLE session's policy (the importer * replays each via putRules). principal is read from the DENORMALIZED column (the sha256 policy_key is one-way); * NULL → undefined = the session-wide row (parity with core's InMemory `principal ?? undefined`). Rules carry rev. */ listBySession(sessionId: string): Promise; /** Operator/owner write: CAS + tighten-only (unless operator) + rev bump, atomic in one txn; first-write race retried. */ putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise; /** * The store's ONE retry arm: re-run the whole read-check-write transaction while the failure is an engine * ARBITRATION artifact rather than an answer. Two such artifacts, and only two: * · **dup-key** — a concurrent FIRST write won the PK (no row ⇒ `FOR UPDATE` locked nothing). The re-read takes * the UPDATE/CAS path, which is core's serialized outcome; converting it to `conflict` in place would be a * spurious answer the caller can't tell from a real CAS loss. * · **deadlock** — the engine picked THIS transaction as the victim and already rolled it back. Which side gets * picked is arbitrary, so propagating it hands the caller an internal coin flip. 🔴 This arm is NOT a licence * for sloppy lock order: the order is still policy row → mark row on BOTH methods, and the oracle for it is * `test/sql-lock-shape-innodb-integration.test.ts` (site ⑩, three engines). It exists because on REAL InnoDB * (REPEATABLE READ) a `FOR UPDATE` read of a MISSING primary key takes a GAP lock, and gap locks are mutually * compatible while both block the other side's INSERT — so N concurrent first writes on one key deadlock NO * MATTER what order any table is touched in. Measured on the real engine (2026-09-20, S-529, 8-way race on * `main`'s statement sequence, zero mark table involved): InnoDB 1 winner / 7 `ER_LOCK_DEADLOCK`, TiDB 0, * 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. * After the first insert commits, the retried writers find the row and take the UPDATE path (record lock, * no gap) ⇒ the loop converges in one extra round per missing-row window; the bound is the suite's concurrency. * Lock-WAIT-TIMEOUT is deliberately NOT retried ({@link isDeadlockError}'s own note: it is a latency signal, * and retrying it multiplies an already-long wait). */ private retrying; /** E21 purge — drop ALL policy rows for a session (every principal), KEEPING each key's rev high-water mark. * Scoped by session_id (the route owner-gates). Return shape aligned to core 1.423's optional interface seam * (`Promise`) — the affectedRows count was incidental and unconsumed; the integration test asserts the * post-state (listBySession empty) instead. * * S-529 (`@contract session_policy.rev_survives_delete`): the rules ROW dies, the key's REV LINE does not, and the * erase ALSO ENDS THE ROW'S LINEAGE (`gen` + 1 — the next write on this key belongs to another generation, which no * reader treats as a continuation of the erased row whatever rev it wears) — one * transaction, three statements, and BOTH the order and the locking verb are load-bearing: * ① `SELECT … ORDER BY policy_key FOR UPDATE` — the rows to erase are read and X-LOCKED first, in a DETERMINISTIC * order. This is the SAME lock order putRules uses (policy row → mark row), which is the whole point: the * obvious shape (`INSERT … SELECT` the marks, then DELETE) locks the MARK first and takes only a SHARED lock * on the policy rows it reads, i.e. a reversed order PLUS an S→X upgrade — the textbook deadlock this repo * 已经吃过两次(retention-store-sql.ts ④a/④b 的 S-131 案:一边持 boundary 等哨兵,一边持哨兵等 boundary)。 * ② the marks (their revs come from ①'s already-locked rows) — `GREATEST` merge, so a mark only ever RISES and * repeated erases never walk the line back. * ③ the DELETE — scoped to ①'s POLICY_KEY SET, never re-scanned by `session_id`. 🔴 This is the whole of * codex r1 [high] #1 (verified, not rebutted): both engines re-evaluate a `WHERE session_id = ?` DELETE * against a CURRENT read (PG is pinned to READ COMMITTED, TiDB's DML is a current read), so a row for a * DIFFERENT principal that a concurrent putRules commits BETWEEN ① and ③ would be deleted by a session-wide * DELETE while ② never marked it — a silently reset rev line, exactly the defect this batch exists to close. * Scoped to the key set, the invariant is exact and checkable: **every row this seam deletes leaves a mark**. * A row born after ① is simply not part of this erase (same as one born a microsecond after ③ — the caller * that needs "and nothing came back" re-issues; the seam is idempotent). * No rows ⇒ return early: an erase of a session that never had rules writes NO mark (no line, nothing to keep), * which is what makes a repeated/empty delete idempotent instead of resurrecting a mark. * Why the mark is a separate table and not a tombstone row: see the DDL comment in tidb-pool.ts — a tombstone * would keep `session_id` + the DENORMALIZED plaintext `principal` in the table after an E21 purge, i.e. the * right-to-delete would degrade to buy rev ordering. The mark keeps only the one-way hash, the session id and * an integer. */ deleteBySession(sessionId: string): Promise; /** * The store's one read-check-write transaction. Its load-bearing read is line ~106's * `SELECT rules, rev … FOR UPDATE` — a LOCKING read, therefore a CURRENT read on both engines, which is * what makes the loosen-diff decision see the prior it is diffing against. That "therefore" is not a * property of the verb: it holds because {@link SqlTxConn.begin}'s `@contract txn.read-semantics` pins * TiDB to pessimistic transactions at connection init (an optimistic session would read `FOR UPDATE` off * the start-ts snapshot). Second line of defence, unchanged: the `rev` re-check plus the first write's * PK race. */ private tx; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class TiDBSessionPolicyStore extends SqlSessionPolicyStore { constructor(pool: MySqlPool); } /** PostgreSQL binding — historical class name + ctor shape preserved. */ export declare class PgSessionPolicyStore extends SqlSessionPolicyStore { constructor(pool: PgPool); } //# sourceMappingURL=session-policy-store-sql.d.ts.map