@sema-agent/server 7.29.0 → 7.30.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 (52) hide show
  1. package/USAGE.md +20 -5
  2. package/dist/boot/governance-seams.d.ts +13 -0
  3. package/dist/boot/governance-seams.js +21 -1
  4. package/dist/boot/retention-lane.d.ts +206 -0
  5. package/dist/boot/retention-lane.js +279 -0
  6. package/dist/boot/shutdown.d.ts +8 -0
  7. package/dist/boot/shutdown.js +21 -2
  8. package/dist/config-types.d.ts +22 -1
  9. package/dist/config-types.js +6 -1
  10. package/dist/config.d.ts +15 -1
  11. package/dist/config.js +43 -2
  12. package/dist/http/routes/capabilities.js +30 -0
  13. package/dist/http/routes/memory-policy.d.ts +13 -0
  14. package/dist/http/routes/memory-policy.js +79 -2
  15. package/dist/http/routes/retention-ops.d.ts +36 -0
  16. package/dist/http/routes/retention-ops.js +190 -0
  17. package/dist/http/server.js +14 -0
  18. package/dist/main.js +74 -0
  19. package/dist/memory-scope.d.ts +5 -3
  20. package/dist/memory-scope.js +6 -8
  21. package/dist/observability/fail-open.d.ts +4 -0
  22. package/dist/observability/fail-open.js +4 -0
  23. package/dist/plugins/caching-session-store.d.ts +11 -1
  24. package/dist/plugins/caching-session-store.js +13 -0
  25. package/dist/plugins/checkpoint-store-sql.d.ts +3 -0
  26. package/dist/plugins/checkpoint-store-sql.js +4 -0
  27. package/dist/plugins/local-checkpoint-store.d.ts +3 -0
  28. package/dist/plugins/local-checkpoint-store.js +4 -0
  29. package/dist/plugins/local-session-store.d.ts +5 -0
  30. package/dist/plugins/local-session-store.js +6 -0
  31. package/dist/plugins/memory-engine-pg.js +22 -10
  32. package/dist/plugins/memory-engine-tidb.js +22 -11
  33. package/dist/plugins/memory-key-guards.d.ts +27 -4
  34. package/dist/plugins/memory-key-guards.js +57 -6
  35. package/dist/plugins/memory-sync-store-pg.js +5 -0
  36. package/dist/plugins/memory-sync-store-tidb.js +5 -0
  37. package/dist/plugins/pg-pool.js +4 -0
  38. package/dist/plugins/pg-session-storage.d.ts +2 -0
  39. package/dist/plugins/pg-session-storage.js +3 -0
  40. package/dist/plugins/retention-lane-store-sql.d.ts +254 -0
  41. package/dist/plugins/retention-lane-store-sql.js +236 -0
  42. package/dist/plugins/retention-store-sql.d.ts +468 -0
  43. package/dist/plugins/retention-store-sql.js +793 -0
  44. package/dist/plugins/store-backend.d.ts +20 -0
  45. package/dist/plugins/store-backend.js +7 -0
  46. package/dist/plugins/tidb-pool.js +5 -0
  47. package/dist/plugins/tidb-session-store.d.ts +3 -0
  48. package/dist/plugins/tidb-session-store.js +4 -0
  49. package/dist/plugins/tool-result-store-sql.d.ts +3 -0
  50. package/dist/plugins/tool-result-store-sql.js +4 -0
  51. package/dist/run-local.js +16 -0
  52. package/package.json +1 -1
@@ -38,6 +38,7 @@ import { handleCapabilities } from "./routes/capabilities.js";
38
38
  import { handleObservability } from "./routes/observability.js";
39
39
  import { handleDiagnostics } from "./routes/diagnostics.js";
40
40
  import { handleAdoption } from "./routes/adoption.js";
41
+ import { handleRetention } from "./routes/retention-ops.js";
41
42
  import { handleMemoryPolicy } from "./routes/memory-policy.js";
42
43
  import { handleSharedMemory } from "./routes/shared-memory.js";
43
44
  import { handleRules } from "./routes/rules.js";
@@ -209,6 +210,7 @@ const ROUTE_DOMAINS = [
209
210
  handleObservability,
210
211
  handleDiagnostics,
211
212
  handleAdoption,
213
+ handleRetention,
212
214
  handleMemoryPolicy,
213
215
  handleRules,
214
216
  handleSharedMemory,
@@ -693,6 +695,11 @@ export function createHttpServer(rawDeps) {
693
695
  // design/183 §4.3:收编面(routes/adoption.ts,operator lane;POST /v1/adoption + GET /v1/adoption/:id)。
694
696
  if (await handleAdoption(req, res, url, ctx))
695
697
  return;
698
+ // #270 车2:托管留存的 operator 治理面(routes/retention-ops.ts,operator lane;hold PUT/DELETE +
699
+ // 审计读)。位置跟着 adoption —— 同族(部署级 operator 动作、同一条 `explicitOperatorOk` 门、
700
+ // 同样 billable=false 且刻意不吃 drain / model-roster-pending 两道 503)。
701
+ if (await handleRetention(req, res, url, ctx))
702
+ return;
696
703
  // design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
697
704
  if (await handleMemoryPolicy(req, res, url, ctx))
698
705
  return;
@@ -3275,6 +3282,9 @@ const ROUTE_LABEL_PATTERNS = [
3275
3282
  [/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
3276
3283
  [/^\/v1\/capabilities\/scenarios\/[^/]+$/, "/v1/capabilities/scenarios/:name"],
3277
3284
  [/^\/v1\/adoption\/[^/]+$/, "/v1/adoption/:id"],
3285
+ // #270 车2:留存 legal-hold 的放置/解除。`*` 不是 `+` —— 空段 = 无主桶(单用户部署里它是唯一的域;
3286
+ // 理由逐字见 routes/retention-ops.ts 的「域的两个边界」)。
3287
+ [/^\/v1\/ops\/retention\/holds\/[^/]*$/, "/v1/ops/retention/holds/:domain"],
3278
3288
  [/^\/v1\/memory\/sync\/[^/]+$/, "/v1/memory/sync/:scope"],
3279
3289
  [/^\/v1\/usage\/(summary|series|breakdown)$/, "/v1/usage/$sub"],
3280
3290
  // design/177 共享记忆库只读面。两条各自成桶(列举是窗口读、正文是单文档读,延迟分布不同族)。
@@ -3316,6 +3326,10 @@ const ROUTE_LABEL_LITERALS = new Set([
3316
3326
  // #A1([4147] 件3):后台 agent 名册读面。壳每次开面板打一次,是**每用户高频**的租户读 ——
3317
3327
  // 落进 `other` 桶会把它的 duration 和 4xx 混进一堆低频运维口里。同上,写字面量不引常量。
3318
3328
  "/v1/agents/roster",
3329
+ // #270 车2:留存审计读面(operator-only)。低频,但它是「留存到底删没删、按哪档删的」的唯一读口
3330
+ // —— 落进 `other` 桶等于合规排障现场看不见它。同上,写**字面量**不引常量(名册门与 billable
3331
+ // 申明门都扫源码文本)。
3332
+ "/v1/ops/retention/audit",
3319
3333
  ]);
3320
3334
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
3321
3335
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
package/dist/main.js CHANGED
@@ -43,6 +43,7 @@ import { createConfigCenterRuntime } from "./boot/config-center.js";
43
43
  import { createResolveSpec } from "./boot/resolve-spec.js";
44
44
  import { createParkedReviveInheritedGate } from "./boot/parked-revive-gate.js";
45
45
  import { startReapers } from "./boot/reapers.js";
46
+ import { assertRetentionLaneWirable, startRetentionLane, RETENTION_LEASE_TTL_FACTOR } from "./boot/retention-lane.js"; // #270 车2
46
47
  import { startFleetReconciler } from "./fleet/fleet-reconciler.js";
47
48
  import { openStores } from "./boot/stores.js";
48
49
  import { runAdoptionBootScan } from "./boot/adoption.js";
@@ -279,6 +280,31 @@ async function main() {
279
280
  // design/170 件B/C/D(#252 件1):三个部署治理座席 + 两条拒启判据(锁的装配相容性、留存能力校验)。
280
281
  // ⚠️ 位置即契约:必须在 store 三件都构造完之后(件D 校验读它们的 `retention` 声明)、`new Runner` 之前
281
282
  // (座席进 RunnerDeps);拒启在这里发生 = 还没开始服务。
283
+ // #270 —— 托管留存的**执行面装配**(设计稿 §7)。⚠️ 位置即契约:必须在 `createGovernanceSeams` 之前
284
+ // (件① 的 `retentionExecutorWired` 是它的入参),也必须在 store 三件构造完之后(自持门要读它们的
285
+ // 声明位)。两只店都是 **SQL 后端专有**,`local`/无 backend 上恒 undefined —— 那正是 lane 自持门
286
+ // 在 `RETENTION_SWEEP_INTERVAL_SEC>0` 时拒启的那一支。
287
+ //
288
+ // `taskAttachmentTable` 如实告知(车1 交接件③):它是「boot 真的 ensure 过那张表吗」的事实,
289
+ // 不是一个默认值 —— 无对象存储的部署上附件面整段不接线、表不建,一条盲发的 DELETE 会以
290
+ // unknown-table 打红整轮 sweep(理由逐字见 retention-store-sql.ts 的 `SqlRetentionStoreOptions`)。
291
+ const retentionExecutor = backend?.retention?.({ taskAttachmentTable: taskAttachmentStore !== undefined, logger });
292
+ const retentionLaneStore = backend?.retentionLane?.();
293
+ // lane **自持**的 boot 不变式(件④;codex F3)——与下面 core 的 `assertRetentionCapability` 是两道门,
294
+ // 各答各的问题,本道**不看 locked**。判据与三条拒启臂逐字见 `boot/retention-lane.ts`。
295
+ assertRetentionLaneWirable({
296
+ intervalSec: config.retentionSweep.intervalSec,
297
+ policy: config.retentionPolicy,
298
+ executor: retentionExecutor,
299
+ // 与件D 校验**同一份**店清单(下面 `retentionStores` 那三行的同源读点):两道门看的是同一批真身,
300
+ // 一门放行另一门拒的分歧才有意义,而"看的不是同一批店"造成的分歧只是 bug。
301
+ stores: [
302
+ { name: "sessionStore", store: sessionStore },
303
+ { name: "checkpointStore", store: checkpointStore },
304
+ { name: "toolResultStore", store: runnerOffloadStore },
305
+ ],
306
+ locked: (config.lockedConfigKeys ?? []).includes("retentionPolicy"),
307
+ });
282
308
  const governanceSeams = createGovernanceSeams({
283
309
  config,
284
310
  logger,
@@ -292,6 +318,11 @@ async function main() {
292
318
  // 喂错实例 = 校验漏掉了真正在跑的那一个,正是「校验读的不是真身」这类假绿。
293
319
  { name: "toolResultStore", store: runnerOffloadStore },
294
320
  ],
321
+ // 🔴 件①(车1 交接件,codex R2-[critical] 的修复面):**本 build 真的接了执行器吗**。
322
+ // core 的 `assertRetentionCapability` 只读店的声明位,而三只 SQL 店从车1 起就诚实地声明了
323
+ // `"managed"` —— 于是那道门会为一台**没有任何东西在删**的机器放行 locked policy。这一位问的是
324
+ // 另一个问题:有没有东西真会删。合取式逐字 = 上面那道自持门放行之后 lane 真的会起的条件。
325
+ retentionExecutorWired: config.retentionSweep.intervalSec > 0 && retentionExecutor !== undefined && retentionLaneStore !== undefined,
295
326
  });
296
327
  // design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
297
328
  const runnerDeps = createRunnerDeps({
@@ -848,6 +879,46 @@ async function main() {
848
879
  backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
849
880
  getRunDenySweep: () => runDenySweep,
850
881
  });
882
+ // #270 车2 —— 托管留存 sweep lane(**独立**定时器,不折进上面那条维护 tick;理由逐字见
883
+ // `boot/retention-lane.ts` 的文件头:那二十条腿全都容忍多副本,而本 lane 必须单执行者)。
884
+ // 两只店在场性已由上面的自持门保证(interval>0 而店缺席 ⇒ 那道门已经拒启了);这里的
885
+ // `&&` 只是把类型收窄出来,不是第二道门。
886
+ /** 租约 TTL = 2× sweep 间隔(§3)。抢租与每 domain 的续租共用同一个值 —— 两处手抄迟早只改一处。 */
887
+ const ttlMs = config.retentionSweep.intervalSec * 1000 * RETENTION_LEASE_TTL_FACTOR;
888
+ const retentionLaneStarted = retentionExecutor !== undefined && retentionLaneStore !== undefined && config.retentionPolicy !== undefined
889
+ ? startRetentionLane({
890
+ intervalSec: config.retentionSweep.intervalSec,
891
+ policy: config.retentionPolicy,
892
+ mode: config.retentionSweep.mode,
893
+ executor: retentionExecutor,
894
+ // 租约 holder = 本副本的 `instanceId`(与 run 行的 claim 同一个身份):审计行里的 holder/token
895
+ // 于是能直接对上"哪台副本跑的那一轮"。
896
+ lease: {
897
+ acquire: async () => {
898
+ const claim = await retentionLaneStore.acquire(instanceId, ttlMs, Date.now());
899
+ return claim.held ? { held: true, fencingToken: claim.fencingToken } : { held: false };
900
+ },
901
+ // 复核**并续租**(一条 CAS)——续租的机会只有这一处:重入守卫会跳过下一 tick(理由逐字见
902
+ // `boot/retention-lane.ts` 的 `startRetentionLane` 头注与店侧 `renew` 的顶注)。
903
+ renew: (fencingToken) => retentionLaneStore.renew(instanceId, fencingToken, ttlMs, Date.now()),
904
+ release: () => retentionLaneStore.release(instanceId),
905
+ },
906
+ audit: { append: (row) => retentionLaneStore.appendAudit(row, Date.now()) },
907
+ // 省调预检(§5 v1.3:**不承重** —— 承重判在店事务内的哨兵行锁上)。
908
+ holdInForce: (domain) => retentionLaneStore.holdInForce(domain),
909
+ logger,
910
+ metrics,
911
+ now: () => Date.now(),
912
+ })
913
+ : undefined;
914
+ if (retentionLaneStarted !== undefined) {
915
+ logger.info("retention_lane_started", {
916
+ intervalSec: config.retentionSweep.intervalSec,
917
+ mode: config.retentionSweep.mode,
918
+ maxAgeDays: config.retentionPolicy?.maxAgeDays,
919
+ holder: instanceId,
920
+ });
921
+ }
851
922
  // Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
852
923
  // best-effort (a collector outage is logged, never affects serving). /metrics stays available too.
853
924
  const otelExporter = config.otel
@@ -1267,6 +1338,9 @@ async function main() {
1267
1338
  config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter,
1268
1339
  runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
1269
1340
  storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
1341
+ // #270 车2:留存 lane 的定时器 + 主动让租(收尾契约 3 同族,理由见 shutdown.ts 的两条注)。
1342
+ retentionLane: retentionLaneStarted,
1343
+ releaseRetentionLease: retentionLaneStore !== undefined ? () => retentionLaneStore.release(instanceId) : undefined,
1270
1344
  });
1271
1345
  }
1272
1346
  void main().catch((err) => {
@@ -24,9 +24,11 @@ export declare function isV2ScopeKey(key: string): boolean;
24
24
  * tenant-isolated backend exists, multi-tenant memory is off by construction. */
25
25
  /** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
26
26
  * security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
27
- * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。 */
28
- export declare const MEMORY_SCOPE_COLUMN_CHARS = 190;
29
- export declare function assertMemoryScopeWidth(scope: string): void;
27
+ * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
28
+ *
29
+ * #271 件1:定义**搬去** `plugins/memory-key-guards.ts`(列宽与 store 写口同源单一属主),此处
30
+ * 只再导出——旧 import 路径(本文件)逐字不变,`memoryScopeFor` 的产出处消费点也不变。 */
31
+ export { MEMORY_SCOPE_COLUMN_CHARS, assertMemoryScopeWidth } from "./plugins/memory-key-guards.js";
30
32
  export declare function memoryScopeFor(config: ServiceConfig, principal?: string, projectId?: string): string | undefined;
31
33
  /**
32
34
  * N0 (通宵测试 2026-07-09,三重坐实 e2b+kata+代码): the file memory engine (core design/138) materializes and
@@ -5,6 +5,7 @@
5
5
  // "exports" only ever exposed "." and "./main" — dist/security.js was never a reachable deep-import path).
6
6
  import { join } from "node:path";
7
7
  import { FileMemoryEngineBackend, resolveMemoryEngineRoot, deriveControlPlaneDir, formatUserScope, formatProjScope } from "@sema-agent/core";
8
+ import { assertMemoryScopeWidth } from "./plugins/memory-key-guards.js"; // #271 件1:列宽单一属主(本文件再导出)
8
9
  /**
9
10
  * design/142 §1.2 的 v2 typed scope-key 前缀表 —— **单一属主**(A-002.4:`config.ts` 的
10
11
  * MEMORY_SYNC_SCOPE 派生处曾有一份逐字副本,两处咬合用途不同[盖 v2 契约标 vs 判要不要再包一层
@@ -46,14 +47,11 @@ export function isV2ScopeKey(key) {
46
47
  * tenant-isolated backend exists, multi-tenant memory is off by construction. */
47
48
  /** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
48
49
  * security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
49
- * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。 */
50
- export const MEMORY_SCOPE_COLUMN_CHARS = 190;
51
- export function assertMemoryScopeWidth(scope) {
52
- if (scope.length > MEMORY_SCOPE_COLUMN_CHARS) {
53
- throw new Error(`memory scope exceeds ${MEMORY_SCOPE_COLUMN_CHARS} characters after segment encoding (the scope column width ` +
54
- `both SQL dialects pin); got ${scope.length} — use a shorter principal/projectId (non-ASCII characters expand ~9x when encoded)`);
55
- }
56
- }
50
+ * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
51
+ *
52
+ * #271 件1:定义**搬去** `plugins/memory-key-guards.ts`(列宽与 store 写口同源单一属主),此处
53
+ * 只再导出——旧 import 路径(本文件)逐字不变,`memoryScopeFor` 的产出处消费点也不变。 */
54
+ export { MEMORY_SCOPE_COLUMN_CHARS, assertMemoryScopeWidth } from "./plugins/memory-key-guards.js";
57
55
  export function memoryScopeFor(config, principal, projectId) {
58
56
  if (config.requirePrincipal === true) {
59
57
  // S3-TOB(设计 §1.3):multi-tenant lights up ONLY on a DB backend (scope column = tenant
@@ -57,6 +57,10 @@ export declare const FAIL_OPEN_TAGS: {
57
57
  readonly cls: "F";
58
58
  readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
59
59
  };
60
+ readonly "server.retention.sweep-report-dropped": {
61
+ readonly cls: "F";
62
+ readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
63
+ };
60
64
  readonly "server.parked-revive.ancestor-classifier-unreachable": {
61
65
  readonly cls: "P-DEBT";
62
66
  readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
@@ -80,6 +80,10 @@ export const FAIL_OPEN_TAGS = {
80
80
  cls: "F",
81
81
  note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
82
82
  },
83
+ "server.retention.sweep-report-dropped": {
84
+ cls: "F",
85
+ note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
86
+ },
83
87
  "server.parked-revive.ancestor-classifier-unreachable": {
84
88
  cls: "P-DEBT",
85
89
  note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
@@ -1,4 +1,4 @@
1
- import type { AcquiredSession, SessionStore, SessionTreeEntry } from "@sema-agent/core";
1
+ import type { AcquiredSession, RetentionDeclaration, SessionStore, SessionTreeEntry } from "@sema-agent/core";
2
2
  import type { SessionListItem } from "../security.js";
3
3
  import type { StagingHandle } from "../session-sync-kernel.js";
4
4
  export interface CachingOptions {
@@ -94,6 +94,16 @@ export declare class CachingSessionStore implements SessionStore {
94
94
  /** 2c session-sync (§7/§8) — delegated IDEMPOTENT replace; like importEntries it writes durable state under
95
95
  * `sessionId`, so a stale warm handle for that id is evicted on success (next acquire re-wakes from the new log). */
96
96
  readonly replaceEntries?: (sessionId: string, owner: string | null, entries: SessionTreeEntry[]) => Promise<void>;
97
+ /**
98
+ * #270 车1 —— 内层店的 `retention` **声明**(core `RetentionDeclaring`)。
99
+ *
100
+ * 🔴 为什么这一格必须转发,而且方向比别的转发更要命:生产默认 `SESSION_CACHE_TTL_SEC=300` 会把真店
101
+ * 包进本类,于是 boot 递给 `assertRetentionCapability` 的就是**包装层**。core 对缺席是 fail-closed
102
+ * 读 `"none"` —— 不转发 ⇒ 一个真 managed 的 SQL 部署在 locked policy 下**被拒启**,而拒启文案会指着
103
+ * 一只其实完全合格的店(「stores that cannot delete」)。转发是**只读快照**而不是 getter:声明是店的
104
+ * 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
105
+ */
106
+ readonly retention?: RetentionDeclaration;
97
107
  constructor(inner: SessionStore, opts: CachingOptions);
98
108
  acquire(sessionId?: string, opts?: {
99
109
  requireExisting?: boolean;
@@ -69,9 +69,22 @@ export class CachingSessionStore {
69
69
  /** 2c session-sync (§7/§8) — delegated IDEMPOTENT replace; like importEntries it writes durable state under
70
70
  * `sessionId`, so a stale warm handle for that id is evicted on success (next acquire re-wakes from the new log). */
71
71
  replaceEntries;
72
+ /**
73
+ * #270 车1 —— 内层店的 `retention` **声明**(core `RetentionDeclaring`)。
74
+ *
75
+ * 🔴 为什么这一格必须转发,而且方向比别的转发更要命:生产默认 `SESSION_CACHE_TTL_SEC=300` 会把真店
76
+ * 包进本类,于是 boot 递给 `assertRetentionCapability` 的就是**包装层**。core 对缺席是 fail-closed
77
+ * 读 `"none"` —— 不转发 ⇒ 一个真 managed 的 SQL 部署在 locked policy 下**被拒启**,而拒启文案会指着
78
+ * 一只其实完全合格的店(「stores that cannot delete」)。转发是**只读快照**而不是 getter:声明是店的
79
+ * 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
80
+ */
81
+ retention;
72
82
  constructor(inner, opts) {
73
83
  this.inner = inner;
74
84
  this.ttlMs = Math.max(0, opts.ttlSec) * 1000;
85
+ const declared = inner.retention;
86
+ if (declared !== undefined)
87
+ this.retention = declared;
75
88
  const ownerAware = inner;
76
89
  if (ownerAware.ownerOf)
77
90
  this.ownerOf = ownerAware.ownerOf.bind(inner);
@@ -186,6 +186,9 @@ export declare class SqlCheckpointStore implements CheckpointStore {
186
186
  protected readonly logger?: {
187
187
  info?(msg: string, meta?: unknown): void;
188
188
  } | undefined;
189
+ /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
190
+ * 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
191
+ readonly retention: import("@sema-agent/core").RetentionDeclaration;
189
192
  /**
190
193
  * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
191
194
  * `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
@@ -35,6 +35,7 @@ import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
35
35
  import { recordFailOpen } from "../observability/fail-open.js";
36
36
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
37
37
  import { isDupKeyError } from "./sql-errors.js";
38
+ import { MANAGED_RETENTION } from "./retention-store-sql.js";
38
39
  /** Cap the persisted/served pending-approval args so a pathological tool payload can't bloat the checkpoint row
39
40
  * or the operator-queue response (the BFF renders this in an approval card; a few KB is plenty). */
40
41
  const MAX_TOOL_INPUT_CHARS = 8192;
@@ -346,6 +347,9 @@ const STEER_CAS_ATTEMPTS = 8;
346
347
  export class SqlCheckpointStore {
347
348
  db;
348
349
  logger;
350
+ /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
351
+ * 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
352
+ retention = MANAGED_RETENTION;
349
353
  /**
350
354
  * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
351
355
  * `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
@@ -8,6 +8,9 @@ export interface LocalCheckpointStoreOptions {
8
8
  ownerOf?: (sessionId: string) => Promise<string | null | undefined>;
9
9
  }
10
10
  export declare class LocalCheckpointStore {
11
+ /** #270 车1:托管留存声明 —— **诚实的 none**(与 local session 店同一条判据:本店的行没有按期删除的腿;
12
+ * 留存三方法只实现在 SQL 聚合店上)。locked policy + 本店 ⇒ core 的门拒启,那是契约的正确行为。 */
13
+ readonly retention: import("@sema-agent/core").RetentionDeclaration;
11
14
  /**
12
15
  * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)——**如实按内核判**:本类的内核是 core 的
13
16
  * `FileCheckpointStore`(crash-safe ledger + snapshot,自身声明 `"durable"`),本地 scope 注册表也落
@@ -26,6 +26,7 @@ import { join } from "node:path";
26
26
  import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
27
27
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
28
28
  import { boundedRuleSuggestions, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
29
+ import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
29
30
  /** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
30
31
  function terminalAtOf(cp) {
31
32
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
@@ -35,6 +36,9 @@ function ctxFileOf(sessionId) {
35
36
  return `${sanitizePathComponent(sessionId).slice(0, 48)}-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}.json`;
36
37
  }
37
38
  export class LocalCheckpointStore {
39
+ /** #270 车1:托管留存声明 —— **诚实的 none**(与 local session 店同一条判据:本店的行没有按期删除的腿;
40
+ * 留存三方法只实现在 SQL 聚合店上)。locked policy + 本店 ⇒ core 的门拒启,那是契约的正确行为。 */
41
+ retention = UNMANAGED_RETENTION;
38
42
  /**
39
43
  * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)——**如实按内核判**:本类的内核是 core 的
40
44
  * `FileCheckpointStore`(crash-safe ledger + snapshot,自身声明 `"durable"`),本地 scope 注册表也落
@@ -3,6 +3,11 @@ import type { SessionSummary } from "./store-contracts.js";
3
3
  import { type StagingHandle } from "../session-sync-kernel.js";
4
4
  export declare class LocalSessionStore implements SessionStore {
5
5
  private readonly repo;
6
+ /** #270 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
7
+ * SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的
8
+ * **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none,
9
+ * 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */
10
+ readonly retention: import("@sema-agent/core").RetentionDeclaration;
6
11
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
7
12
  private readonly pending;
8
13
  /** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
@@ -31,6 +31,7 @@ import { join, resolve } from "node:path";
31
31
  // 已立案的病族,过期待办比没有更有害)。
32
32
  import { SessionError, validateEntriesForImport } from "@sema-agent/core";
33
33
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
34
+ import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
34
35
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
35
36
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
36
37
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -61,6 +62,11 @@ function userText(entry) {
61
62
  const TITLE_STATE_BY_PATH = new Map();
62
63
  export class LocalSessionStore {
63
64
  repo;
65
+ /** #270 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
66
+ * SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的
67
+ * **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none,
68
+ * 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */
69
+ retention = UNMANAGED_RETENTION;
64
70
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
65
71
  pending = new Map();
66
72
  /** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
@@ -23,7 +23,7 @@ import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
23
23
  import { isUniqueViolation } from "./memory-engine-vector-util.js";
24
24
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
25
25
  import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
26
- import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
26
+ import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
27
27
  /** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
28
28
  * the retired MemoryStore plane and this entry plane must never cross-write. */
29
29
  export const PG_MEMORY_ENGINE_TABLES = {
@@ -265,6 +265,18 @@ export class PgMemoryEngineBackend {
265
265
  report.conflicts.push({ op: patch.op, id: patch.id, reason: `patch id ${JSON.stringify(patch.id)} != entry id ${JSON.stringify(patch.entry.id)} (malformed patch refused)` });
266
266
  return;
267
267
  }
268
+ // #271 件1(R-1):键宽在**任何 I/O 之前**判,add/update 两条腿一处收(tidb 版同注)。R5 那批只
269
+ // 护住了 add 腿的 slug ⇒ 一次改名(update `SET scope=…, slug=…`)就能把超宽键送进 DB;而超宽键
270
+ // 的两个结局都坏——PG 落不可分类的裸 22001,非严格 MySQL **静默截断**(截断后的键指向**另一个**
271
+ // scope/slug:两租户的盘折叠 / 两条目互串)。拒绝式,与 unstorable_bytes 同族(后端能力差异诚实
272
+ // 暴露,重试恒同答=幂等成立);写语句一条都不发出去。
273
+ if (patch.op !== "delete" && patch.entry !== undefined) {
274
+ const refusal = memoryEntryKeyWidthRefusal(patch.entry.scope, patch.entry.slug);
275
+ if (refusal !== undefined) {
276
+ report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
277
+ return;
278
+ }
279
+ }
268
280
  if (patch.op === "add") {
269
281
  const rawEntry = patch.entry;
270
282
  if (!rawEntry) {
@@ -279,15 +291,7 @@ export class PgMemoryEngineBackend {
279
291
  report.conflicts.push({ op: "add", id: rawEntry.id, reason: "unstorable_bytes (PG cannot store NUL/lone surrogates; strip them at the source — the store never rewrites content)" });
280
292
  return;
281
293
  }
282
- // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形;tidb 版同注)。守卫常量与 DDL 同源门
283
- // 在 test/key-width-guards.test.ts。
284
- try {
285
- assertSlugWidth(rawEntry.slug);
286
- }
287
- catch (e) {
288
- report.conflicts.push({ op: "add", id: rawEntry.id, reason: e instanceof Error ? e.message : String(e) });
289
- return;
290
- }
294
+ // (R5 的 add-only slug 守卫已上提到 applyOne 顶部的键宽块 —— add/update 同判,见那里的注。)
291
295
  const entry = rawEntry;
292
296
  // opus 审 C3: an add whose id already lives in a DIFFERENT scope is refused explicitly — the bare
293
297
  // `ON CONFLICT (id) DO UPDATE SET scope=…` would silently MOVE the row across scopes (and diverge
@@ -331,6 +335,13 @@ export class PgMemoryEngineBackend {
331
335
  const embedding = await this.embeddingParam(this.haystackOf(cEntry.slug, cEntry.frontmatter, cEntry.body));
332
336
  let slug = cEntry.slug;
333
337
  for (let n = 2;; n++) {
338
+ // #271 件1(codex 对抗复审 H1;tidb 版同注):**派生**候选也要吃守卫 —— 顶部那道只判原始 slug,
339
+ // 而撞键派生的 `${slug}-${n}` 可以越过列宽(511 + "-2" = 513),此前直奔 INSERT 落裸 22001。
340
+ const derivedRefusal = memoryEntryKeyWidthRefusal(entry.scope, slug);
341
+ if (derivedRefusal !== undefined) {
342
+ report.conflicts.push({ op: "add", id: entry.id, reason: `${derivedRefusal} (slug collision suffix "-${n - 1}" no longer fits the column — shorten the entry name)` });
343
+ return;
344
+ }
334
345
  const key = `${entry.scope}|${slug}`;
335
346
  if (!plannedSlugs.has(key)) {
336
347
  const taken = await this.query(`SELECT id FROM ${T} WHERE scope = $1 AND slug = $2`, [entry.scope, slug]);
@@ -456,6 +467,7 @@ export class PgMemoryEngineBackend {
456
467
  return res.rows.length > 0 ? String(res.rows[0].cursor) : undefined;
457
468
  }
458
469
  async setConsolidationCursor(scope, cursor) {
470
+ assertMemoryScopeWidth(scope); // #271 件1:游标表 PK 也是 varchar(190) —— 截断=把两个盘的整合游标折叠成一个
459
471
  await this.query(`INSERT INTO ${PG_MEMORY_ENGINE_TABLES.cursor} (scope, cursor) VALUES ($1, $2)
460
472
  ON CONFLICT (scope) DO UPDATE SET cursor = $2`, [scope, cursor]);
461
473
  }
@@ -24,7 +24,7 @@
24
24
  import { jaccardDistance, termSet } from "@sema-agent/core";
25
25
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
26
26
  import { pgHasUnstorable } from "./pg-safe-json.js";
27
- import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
27
+ import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
28
28
  import { isMysqlDupKeyError } from "./sql-errors.js";
29
29
  /** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
30
30
  * one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
@@ -218,6 +218,16 @@ export class TiDBMemoryEngineBackend {
218
218
  report.conflicts.push({ op: patch.op, id: patch.id, reason: `patch id ${JSON.stringify(patch.id)} != entry id ${JSON.stringify(patch.entry.id)} (malformed patch refused)` });
219
219
  return;
220
220
  }
221
+ // #271 件1(R-1):键宽在任何 I/O 之前判,add/update 两条腿一处收(Pg 版同注)。本方言尤其承重
222
+ // ——非严格 MySQL 对超宽键**静默截断**(只发 warning),截断后的 scope/slug 指向**另一个**键:
223
+ // 两租户的盘折叠成一个、两个条目互串,写入侧不炸、读回侧才发现串了。拒绝式,零写。
224
+ if (patch.op !== "delete" && patch.entry !== undefined) {
225
+ const refusal = memoryEntryKeyWidthRefusal(patch.entry.scope, patch.entry.slug);
226
+ if (refusal !== undefined) {
227
+ report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
228
+ return;
229
+ }
230
+ }
221
231
  if (patch.op === "add") {
222
232
  const entry = patch.entry;
223
233
  if (!entry) {
@@ -233,16 +243,7 @@ export class TiDBMemoryEngineBackend {
233
243
  report.conflicts.push({ op: "add", id: entry.id, reason: "unstorable_bytes (utf8mb4 cannot store NUL/lone surrogates without mangling; strip them at the source — the store never rewrites content)" });
234
244
  return;
235
245
  }
236
- // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形)——非严格 MySQL 会静默截断,
237
- // 截断=两个不同 slug 折叠成一行互串;PG/严格 MySQL 落裸错不可分类。守卫常量与 DDL 同源门在
238
- // test/key-width-guards.test.ts。
239
- try {
240
- assertSlugWidth(entry.slug);
241
- }
242
- catch (e) {
243
- report.conflicts.push({ op: "add", id: entry.id, reason: e instanceof Error ? e.message : String(e) });
244
- return;
245
- }
246
+ // (R5 的 add-only slug 守卫已上提到 applyOne 顶部的键宽块 —— add/update 同判,见那里的注。)
246
247
  // Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
247
248
  // scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
248
249
  // One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
@@ -276,6 +277,15 @@ export class TiDBMemoryEngineBackend {
276
277
  // a concurrent loser retries the next suffix (Pg 版同注).
277
278
  let slug = entry.slug;
278
279
  for (let n = 2;; n++) {
280
+ // #271 件1(codex 对抗复审 H1,验真属实):**派生**候选也要吃守卫。顶部那道只判调用方给的原始
281
+ // slug,而本循环在撞键时派生 `${slug}-${n}` —— 511 字符的合法 slug 撞一次就派生出 513 字符,
282
+ // 绕过守卫直奔 INSERT(非严格 MySQL 截成另一个 512 字符键、还照报未截断的 slug=最坏形)。
283
+ // 判在循环**顶**=任何探针/写之前;后缀装不下 ⇒ 可分类 conflict,不是静默截断也不是死循环。
284
+ const derivedRefusal = memoryEntryKeyWidthRefusal(entry.scope, slug);
285
+ if (derivedRefusal !== undefined) {
286
+ report.conflicts.push({ op: "add", id: entry.id, reason: `${derivedRefusal} (slug collision suffix "-${n - 1}" no longer fits the column — shorten the entry name)` });
287
+ return;
288
+ }
279
289
  const key = `${entry.scope}|${slug}`;
280
290
  if (!plannedSlugs.has(key)) {
281
291
  const taken = await this.selectRows(`SELECT id FROM ${T} WHERE scope = ? AND slug = ?`, [entry.scope, slug]);
@@ -442,6 +452,7 @@ export class TiDBMemoryEngineBackend {
442
452
  return rows.length > 0 ? String(rows[0].cursor) : undefined;
443
453
  }
444
454
  async setConsolidationCursor(scope, cursor) {
455
+ assertMemoryScopeWidth(scope); // #271 件1:游标表 PK 也是 VARCHAR(190) —— 截断=把两个盘的整合游标折叠成一个
445
456
  // Single unique key (the PK) on this table — ON DUPLICATE KEY UPDATE is unambiguous here,
446
457
  // unlike the two-unique-key entries table (see the file-head dialect note).
447
458
  await this.write(`INSERT INTO ${TIDB_MEMORY_ENGINE_TABLES.cursor} (scope, \`cursor\`) VALUES (?, ?) ON DUPLICATE KEY UPDATE \`cursor\` = VALUES(\`cursor\`)`, [scope, cursor]);
@@ -1,8 +1,31 @@
1
- /** R5(车A [3191] 欠账,批γ 落地):memory-engine 键宽写前守卫。
2
- * 列宽收窄后,模型可控的条目名(slug)超宽此前落裸 SQL 错(PG `value too long`)或非严格 MySQL
3
- * 静默截断(截断=两个不同 slug 折叠成一行=条目互串,最危险形)。写前响亮拒,错误可分类。
4
- * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表)。 */
1
+ /** R5(车A [3191] 欠账,批γ 落地;#271 件1 补齐写口):memory 面的键宽写前守卫。
2
+ * 列宽收窄后,模型/调用方可控的键(条目名 slug、租户盘 scope)超宽此前落裸 SQL 错(PG
3
+ * `value too long`)或非严格 MySQL 静默截断(截断=两个不同的键折叠成同一行=**条目互串 / 两个租户
4
+ * 的盘塌成一个**,最危险形)。写前响亮拒,错误可分类。
5
+ * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表);写口在场门=同文件
6
+ * 「#271 件1」组(超宽键一条写语句都不许发出 seam)。
7
+ *
8
+ * **本文件是这两个宽度的唯一属主**(#271 件1):scope 守卫此前住在 `src/memory-scope.ts`,只被
9
+ * `memoryScopeFor` 的产出处消费,而 store 写口全无——同一个列宽有两个居所、两半消费面,正是
10
+ * 「守卫在位≠写口接上」那类缺口的温床。`memory-scope.ts` 现在只**再导出**本文件的这两个符号
11
+ * (旧 import 路径逐字不变)。 */
5
12
  /** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
6
13
  export declare const MEMORY_SLUG_COLUMN_CHARS = 512;
14
+ /** memory 面 `scope` 列宽(两方言 VARCHAR(190) 同宽:engine 的 entry/cursor 两表 + memory-sync 的
15
+ * sync_cursor/push_queue/history)。`formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
16
+ * **膨胀**——非 ASCII principal 编码后可超列宽,故产出处与写口两道都要判。 */
17
+ export declare const MEMORY_SCOPE_COLUMN_CHARS = 190;
7
18
  export declare function assertSlugWidth(slug: string): void;
19
+ export declare function assertMemoryScopeWidth(scope: string): void;
20
+ /**
21
+ * 一个 entry 写(add / update)的两个键宽一起判 —— 两方言的 `applyOne` 在**任何 I/O 之前**调用它。
22
+ *
23
+ * 为什么两条腿共用一个入口而不是各自展开:add 与 update 写的是**同两列**(update 的
24
+ * `SET scope = …, slug = …` 会改盘也会改名),R5 那批只护住了 add ⇒ 一次改名就能把超宽 slug 送进
25
+ * DB。一个入口=两条腿不会再各自漂。
26
+ *
27
+ * 返回**字符串**而不是抛:两方言的 `applyPatches` 契约是「冲突进 report,不抛」(与
28
+ * `unstorable_bytes` 的拒绝式同族——后端能力差异诚实暴露,重试恒同答=幂等成立)。`undefined` = 过。
29
+ */
30
+ export declare function memoryEntryKeyWidthRefusal(scope: string, slug: string): string | undefined;
8
31
  //# sourceMappingURL=memory-key-guards.d.ts.map