dsh-plugin-xujie-memory 0.5.0 → 0.7.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 (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -1,1312 +1,1333 @@
1
- """内核:痕迹、事件、状态投影、双时间、重算、依赖传播。
2
-
3
- 阶段 1:作用域、痕迹、幂等、投影、依赖链(T01/T03/T05)
4
- 阶段 2:双时间、纠正撤销、按状态键重算、依赖图与原子事件组(T04/T06/T07/T08)
5
-
6
- 本模块不调用任何模型。所有判定都是确定性函数。
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import json
13
- import sqlite3
14
- import threading
15
- import time
16
- from contextlib import contextmanager
17
- from dataclasses import dataclass, replace
18
- from datetime import datetime, timezone
19
- from pathlib import Path
20
- from typing import Any, Callable, Iterator, Sequence
21
-
22
- from .errors import (
23
- EventAlreadyVoid,
24
- LockTimeout,
25
- DependencyCycle,
26
- EvidenceMissing,
27
- IdempotencyConflict,
28
- ScopeNotFound,
29
- SpecNotFound,
30
- TraceErased,
31
- TraceNotFound,
32
- VersionConflict,
33
- )
34
- from .specs import RelationSpec, SpecRegistry
35
-
36
- SCHEMA_PATH = Path(__file__).with_name("schema.sql")
37
-
38
-
39
- # ============================================================ 工具
40
- def utcnow() -> str:
41
- return datetime.now(timezone.utc).isoformat(timespec="microseconds")
42
-
43
-
44
- def fixed_clock(start: str, step_seconds: float = 0.0):
45
- """可推进的假时钟,用于模拟"过了 30 天"。
46
-
47
- 没有它就没法测试遗忘:真实的 30 天不能等,而 monkeypatch 全局函数
48
- 会让测试之间互相污染。**遗忘是这个项目最核心的行为之一,
49
- 它必须是可复现的。**
50
- """
51
- from datetime import timedelta
52
-
53
- current = datetime.fromisoformat(start)
54
- if current.tzinfo is None:
55
- current = current.replace(tzinfo=timezone.utc)
56
-
57
- class Clock:
58
- def __call__(self) -> str:
59
- return current.isoformat(timespec="microseconds")
60
-
61
- def advance(self, **kwargs) -> str:
62
- nonlocal current
63
- current = current + timedelta(**kwargs)
64
- return self()
65
-
66
- def set(self, when: str) -> str:
67
- nonlocal current
68
- current = datetime.fromisoformat(when)
69
- if current.tzinfo is None:
70
- current = current.replace(tzinfo=timezone.utc)
71
- return self()
72
-
73
- return Clock()
74
-
75
-
76
- def canonical_json(value: Any) -> str:
77
- """规范化序列化。I2 的比较基础:键排序、无多余空白、不转义非 ASCII。"""
78
- return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
79
-
80
-
81
- def digest(*parts: str) -> str:
82
- h = hashlib.sha256()
83
- for p in parts:
84
- h.update(p.encode("utf-8"))
85
- h.update(b"\x00")
86
- return h.hexdigest()
87
-
88
-
89
- def make_state_key(
90
- scope_id: str,
91
- subject: str,
92
- relation: str,
93
- obj: str | None = None,
94
- **dims: str,
95
- ) -> str:
96
- """状态键必须稳定、可读、可比较。业务维度按名称排序,避免顺序影响。"""
97
- parts = [scope_id, subject, relation, obj or "-"]
98
- for k in sorted(dims):
99
- parts.append(f"{k}={dims[k]}")
100
- return "|".join(parts)
101
-
102
-
103
- def relation_of(state_key: str) -> str:
104
- parts = state_key.split("|")
105
- return parts[2] if len(parts) >= 3 else ""
106
-
107
-
108
- # ============================================================ 值对象
109
- @dataclass(frozen=True)
110
- class StateView:
111
- state_key: str
112
- status: str # known / unknown / disputed / stale / retracted
113
- value: Any
114
- last_known: Any
115
- version: int # 有效事件数,重算后稳定
116
- watermark: int # 已投影到的最大 sequence_no
117
- conflict_reason: str | None
118
- spec_version: str
119
-
120
- @property
121
- def is_usable(self) -> bool:
122
- return self.status in ("known", "stale")
123
-
124
-
125
- @dataclass
126
- class CommitResult:
127
- event_id: str
128
- state_key: str
129
- sequence_no: int
130
- applied: bool # False 表示命中幂等,未重复生效
131
- state: StateView
132
- # 这次提交**顺带作废**了几条事件。
133
- #
134
- # 目前只有一个来源:改初始值时,之后的余额快照会一起作废(XM-33)。
135
- # 放在这里而不是写日志,是因为调用方需要知道"我这一步不只改了一个数"——
136
- # 一次操作动了三条事件却不告诉调用方,就是另一种静默。
137
- cascaded: int = 0
138
-
139
-
140
- @dataclass
141
- class Assertion:
142
- """一条待提交的领域变更。阶段 1 由调用方直接构造,阶段 3 由闸门产出。"""
143
- scope_id: str
144
- state_key: str
145
- relation: str
146
- operation: str # append / update / retract / conflict
147
- effect_kind: str # set / delta / invalidate
148
- payload: dict[str, Any]
149
- idempotency_key: str
150
- evidence_trace_ids: tuple[str, ...]
151
- actor_id: str
152
- effective_at: str | None = None # 业务生效时间;缺省等于 recorded_at
153
- recorded_at: str | None = None # 系统获知时间;一般由内核生成
154
- expected_version: int | None = None
155
- spec_version: str | None = None
156
- gate_decision_id: str | None = None
157
- operation_group_id: str | None = None
158
- target_ref: str | None = None
159
- # 这句话属于哪一类句式(准入熔断用)。空字符串 = 调用方不区分。
160
- # 它进事件是为了**事后能追责**:一条 auto 提交被纠正时,
161
- # 要知道该关哪个格子——没有它就只能关掉整个关系。
162
- pattern: str = ""
163
-
164
-
165
- # ============================================================ 引擎
166
- class Engine:
167
- def __init__(
168
- self,
169
- db_path: str | Path,
170
- registry: SpecRegistry,
171
- clock: Callable[[], str] | None = None,
172
- ):
173
- self.db_path = str(db_path)
174
- self.registry = registry
175
- self.registry.assert_acyclic()
176
- # 时钟可注入。默认是真实时间;测试和"模拟过了很久"的演示传自己的。
177
- self._clock = clock or utcnow
178
- self._local = threading.local()
179
- # 每个线程一条连接,但 close() 必须能关掉全部:
180
- # 并发示例里 8 个 worker 各建一条,主线程 close() 只关自己那条,
181
- # 剩下的在 Windows 上会让库文件删不掉(POSIX 不会,所以一直没暴露)。
182
- self._conns: list[sqlite3.Connection] = []
183
- self._conns_lock = threading.Lock()
184
- self._derivations: dict[str, tuple[tuple[str, ...], str, Callable]] = {}
185
- self._init_schema()
186
-
187
- # -------------------------------------------------------- 连接
188
- def _conn(self) -> sqlite3.Connection:
189
- conn = getattr(self._local, "conn", None)
190
- if conn is None:
191
- conn = sqlite3.connect(
192
- self.db_path, timeout=30.0, isolation_level=None,
193
- check_same_thread=False,
194
- )
195
- conn.row_factory = sqlite3.Row
196
- conn.execute("PRAGMA foreign_keys = ON")
197
- # WAL 下 NORMAL 不会损坏库,只可能在断电时丢最后一个事务;
198
- # FULL 每次提交都 fsync,是旧版慢的原因之一。
199
- conn.execute("PRAGMA synchronous = NORMAL")
200
- conn.execute("PRAGMA busy_timeout = 30000")
201
- self._local.conn = conn
202
- with self._conns_lock:
203
- self._conns.append(conn)
204
- return conn
205
-
206
- def now(self) -> str:
207
- """当前时间。全库只通过这里取时间,方便模拟"过了很久"。"""
208
- return self._clock()
209
-
210
- def close(self) -> None:
211
- with self._conns_lock:
212
- conns, self._conns = self._conns, []
213
- for conn in conns:
214
- try:
215
- conn.close()
216
- except sqlite3.Error:
217
- # 另一个线程正在用这条连接时 close 会报错;关闭是收尾动作,
218
- # 报错没有调用方能处理,忽略即可。
219
- pass
220
- self._local.conn = None
221
-
222
- # 上下文管理不是为了好看:Windows 上还开着的库文件删不掉、改名改不掉,
223
- # 而"异常路径忘了 close"是最常见的写法。`with Engine(...) as e:`
224
- # 让关闭走 __exit__,异常也关。
225
- def __enter__(self) -> "Engine":
226
- return self
227
-
228
- def __exit__(self, *exc: object) -> None:
229
- self.close()
230
-
231
- def _init_schema(self) -> None:
232
- Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
233
- conn = sqlite3.connect(self.db_path, isolation_level=None)
234
- try:
235
- conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
236
- self._migrate(conn)
237
- finally:
238
- conn.close()
239
-
240
- # 增量列:CREATE TABLE IF NOT EXISTS 不会给已有库加列,
241
- # 而"用旧库跑新代码"是最常见的升级路径。每加一列就在这里登记一次。
242
- _ADDED_COLUMNS = (
243
- # (表, 列, 定义)
244
- ("interpretations", "semantic_op", "TEXT"),
245
- ("commitments", "unit", "TEXT"),
246
- # 事件来自哪一类句式(准入/熔断用)。老库补列后为 NULL,
247
- # 熔断只对 pattern 非空的事件生效——不让历史数据参与判定。
248
- ("events", "pattern", "TEXT"),
249
- # 澄清只念一次(v0.50.0)。老库补列后为 NULL,
250
- # 那批历史请求会被当成"还没问过"——最多多说一次,不会丢东西。
251
- ("clarifications", "asked_at", "TEXT"),
252
- # v0.52:派生标签记住自己对应哪个槽位(偏好对象 / 称呼),
253
- # 用户改口时才能精确把旧值标成 corrected,不用整表扫描。
254
- ("person_tags", "slot", "TEXT"),
255
- # v0.52:澄清是在哪个会话里念过的。记忆跨会话共享之后,
256
- # 「一个会话最多问几次」必须按会话算,否则问满 6 次就终身不再问。
257
- ("clarifications", "asked_session", "TEXT"),
258
- )
259
-
260
- def _migrate(self, conn: sqlite3.Connection) -> None:
261
- for table, column, decl in self._ADDED_COLUMNS:
262
- # 这里的连接没有 row_factory,PRAGMA 返回的是元组,name 在第 1 列
263
- cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}
264
- if column not in cols:
265
- conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
266
-
267
- @contextmanager
268
- def _tx(self, retries: int = 1) -> Iterator[sqlite3.Connection]:
269
- """BEGIN IMMEDIATE:写事务立即取锁,避免升级锁时的死锁。
270
-
271
- **可重入**:已经在事务里时用 SAVEPOINT。此前嵌套会抛
272
- `cannot start a transaction within a transaction`——那是一颗地雷,
273
- 因为"某个内部方法顺手开了个事务"是很自然的重构,一改就炸,
274
- 而且错误信息指向 SQL 层,看不出是设计约束。
275
-
276
- **锁超时转领域异常**:`busy_timeout` 用完时 SQLite 抛裸的
277
- `OperationalError: database is locked`。调用方拿不到任何信息,
278
- 只能当致命错误处理——而它其实重试就可能成功。
279
-
280
- 重试是安全的:`commit` 本身幂等(`idempotency_key`),
281
- 重跑一次事务不会产生第二次副作用。
282
- """
283
- conn = self._conn()
284
- depth = getattr(self._local, "tx_depth", 0)
285
- savepoint = f"sp_{depth}" if depth else None
286
-
287
- last: Exception | None = None
288
- for attempt in range(retries + 1):
289
- try:
290
- if depth:
291
- conn.execute(f"SAVEPOINT {savepoint}")
292
- else:
293
- conn.execute("BEGIN IMMEDIATE")
294
- break
295
- except sqlite3.OperationalError as exc:
296
- if "locked" not in str(exc) or attempt == retries:
297
- raise LockTimeout(str(exc)) from exc
298
- last = exc
299
- time.sleep(0.05 * (2 ** attempt))
300
- else: # pragma: no cover
301
- raise LockTimeout(str(last))
302
-
303
- self._local.tx_depth = depth + 1
304
- try:
305
- yield conn
306
- except BaseException:
307
- if depth:
308
- conn.execute(f"ROLLBACK TO {savepoint}")
309
- conn.execute(f"RELEASE {savepoint}")
310
- else:
311
- conn.execute("ROLLBACK")
312
- raise
313
- else:
314
- if depth:
315
- conn.execute(f"RELEASE {savepoint}")
316
- else:
317
- conn.execute("COMMIT")
318
- finally:
319
- self._local.tx_depth = depth
320
-
321
- # ======================================================== 作用域
322
- def ensure_scope(self, tenant_id: str, agent_id: str, world_id: str) -> str:
323
- scope_id = "sc_" + digest(tenant_id, agent_id, world_id)[:24]
324
- with self._tx() as c:
325
- c.execute(
326
- "INSERT OR IGNORE INTO scopes(scope_id, tenant_id, agent_id, world_id, created_at)"
327
- " VALUES (?,?,?,?,?)",
328
- (scope_id, tenant_id, agent_id, world_id, self.now()),
329
- )
330
- return scope_id
331
-
332
- def get_scope(self, scope_id: str) -> dict[str, str]:
333
- row = self._conn().execute(
334
- "SELECT * FROM scopes WHERE scope_id = ?", (scope_id,)
335
- ).fetchone()
336
- if row is None:
337
- raise ScopeNotFound(scope_id)
338
- return dict(row)
339
-
340
- # ======================================================== 痕迹
341
- def record_trace(
342
- self,
343
- scope_id: str,
344
- source_ref: str,
345
- actor_id: str,
346
- source_kind: str,
347
- content: str,
348
- source_time: str | None = None,
349
- source_time_precision: str | None = None,
350
- context_ref: str | None = None,
351
- ) -> str:
352
- """写入一条不可变痕迹。(scope_id, source_ref) 唯一:重试返回同一 trace_id。"""
353
- self.get_scope(scope_id)
354
- trace_id = "tr_" + digest(scope_id, source_ref)[:24]
355
- with self._tx() as c:
356
- existing = c.execute(
357
- "SELECT trace_id, content_hash FROM traces WHERE scope_id=? AND source_ref=?",
358
- (scope_id, source_ref),
359
- ).fetchone()
360
- if existing:
361
- if existing["content_hash"] != digest("content", content):
362
- raise IdempotencyConflict(source_ref, existing["trace_id"])
363
- return existing["trace_id"]
364
-
365
- c.execute(
366
- "INSERT INTO traces(trace_id, scope_id, source_ref, actor_id, source_kind,"
367
- " content, content_hash, recorded_at, source_time, source_time_precision, context_ref)"
368
- " VALUES (?,?,?,?,?,?,?,?,?,?,?)",
369
- (
370
- trace_id, scope_id, source_ref, actor_id, source_kind,
371
- content, digest("content", content), self.now(),
372
- source_time, source_time_precision, context_ref,
373
- ),
374
- )
375
- c.execute(
376
- "INSERT INTO traces_fts(trace_id, content) VALUES (?,?)",
377
- (trace_id, content),
378
- )
379
- return trace_id
380
-
381
- def get_trace(self, trace_id: str) -> dict[str, Any] | None:
382
- row = self._conn().execute(
383
- "SELECT * FROM traces WHERE trace_id = ?", (trace_id,)
384
- ).fetchone()
385
- return dict(row) if row else None
386
-
387
- def erase_trace(self, trace_id: str) -> dict[str, Any]:
388
- """显式删除:连同依赖它的事件一起失效,然后重算受影响状态。
389
-
390
- 流程(12.4):
391
- 1. 找出引用该痕迹的事件(读,事务外)
392
- 2. 事务内:事件置 void、状态重算、索引与摘录清除、原文清空
393
- 3. 事务外:沿依赖图传播派生状态
394
-
395
- **不追加反向事件**:删除不是"撤销",是"依据没了"。
396
- 依据没了的状态必须转 unknown,不能靠一条补偿事件把余额算回去——
397
- 那等于伪造替代证据(12.5)。
398
-
399
- XM-07:摘要、快照、导出副本的清理仍属部署层,见 ISSUES。
400
- """
401
- affected = self._state_keys_depending_on(trace_id)
402
-
403
- with self._tx() as c:
404
- rows = c.execute(
405
- "SELECT DISTINCT event_id FROM event_evidence WHERE trace_id=?",
406
- (trace_id,),
407
- ).fetchall()
408
- for row in rows:
409
- c.execute(
410
- "UPDATE events SET void=1, superseded_by='ERASED' WHERE event_id=?",
411
- (row["event_id"],),
412
- )
413
- for key in affected:
414
- spec = self._spec_or_none(key)
415
- if spec is not None:
416
- self._recompute(c, key, spec)
417
-
418
- c.execute("DELETE FROM traces_fts WHERE trace_id=?", (trace_id,))
419
- c.execute(
420
- "UPDATE traces SET content='', erased_at=? WHERE trace_id=?",
421
- (self.now(), trace_id),
422
- )
423
- c.execute(
424
- "UPDATE observations SET excerpt='', status='archived' WHERE trace_id=?",
425
- (trace_id,),
426
- )
427
-
428
- # 依赖传播要另开事务(recompute_derived 自己管事务)
429
- for key in affected:
430
- self.propagate(key)
431
-
432
- return {
433
- "trace_id": trace_id,
434
- "state_keys": sorted(affected),
435
- "voided_events": len(rows),
436
- }
437
-
438
- def _state_keys_depending_on(self, trace_id: str) -> list[str]:
439
- rows = self._conn().execute(
440
- "SELECT DISTINCT e.state_key FROM event_evidence ev"
441
- " JOIN events e ON e.event_id = ev.event_id WHERE ev.trace_id=?",
442
- (trace_id,),
443
- ).fetchall()
444
- return [r["state_key"] for r in rows]
445
-
446
- def _spec_or_none(self, state_key: str) -> RelationSpec | None:
447
- try:
448
- return self.registry.get(relation_of(state_key))
449
- except SpecNotFound:
450
- return None
451
-
452
- # ======================================================== 提交
453
- def commit(self, a: Assertion) -> CommitResult:
454
- spec = self._resolve_spec(a)
455
- self._validate_assertion(a, spec)
456
- with self._tx() as c:
457
- return self._commit_in_tx(c, a, spec)
458
-
459
- def commit_batch(self, assertions: Sequence[Assertion]) -> list[CommitResult]:
460
- """原子事件组:要么全部提交,要么全部回滚(设计文档 9.5)。
461
-
462
- 组内各事件共享 operation_group_id;调用方应保证它们属于同一业务操作。
463
- """
464
- if not assertions:
465
- return []
466
- results: list[CommitResult] = []
467
- with self._tx() as c:
468
- for a in assertions:
469
- spec = self._resolve_spec(a)
470
- # 组内前序事件会改变版本,因此组内不强制 expected_version
471
- a.expected_version = None
472
- self._validate_assertion(a, spec)
473
- results.append(self._commit_in_tx(c, a, spec))
474
- return results
475
-
476
- def _commit_in_tx(
477
- self, c: sqlite3.Connection, a: Assertion, spec: RelationSpec
478
- ) -> CommitResult:
479
- if c.execute("SELECT 1 FROM scopes WHERE scope_id=?", (a.scope_id,)).fetchone() is None:
480
- raise ScopeNotFound(a.scope_id)
481
-
482
- payload_hash = digest(canonical_json(a.payload))
483
- prior = c.execute(
484
- "SELECT * FROM events WHERE scope_id=? AND idempotency_key=?",
485
- (a.scope_id, a.idempotency_key),
486
- ).fetchone()
487
-
488
- # ---- 幂等:同键同载荷返回原结果;同键异载荷拒绝
489
- if prior is not None:
490
- if prior["payload_hash"] != payload_hash:
491
- raise IdempotencyConflict(a.idempotency_key, prior["event_id"])
492
- return CommitResult(
493
- event_id=prior["event_id"], state_key=prior["state_key"],
494
- sequence_no=prior["sequence_no"], applied=False,
495
- state=self._read_state(c, prior["state_key"]),
496
- )
497
-
498
- # ---- 证据必须可解析(I1)
499
- self._assert_evidence_usable(c, a.evidence_trace_ids)
500
-
501
- # ---- 乐观锁:version 为有效事件数,重算后稳定
502
- current = self._read_state(c, a.state_key)
503
- actual_version = current.version if current else 0
504
- if a.expected_version is not None and a.expected_version != actual_version:
505
- raise VersionConflict(a.state_key, a.expected_version, actual_version)
506
-
507
- # ---- 写入事件
508
- seq = c.execute(
509
- "SELECT COALESCE(MAX(sequence_no), 0) + 1 FROM events WHERE state_key=?",
510
- (a.state_key,),
511
- ).fetchone()[0]
512
- recorded_at = a.recorded_at or self.now()
513
- effective_at = a.effective_at or recorded_at
514
- event_id = "ev_" + digest(a.scope_id, a.state_key, a.idempotency_key)[:24]
515
-
516
- c.execute(
517
- "INSERT INTO events(event_id, scope_id, state_key, idempotency_key, payload_hash,"
518
- " operation_group_id, operation, effect_kind, payload_json, gate_decision_id,"
519
- " actor_id, effective_at, recorded_at, sequence_no, spec_version, target_ref,"
520
- " pattern, void)"
521
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
522
- (
523
- event_id, a.scope_id, a.state_key, a.idempotency_key, payload_hash,
524
- a.operation_group_id, a.operation, a.effect_kind,
525
- canonical_json(a.payload), a.gate_decision_id,
526
- a.actor_id, effective_at, recorded_at,
527
- seq, spec.spec_version, a.target_ref, a.pattern or None,
528
- ),
529
- )
530
- for trace_id in a.evidence_trace_ids:
531
- c.execute(
532
- "INSERT INTO event_evidence(event_id, trace_id) VALUES (?,?)",
533
- (event_id, trace_id),
534
- )
535
-
536
- # ---- 投影:全量重算该状态键(设计文档 9.6 的简单基点)
537
- state = self._recompute(c, a.state_key, spec)
538
- return CommitResult(
539
- event_id=event_id, state_key=a.state_key,
540
- sequence_no=seq, applied=True, state=state,
541
- )
542
-
543
- # -------------------------------------------------------- 提交辅助
544
- def _resolve_spec(self, a: Assertion) -> RelationSpec:
545
- spec = self.registry.get(a.relation)
546
- if a.spec_version and a.spec_version != spec.spec_version:
547
- raise SpecNotFound(
548
- f"assertion pins {a.spec_version!r} but registry has {spec.spec_version!r}"
549
- )
550
- return spec
551
-
552
- def _validate_assertion(self, a: Assertion, spec: RelationSpec) -> None:
553
- if not a.evidence_trace_ids:
554
- raise EvidenceMissing(f"{a.state_key}: no evidence_trace_ids")
555
- if a.effect_kind not in ("set", "delta", "invalidate", "none"):
556
- raise ValueError(f"bad effect_kind {a.effect_kind!r}")
557
- if a.effect_kind == "delta" and "delta" not in a.payload:
558
- raise ValueError("delta effect requires payload['delta']")
559
- if a.effect_kind == "none":
560
- # 无效果事件只有一种合法形状:承诺。写成别的说明上游搞错了,
561
- # 而"不知道是什么的无效果事件"会让状态机永远算不清。
562
- if a.payload.get("kind") != "promise":
563
- raise ValueError("effect_kind='none' requires payload['kind']='promise'")
564
- if not isinstance(a.payload.get("quantity"), (int, float)):
565
- raise ValueError("promise requires numeric payload['quantity']")
566
- if a.effect_kind == "set" and "value" not in a.payload:
567
- raise ValueError("set effect requires payload['value']")
568
-
569
- def _assert_evidence_usable(
570
- self, c: sqlite3.Connection, trace_ids: Sequence[str]
571
- ) -> None:
572
- for tid in trace_ids:
573
- row = c.execute(
574
- "SELECT erased_at FROM traces WHERE trace_id=?", (tid,)
575
- ).fetchone()
576
- if row is None:
577
- raise TraceNotFound(tid)
578
- if row["erased_at"]:
579
- raise TraceErased(tid)
580
-
581
- # ======================================================== 纠正 / 撤销
582
- def correct(
583
- self,
584
- target_event_id: str,
585
- new_payload: dict[str, Any],
586
- idempotency_key: str,
587
- evidence_trace_ids: tuple[str, ...],
588
- actor_id: str,
589
- effective_at: str | None = None,
590
- ) -> CommitResult:
591
- """纠正一条事件:新增替代事件,旧事件置为 void,然后重算。
592
-
593
- 旧事件保留在流中(审计可查),但不参与求值。
594
-
595
- XM-01: 不检查「修正后是否与更早的偿付矛盾」。这条要引入「累计量」概念,
596
- 当前只有余额,判不出来。调用方需自行校验后决定是否调用。
597
- """
598
- # 读目标和写替代必须在**同一个事务**里。
599
- # 早先是事务外读、事务内写:两个线程可以同时看到「目标未失效」,
600
- # 各自写一条替代事件,`superseded_by` 被后一个覆盖,
601
- # 前一条替代事件就成了无主孤儿——账是对的,链是断的。
602
- with self._tx() as c:
603
- target = self._get_event_in(c, target_event_id)
604
- if target is None:
605
- raise TraceNotFound(f"event {target_event_id} not found")
606
- if target["void"]:
607
- raise EventAlreadyVoid(target_event_id)
608
-
609
- a = Assertion(
610
- scope_id=target["scope_id"],
611
- state_key=target["state_key"],
612
- relation=relation_of(target["state_key"]),
613
- operation="append",
614
- effect_kind=target["effect_kind"],
615
- payload=new_payload,
616
- idempotency_key=idempotency_key,
617
- evidence_trace_ids=evidence_trace_ids,
618
- actor_id=actor_id,
619
- effective_at=effective_at or target["effective_at"],
620
- target_ref=target_event_id,
621
- )
622
- spec = self._resolve_spec(a)
623
- self._validate_assertion(a, spec)
624
- self._assert_evidence_usable(c, a.evidence_trace_ids)
625
-
626
- result = self._commit_in_tx(c, a, spec)
627
- if result.applied:
628
- # `AND void=0` 是第二道闸:即使前面所有检查都过了,
629
- # 条件更新也能保证「只有一个人能替代它」。
630
- cur = c.execute(
631
- "UPDATE events SET void=1, superseded_by=? WHERE event_id=? AND void=0",
632
- (result.event_id, target_event_id),
633
- )
634
- if cur.rowcount != 1:
635
- raise EventAlreadyVoid(target_event_id)
636
- result.state = self._recompute(c, a.state_key, spec)
637
- return result
638
-
639
- def retract_event(
640
- self,
641
- target_event_id: str,
642
- idempotency_key: str,
643
- evidence_trace_ids: tuple[str, ...],
644
- actor_id: str,
645
- ) -> StateView:
646
- """撤销一条事件:写入 tombstone,旧事件置为 void,然后重算。
647
-
648
- 不追加反向 delta——那会在存在后续依赖时算错(设计文档 9.3)。
649
- """
650
- # 同 correct:读目标和写 tombstone 必须在同一个事务里。
651
- with self._tx() as c:
652
- target = self._get_event_in(c, target_event_id)
653
- if target is None:
654
- raise TraceNotFound(f"event {target_event_id} not found")
655
- if target["void"]:
656
- raise EventAlreadyVoid(target_event_id)
657
-
658
- spec = self.registry.get(relation_of(target["state_key"]))
659
- self._assert_evidence_usable(c, evidence_trace_ids)
660
- seq = c.execute(
661
- "SELECT COALESCE(MAX(sequence_no), 0) + 1 FROM events WHERE state_key=?",
662
- (target["state_key"],),
663
- ).fetchone()[0]
664
- tomb = "ev_" + digest(target["scope_id"], target["state_key"], idempotency_key)[:24]
665
- now = self.now()
666
- c.execute(
667
- "INSERT INTO events(event_id, scope_id, state_key, idempotency_key, payload_hash,"
668
- " operation, effect_kind, payload_json, actor_id, effective_at, recorded_at,"
669
- " sequence_no, spec_version, target_ref, void)"
670
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)",
671
- (
672
- tomb, target["scope_id"], target["state_key"], idempotency_key,
673
- digest(canonical_json({"retracted_event": target_event_id})),
674
- "retract", "invalidate",
675
- canonical_json({"retracted_event": target_event_id}),
676
- actor_id, target["effective_at"], now,
677
- seq, spec.spec_version, target_event_id,
678
- ),
679
- )
680
- for tid in evidence_trace_ids:
681
- c.execute(
682
- "INSERT INTO event_evidence(event_id, trace_id) VALUES (?,?)", (tomb, tid)
683
- )
684
- cur = c.execute(
685
- "UPDATE events SET void=1, superseded_by=? WHERE event_id=? AND void=0",
686
- (tomb, target_event_id),
687
- )
688
- if cur.rowcount != 1:
689
- raise EventAlreadyVoid(target_event_id)
690
- return self._recompute(c, target["state_key"], spec)
691
-
692
- def _get_event_in(self, c: sqlite3.Connection, event_id: str) -> dict[str, Any] | None:
693
- """在指定事务里读事件。读-判-写必须共用同一个连接,否则又是 TOCTOU。"""
694
- row = c.execute("SELECT * FROM events WHERE event_id=?", (event_id,)).fetchone()
695
- return dict(row) if row else None
696
-
697
- def _get_event(self, event_id: str) -> dict[str, Any] | None:
698
- row = self._conn().execute(
699
- "SELECT * FROM events WHERE event_id=?", (event_id,)
700
- ).fetchone()
701
- return dict(row) if row else None
702
-
703
- # ======================================================== 求值
704
- def _fold(
705
- self,
706
- c: sqlite3.Connection,
707
- state_key: str,
708
- spec: RelationSpec,
709
- as_of: str | None = None,
710
- knowledge_as_of: str | None = None,
711
- ) -> StateView:
712
- """按 (effective_at, sequence_no) 重放一条状态流。
713
-
714
- 两种时间:
715
- as_of 业务时间上限(effective_at <= as_of)
716
- knowledge_as_of 知识时间上限(recorded_at <= as_of)
717
- 两者组合即"当时系统认为的状态"。
718
- """
719
- # 排序键:`(生效时间, 它取代的那条事件的序号, 自己的序号)`。
720
- #
721
- # 第三个键不是多余的。修正事件的排序如果只看自己的序号,
722
- # 它会永远排在**最后**——于是「改初始值」被排到两笔还款之后,
723
- # set 又把它们抹掉了,余额从 20 变成 50,而事件流看起来完全正常。
724
- #
725
- # 时间戳精确到微秒时这个问题碰不到(修正事件带着原事件的
726
- # effective_at,会比后面的还款更早)。但时钟一旦变粗
727
- # (固定时钟、批量导入、秒级时间戳),三条事件时间相同,
728
- # 就只能靠序号——这时"取代"语义必须显式写出来。
729
- sql = (
730
- "SELECT e.*, COALESCE(t.sequence_no, e.sequence_no) AS sort_seq"
731
- " FROM events e"
732
- " LEFT JOIN events t ON t.event_id = e.target_ref AND e.target_ref IS NOT NULL"
733
- " WHERE e.state_key=? AND e.void=0"
734
- )
735
- params: list[Any] = [state_key]
736
- if as_of:
737
- sql += " AND e.effective_at <= ?"
738
- params.append(as_of)
739
- if knowledge_as_of:
740
- sql += " AND e.recorded_at <= ?"
741
- params.append(knowledge_as_of)
742
- sql += " ORDER BY e.effective_at, sort_seq, e.sequence_no"
743
- rows = c.execute(sql, params).fetchall()
744
-
745
- # watermark 用全部事件(含 void)的最大序号,表示"投影到了这里"
746
- wm_row = c.execute(
747
- "SELECT COALESCE(MAX(sequence_no), 0) FROM events WHERE state_key=?", (state_key,)
748
- ).fetchone()
749
- watermark = wm_row[0]
750
-
751
- state: StateView | None = None
752
- totals: dict = {}
753
- count = 0
754
- # 这本账用哪个单位记的。单位不是必填,也不是规格里定死的
755
- # (米按袋、按石、按斗都有人用),但**一本账只能有一个口径**。
756
- unit: str | None = None
757
- for row in rows:
758
- payload = json.loads(row["payload_json"])
759
- fake = Assertion(
760
- scope_id=row["scope_id"], state_key=state_key,
761
- relation=spec.relation, operation=row["operation"],
762
- effect_kind=row["effect_kind"],
763
- payload=payload,
764
- idempotency_key=row["idempotency_key"],
765
- evidence_trace_ids=(), actor_id=row["actor_id"],
766
- )
767
- change = apply_event(state, fake, spec, totals)
768
- count += 1
769
-
770
- # 单位变了:两笔账不是同一个计量口径,不能直接加减。
771
- #
772
- # 实测:「欠两石米」+「再欠三斗」−「还五斗」算出来是 0,状态 known——
773
- # 2+3−5。而真实答案是 2 石 − 2 斗 = 18 斗。**账面看不出任何异常。**
774
- #
775
- # 内核不做换算:石等于十斗是领域知识,写死在核心里就是把某地的度量
776
- # 习惯当成普适真理(各朝各代一石并不一样)。但内核也不能装作没看见。
777
- # 所以:**判 disputed、值停在上一次**,等有人来定口径。
778
- this_unit = payload.get("unit")
779
-
780
- # 一旦因为单位卡住,后面的事件都别再改写状态——
781
- # 否则「还你五斗」会把它盖成 non_negative_violated,
782
- # 真正的原因(单位不同)就丢了。**账卡住的原因要稳定。**
783
- if (state is not None and state.status == "disputed"
784
- and (state.conflict_reason or "").startswith("unit_mismatch")):
785
- change = StateChange(
786
- status="disputed", value=state.value, last_known=state.value,
787
- conflict_reason=state.conflict_reason,
788
- )
789
- elif this_unit and unit and this_unit != unit and change.status in (
790
- "known", "stale"
791
- ):
792
- keep = state.value if state is not None else None
793
- change = StateChange(
794
- status="disputed",
795
- value=keep,
796
- last_known=keep,
797
- conflict_reason=f"unit_mismatch:{unit}!={this_unit}",
798
- )
799
- elif this_unit and not unit:
800
- unit = this_unit
801
-
802
- state = StateView(
803
- state_key=state_key, status=change.status, value=change.value,
804
- last_known=change.last_known, version=count, watermark=watermark,
805
- conflict_reason=change.conflict_reason,
806
- spec_version=row["spec_version"],
807
- )
808
-
809
- if state is None:
810
- return StateView(
811
- state_key=state_key, status="unknown", value=None, last_known=None,
812
- version=0, watermark=watermark, conflict_reason=None,
813
- spec_version=spec.spec_version,
814
- )
815
- return state
816
-
817
- def _recompute(self, c: sqlite3.Connection, state_key: str, spec: RelationSpec) -> StateView:
818
- # XM-04: 全量重放,不是文档 9.4 的「受影响后缀 + 依赖闭包」。
819
- # 改成后缀时 states.watermark 的语义会从「最大序号」变成「快照点」,
820
- # 依赖它做增量同步的调用方会静默出错。
821
- state = self._fold(c, state_key, spec)
822
- state = self._flag_delta_before_first_set(c, state_key, state)
823
- self._write_state(c, state, spec.spec_version)
824
- # 承诺投影挂在**唯一的重算入口**上,而不是各条写路径各调一次。
825
- # 之前只在 append 里调,结果 retract / correct 走的是另一条路——
826
- # 撤销一笔还款之后,承诺还写着 kept(测试当场抓到)。
827
- self._refresh_commitments(c, state_key)
828
- return state
829
-
830
- # -------------------------------------------------------- 承诺投影
831
- def _refresh_commitments(self, c: sqlite3.Connection, state_key: str) -> None:
832
- """从事件流重算某个状态键下的承诺。**全量,不做增量。**
833
-
834
- 规则只有两条,但必须按时间顺序一起跑,否则会错:
835
-
836
- 承诺事件 → 开一条 open,remaining = quantity
837
- 还款事件 → 从最早的 open 开始扣,扣完标 kept
838
-
839
- 为什么是"最早优先"(FIFO):两笔承诺「下月还两袋」「月底还一袋」,
840
- 今天还了一袋——没有任何信息能区分他先还的是哪一笔,
841
- 而 FIFO 至少是**可解释**的:先答应的先兑现。
842
-
843
- 撤销会自动复员:void=1 的事件直接不参与重算,
844
- 所以撤回一次还款之后,被它结清的承诺会自己回到 open。
845
- 这是选"全量重算"而不是"增量维护"换来的。
846
- """
847
- rows = c.execute(
848
- "SELECT event_id, scope_id, operation, effect_kind, payload_json, actor_id,"
849
- " effective_at, recorded_at, sequence_no, void, target_ref"
850
- " FROM events WHERE state_key=? AND void=0"
851
- " ORDER BY effective_at, sequence_no",
852
- (state_key,),
853
- ).fetchall()
854
-
855
- c.execute("DELETE FROM commitments WHERE state_key=?", (state_key,))
856
- open_items: list[dict[str, Any]] = []
857
- now = self.now()
858
-
859
- for row in rows:
860
- payload = json.loads(row["payload_json"] or "{}")
861
- ek = row["effect_kind"]
862
- if ek == "none" and payload.get("kind") == "promise":
863
- qty = payload.get("quantity")
864
- if not isinstance(qty, (int, float)):
865
- continue
866
- open_items.append({
867
- "commitment_id": "cm_" + row["event_id"][3:],
868
- "scope_id": row["scope_id"],
869
- "state_key": state_key,
870
- "event_id": row["event_id"],
871
- "actor_id": row["actor_id"],
872
- "quantity": float(qty),
873
- "remaining": float(qty),
874
- "unit": payload.get("unit"),
875
- "due_hint": payload.get("due_hint"),
876
- "due_at": payload.get("due_at"),
877
- "status": "open",
878
- "settled_by": None,
879
- "created_at": row["recorded_at"],
880
- "updated_at": now,
881
- })
882
- continue
883
-
884
- if ek != "delta":
885
- continue
886
- d = payload.get("delta")
887
- if not isinstance(d, (int, float)) or d >= 0:
888
- continue
889
- left = float(-d)
890
- for item in open_items:
891
- if left <= 0:
892
- break
893
- if item["remaining"] <= 0:
894
- continue
895
- take = min(item["remaining"], left)
896
- item["remaining"] -= take
897
- left -= take
898
- if item["remaining"] <= 0:
899
- item["status"] = "kept"
900
- item["settled_by"] = row["event_id"]
901
-
902
- for item in open_items:
903
- if item["status"] == "open" and item["remaining"] < item["quantity"]:
904
- item["status"] = "partial"
905
- c.execute(
906
- "INSERT INTO commitments(commitment_id, scope_id, state_key, event_id,"
907
- " actor_id, quantity, remaining, unit, due_hint, due_at, status,"
908
- " settled_by, created_at, updated_at)"
909
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
910
- (
911
- item["commitment_id"], item["scope_id"], item["state_key"],
912
- item["event_id"], item["actor_id"], item["quantity"],
913
- item["remaining"], item["unit"], item["due_hint"], item["due_at"],
914
- item["status"], item["settled_by"], item["created_at"],
915
- item["updated_at"],
916
- ),
917
- )
918
-
919
- def commitments(
920
- self, scope_id: str, state_key: str | None = None, status: str | None = None
921
- ) -> list[dict[str, Any]]:
922
- """列出承诺。默认全给,调用方自己筛。"""
923
- sql = "SELECT * FROM commitments WHERE scope_id=?"
924
- params: list[Any] = [scope_id]
925
- if state_key:
926
- sql += " AND state_key=?"
927
- params.append(state_key)
928
- if status:
929
- sql += " AND status=?"
930
- params.append(status)
931
- sql += " ORDER BY created_at, commitment_id"
932
- return [dict(r) for r in self._conn().execute(sql, params).fetchall()]
933
-
934
- def overdue(
935
- self, scope_id: str, state_key: str | None = None, now: str | None = None
936
- ) -> list[dict[str, Any]]:
937
- """到期没还的承诺,最早的在前。
938
-
939
- `due_at` 为 NULL 的("回头""发工资")永远不进这个列表——
940
- 没有约定日子的承诺不该被催。这不是遗漏,是 XM-24 的边界。
941
- """
942
- at = now or self.now()
943
- sql = (
944
- "SELECT * FROM commitments WHERE scope_id=?"
945
- " AND status IN ('open','partial') AND due_at IS NOT NULL AND due_at <= ?"
946
- )
947
- params: list[Any] = [scope_id, at]
948
- if state_key:
949
- sql += " AND state_key=?"
950
- params.append(state_key)
951
- sql += " ORDER BY due_at, created_at"
952
- return [dict(r) for r in self._conn().execute(sql, params).fetchall()]
953
-
954
- def rebuild_commitments(self, scope_id: str) -> int:
955
- """从事件流重建承诺投影。返回处理的状态键数。
956
-
957
- 存在的意义不是"修数据",是**证明这张表可以随时扔掉**。
958
- 只要它和事件流不一致,就是这个投影写坏了,而不是数据坏了。
959
- """
960
- keys = [
961
- r[0] for r in self._conn().execute(
962
- "SELECT DISTINCT state_key FROM events WHERE scope_id=?", (scope_id,)
963
- ).fetchall()
964
- ]
965
- with self._tx() as c:
966
- for key in keys:
967
- self._refresh_commitments(c, key)
968
- return len(keys)
969
-
970
- def _flag_delta_before_first_set(
971
- self, c: sqlite3.Connection, state_key: str, state: StateView
972
- ) -> StateView:
973
- """首个 set 之前的 delta:没有基数,永远不可能成立。
974
-
975
- 它会被后来的 set 覆盖,最终状态看起来"正常"——但那条记录
976
- 其实从未生效过。不标记的话,审计里完全看不出它是个错误。
977
-
978
- XM-09:这个检查放在重算的**最后**,因为要等整条流都算完才知道
979
- 谁是"首个 set"。放在求值过程中只能看到局部,会把
980
- "未来会有 set"的合法情况误判成错误。
981
- """
982
- row = c.execute(
983
- "SELECT MIN(effective_at) AS t FROM events"
984
- " WHERE state_key=? AND effect_kind='set' AND void=0",
985
- (state_key,),
986
- ).fetchone()
987
- if row is None or row["t"] is None:
988
- # 整条流没有 set:delta 是「从 0 开始」,合法(+5 → 5)。
989
- # 减量仍然会被非负约束拦住,不需要这里再判。
990
- return state
991
-
992
- n = c.execute(
993
- "SELECT COUNT(*) AS n FROM events WHERE state_key=? AND effect_kind='delta'"
994
- " AND void=0 AND effective_at < ?",
995
- (state_key, row["t"]),
996
- ).fetchone()["n"]
997
- if not n:
998
- return state
999
-
1000
- # 已经是 disputed 的保留原原因(那个更具体)。
1001
- if state.status in ("known", "stale"):
1002
- return replace(state, status="disputed",
1003
- conflict_reason="delta_before_first_set")
1004
- return state
1005
-
1006
- def _read_state(self, c: sqlite3.Connection, state_key: str) -> StateView:
1007
- row = c.execute("SELECT * FROM states WHERE state_key=?", (state_key,)).fetchone()
1008
- if row is None:
1009
- return StateView(
1010
- state_key=state_key, status="unknown", value=None, last_known=None,
1011
- version=0, watermark=0, conflict_reason=None, spec_version="",
1012
- )
1013
- return StateView(
1014
- state_key=row["state_key"], status=row["status"],
1015
- value=json.loads(row["value_json"]) if row["value_json"] else None,
1016
- last_known=json.loads(row["last_known_json"]) if row["last_known_json"] else None,
1017
- version=row["version"], watermark=row["watermark"],
1018
- conflict_reason=row["conflict_reason"], spec_version=row["spec_version"],
1019
- )
1020
-
1021
- def _write_state(self, c: sqlite3.Connection, s: StateView, spec_version: str) -> None:
1022
- c.execute(
1023
- "INSERT INTO states(state_key, scope_id, value_json, status, last_known_json,"
1024
- " conflict_reason, version, watermark, evaluated_as_of, spec_version)"
1025
- " VALUES (?,?,?,?,?,?,?,?,?,?)"
1026
- " ON CONFLICT(state_key) DO UPDATE SET"
1027
- " value_json=excluded.value_json,"
1028
- " status=excluded.status,"
1029
- " last_known_json=excluded.last_known_json,"
1030
- " conflict_reason=excluded.conflict_reason,"
1031
- " version=excluded.version,"
1032
- " watermark=excluded.watermark,"
1033
- " evaluated_as_of=excluded.evaluated_as_of,"
1034
- " spec_version=excluded.spec_version",
1035
- (
1036
- s.state_key, s.state_key.split("|")[0],
1037
- canonical_json(s.value) if s.value is not None else None,
1038
- s.status,
1039
- canonical_json(s.last_known) if s.last_known is not None else None,
1040
- s.conflict_reason, s.version, s.watermark, self.now(), spec_version,
1041
- ),
1042
- )
1043
-
1044
- # ======================================================== 读取
1045
- # XM-06: 只有同步投影。加异步后,这里要能区分「读投影」与「等投影」
1046
- # (read-your-writes),属于接口变更。
1047
- def get_state(
1048
- self,
1049
- state_key: str,
1050
- as_of: str | None = None,
1051
- knowledge_as_of: str | None = None,
1052
- ) -> StateView:
1053
- """无参数读当前投影;带时间参数则按事件流重放(不写回)。"""
1054
- if as_of is None and knowledge_as_of is None:
1055
- return self._read_state(self._conn(), state_key)
1056
-
1057
- spec = self.registry.get(relation_of(state_key))
1058
- return self._fold(self._conn(), state_key, spec, as_of, knowledge_as_of)
1059
-
1060
- def events_of(self, state_key: str, include_void: bool = True) -> list[dict[str, Any]]:
1061
- sql = "SELECT * FROM events WHERE state_key=?"
1062
- if not include_void:
1063
- sql += " AND void=0"
1064
- sql += " ORDER BY effective_at, sequence_no"
1065
- rows = self._conn().execute(sql, (state_key,)).fetchall()
1066
- return [dict(r) for r in rows]
1067
-
1068
- def evidence_of(self, event_id: str) -> list[str]:
1069
- rows = self._conn().execute(
1070
- "SELECT trace_id FROM event_evidence WHERE event_id=? ORDER BY rowid", (event_id,)
1071
- ).fetchall()
1072
- return [r["trace_id"] for r in rows]
1073
-
1074
- def explain(self, state_key: str) -> dict[str, Any]:
1075
- """状态值 + 事件链 + 证据片段。用于回答"为什么是这个值"。"""
1076
- chain = []
1077
- for ev in self.events_of(state_key):
1078
- chain.append({
1079
- "event_id": ev["event_id"],
1080
- "sequence_no": ev["sequence_no"],
1081
- "operation": ev["operation"],
1082
- "effect_kind": ev["effect_kind"],
1083
- "payload": json.loads(ev["payload_json"]),
1084
- "effective_at": ev["effective_at"],
1085
- "recorded_at": ev["recorded_at"],
1086
- "void": bool(ev["void"]),
1087
- "superseded_by": ev["superseded_by"],
1088
- "evidence": [
1089
- {"trace_id": tid, "content": (self.get_trace(tid) or {}).get("content", "")}
1090
- for tid in self.evidence_of(ev["event_id"])
1091
- ],
1092
- })
1093
- return {"state": self.get_state(state_key), "chain": chain}
1094
-
1095
- def replay(
1096
- self,
1097
- state_key: str,
1098
- as_of: str | None = None,
1099
- knowledge_as_of: str | None = None,
1100
- ) -> StateView:
1101
- """从事件流重建状态。用于 T04 与重算一致性校验。"""
1102
- spec = self.registry.get(relation_of(state_key))
1103
- return self._fold(self._conn(), state_key, spec, as_of, knowledge_as_of)
1104
-
1105
- # ======================================================== 依赖图
1106
- def register_derivation(
1107
- self,
1108
- derived_key: str,
1109
- input_keys: Sequence[str],
1110
- rule_version: str,
1111
- fn: Callable[[list[StateView]], tuple[str, Any, str | None]],
1112
- ) -> None:
1113
- """登记派生状态。
1114
-
1115
- fn 接收输入状态列表,返回 (status, value, conflict_reason)。
1116
- 派生状态不保存独立证据,其依据即输入状态的依赖链(设计文档 6.9)。
1117
- """
1118
- if derived_key in self._derivations:
1119
- raise ValueError(f"derivation already registered: {derived_key}")
1120
- # 环检测:derived_key 不能出现在自己的输入闭包里
1121
- if derived_key in input_keys:
1122
- raise DependencyCycle(f"{derived_key} depends on itself")
1123
- # XM-05: fn 存在内存里,进程重启后必须重新注册。
1124
- # state_deps 只存了依赖关系,不存实现,别指望从库里恢复。
1125
- self._derivations[derived_key] = (tuple(input_keys), rule_version, fn)
1126
- with self._tx() as c:
1127
- for k in input_keys:
1128
- c.execute(
1129
- "INSERT OR REPLACE INTO state_deps(derived_key, input_key, rule_version)"
1130
- " VALUES (?,?,?)",
1131
- (derived_key, k, rule_version),
1132
- )
1133
-
1134
- def recompute_derived(self, derived_key: str) -> StateView:
1135
- if derived_key not in self._derivations:
1136
- raise KeyError(f"no derivation registered for {derived_key}")
1137
- inputs, rule_version, fn = self._derivations[derived_key]
1138
- states = [self.get_state(k) for k in inputs]
1139
- status, value, reason = fn(states)
1140
- view = StateView(
1141
- state_key=derived_key, status=status, value=value, last_known=None,
1142
- version=0, watermark=0, conflict_reason=reason,
1143
- spec_version=f"derived:{rule_version}",
1144
- )
1145
- with self._tx() as c:
1146
- self._write_state(c, view, f"derived:{rule_version}")
1147
- return view
1148
-
1149
- def propagate(self, changed_key: str) -> list[str]:
1150
- """从被改动的键出发,按拓扑序重算其下游派生状态。"""
1151
- order = self._dependent_order(changed_key)
1152
- for key in order:
1153
- self.recompute_derived(key)
1154
- return order
1155
-
1156
- def _dependent_order(self, root: str) -> list[str]:
1157
- conn = self._conn()
1158
- rows = conn.execute(
1159
- "SELECT derived_key, input_key FROM state_deps"
1160
- ).fetchall()
1161
- graph: dict[str, list[str]] = {}
1162
- for r in rows:
1163
- graph.setdefault(r["input_key"], []).append(r["derived_key"])
1164
-
1165
- seen: list[str] = []
1166
- seen_set: set[str] = set()
1167
-
1168
- def walk(node: str) -> None:
1169
- for child in graph.get(node, ()):
1170
- if child in seen_set:
1171
- continue
1172
- walk(child) # 先递归下游,保证拓扑序
1173
- seen_set.add(child)
1174
- seen.append(child)
1175
-
1176
- walk(root)
1177
- seen.reverse()
1178
- return seen
1179
-
1180
- # ======================================================== T01 检查
1181
- def check_source_closure(self, state_key: str) -> list[str]:
1182
- """来源闭合检查。返回问题列表,空表示通过。"""
1183
- problems: list[str] = []
1184
- for ev in self.events_of(state_key):
1185
- ev_id = ev["event_id"]
1186
- traces = self.evidence_of(ev_id)
1187
- if not traces:
1188
- problems.append(f"{ev_id}: no evidence")
1189
- continue
1190
- for tid in traces:
1191
- t = self.get_trace(tid)
1192
- if t is None:
1193
- problems.append(f"{ev_id}: evidence {tid} missing")
1194
- elif t["erased_at"]:
1195
- problems.append(f"{ev_id}: evidence {tid} erased")
1196
- elif t["scope_id"] != ev["scope_id"]:
1197
- problems.append(f"{ev_id}: evidence {tid} crosses scope")
1198
- return problems
1199
-
1200
-
1201
- # ============================================================ 纯函数
1202
- @dataclass(frozen=True)
1203
- class StateChange:
1204
- status: str
1205
- value: Any = None
1206
- last_known: Any = None
1207
- conflict_reason: str | None = None
1208
-
1209
-
1210
- def apply_event(
1211
- current: StateView | None,
1212
- a: Assertion,
1213
- spec: RelationSpec,
1214
- totals: dict | None = None,
1215
- ) -> StateChange:
1216
- """事件 → 新状态。
1217
-
1218
- 对 status/value 是纯函数:不依赖时钟、不访问数据库。
1219
- 唯一被就地改写的是 `totals`——它是折叠过程中的累加器,
1220
- 用来支持「累计偿付量」这类无法只看余额判断的约束(XM-01)。
1221
-
1222
- 违反业务约束时不截断、不丢弃事件,而是进入 disputed
1223
- (设计文档 7.4 conflict_policy / 8.5 第 7 条 / 14.2-H)。
1224
- """
1225
- prev_value = current.value if current else None
1226
-
1227
- # 承诺(effect_kind="none"):事件进流,状态不动。
1228
- #
1229
- # 它不是"没有变化",而是**变化发生在以后**。事件流里留着它,
1230
- # 才能回答「他答应过什么」「答应了几次」;而 states 里不体现它,
1231
- # 因为余额确实一分没动。把这两件事混在一起,就会出现
1232
- # 「说了句下个月还,余额先少了」——XM-22 就是这么来的。
1233
- if a.effect_kind == "none":
1234
- if current is None:
1235
- return StateChange(status="unknown", value=None, last_known=None)
1236
- return StateChange(
1237
- status=current.status, value=prev_value, last_known=prev_value,
1238
- )
1239
-
1240
- if a.operation == "retract" or a.effect_kind == "invalidate":
1241
- return StateChange(status="retracted", value=None, last_known=prev_value)
1242
-
1243
- if a.operation == "conflict":
1244
- return StateChange(
1245
- status="disputed", value=prev_value, last_known=prev_value,
1246
- conflict_reason=a.payload.get("reason", "explicit_conflict"),
1247
- )
1248
-
1249
- # 累计(只对 delta,且只在该关系声明了守恒量时)
1250
- if totals is not None and a.effect_kind == "delta" and spec.conservation == "repayment":
1251
- d = a.payload.get("delta", 0)
1252
- # 内核必须自己挡类型:编码器给什么都不能让它抛异常。
1253
- # 非数字的 delta 不是"没有变化",是一个明确的错误,要落成 disputed。
1254
- if not isinstance(d, (int, float)) or isinstance(d, bool):
1255
- return StateChange(
1256
- status="disputed", value=prev_value, last_known=prev_value,
1257
- conflict_reason="non_numeric_delta",
1258
- )
1259
- if d < 0:
1260
- totals["repaid"] = totals.get("repaid", 0.0) + (-d)
1261
-
1262
- if a.effect_kind == "set":
1263
- raw = a.payload.get("value")
1264
- # XM-01:修正后的初始值不能小于已偿付总额,否则「还得比欠的多」。
1265
- # 这一类矛盾只看余额发现不了——余额可能仍是正的(因为期间又有新借款)。
1266
- if (spec.conservation == "repayment" and totals is not None
1267
- and isinstance(raw, (int, float))
1268
- and totals.get("repaid", 0) > raw):
1269
- return StateChange(
1270
- status="disputed", value=prev_value, last_known=prev_value,
1271
- conflict_reason="violates_accumulated_repayment",
1272
- )
1273
- else: # delta
1274
- # XM-09(已修):delta 的 effective_at 早于本流首个 set 时,这里会
1275
- # 在空状态上求值(如 0-5=-5),随后被 set 覆盖。
1276
- # 现在由 `_recompute` 末尾的 `_flag_delta_before_first_set` 兜住:
1277
- # 值照算,但状态降级为 disputed / delta_before_first_set。
1278
- # 不在求值过程中判,因为那时还不知道"未来会不会有 set"。
1279
- d = a.payload.get("delta", 0)
1280
- if not isinstance(d, (int, float)) or isinstance(d, bool):
1281
- return StateChange(
1282
- status="disputed", value=prev_value, last_known=prev_value,
1283
- conflict_reason="non_numeric_delta",
1284
- )
1285
- base = prev_value if isinstance(prev_value, (int, float)) else 0
1286
- raw = base + d
1287
-
1288
- err = _validate_value(spec, raw)
1289
- if err:
1290
- return StateChange(
1291
- status="disputed", value=prev_value, last_known=prev_value,
1292
- conflict_reason=err,
1293
- )
1294
-
1295
- return StateChange(status="known", value=raw, last_known=prev_value)
1296
-
1297
-
1298
- def _validate_value(spec: RelationSpec, value: Any) -> str | None:
1299
- vt = spec.value_type
1300
- if vt in ("integer", "number"):
1301
- if isinstance(value, bool) or not isinstance(value, (int, float)):
1302
- return f"value_type:{vt}"
1303
- if vt == "integer" and int(value) != value:
1304
- return "value_type:integer"
1305
- if not spec.allow_negative and value < 0:
1306
- return "non_negative_violated"
1307
- return None
1308
- if vt == "string":
1309
- return None if isinstance(value, str) else "value_type:string"
1310
- if vt == "enum":
1311
- return None if value in spec.enum_values else "enum_violated"
1312
- return None
1
+ """内核:痕迹、事件、状态投影、双时间、重算、依赖传播。
2
+
3
+ 阶段 1:作用域、痕迹、幂等、投影、依赖链(T01/T03/T05)
4
+ 阶段 2:双时间、纠正撤销、按状态键重算、依赖图与原子事件组(T04/T06/T07/T08)
5
+
6
+ 本模块不调用任何模型。所有判定都是确定性函数。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sqlite3
14
+ import threading
15
+ import time
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass, replace
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Iterator, Sequence
21
+
22
+ from .errors import (
23
+ EventAlreadyVoid,
24
+ LockTimeout,
25
+ DependencyCycle,
26
+ EvidenceMissing,
27
+ IdempotencyConflict,
28
+ ScopeNotFound,
29
+ SpecNotFound,
30
+ TraceErased,
31
+ TraceNotFound,
32
+ VersionConflict,
33
+ )
34
+ from .specs import RelationSpec, SpecRegistry
35
+
36
+ SCHEMA_PATH = Path(__file__).with_name("schema.sql")
37
+
38
+
39
+ # ============================================================ 工具
40
+ def utcnow() -> str:
41
+ return datetime.now(timezone.utc).isoformat(timespec="microseconds")
42
+
43
+
44
+ def fixed_clock(start: str, step_seconds: float = 0.0):
45
+ """可推进的假时钟,用于模拟"过了 30 天"。
46
+
47
+ 没有它就没法测试遗忘:真实的 30 天不能等,而 monkeypatch 全局函数
48
+ 会让测试之间互相污染。**遗忘是这个项目最核心的行为之一,
49
+ 它必须是可复现的。**
50
+ """
51
+ from datetime import timedelta
52
+
53
+ current = datetime.fromisoformat(start)
54
+ if current.tzinfo is None:
55
+ current = current.replace(tzinfo=timezone.utc)
56
+
57
+ class Clock:
58
+ def __call__(self) -> str:
59
+ return current.isoformat(timespec="microseconds")
60
+
61
+ def advance(self, **kwargs) -> str:
62
+ nonlocal current
63
+ current = current + timedelta(**kwargs)
64
+ return self()
65
+
66
+ def set(self, when: str) -> str:
67
+ nonlocal current
68
+ current = datetime.fromisoformat(when)
69
+ if current.tzinfo is None:
70
+ current = current.replace(tzinfo=timezone.utc)
71
+ return self()
72
+
73
+ return Clock()
74
+
75
+
76
+ def canonical_json(value: Any) -> str:
77
+ """规范化序列化。I2 的比较基础:键排序、无多余空白、不转义非 ASCII。"""
78
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
79
+
80
+
81
+ def digest(*parts: str) -> str:
82
+ h = hashlib.sha256()
83
+ for p in parts:
84
+ h.update(p.encode("utf-8"))
85
+ h.update(b"\x00")
86
+ return h.hexdigest()
87
+
88
+
89
+ def make_state_key(
90
+ scope_id: str,
91
+ subject: str,
92
+ relation: str,
93
+ obj: str | None = None,
94
+ **dims: str,
95
+ ) -> str:
96
+ """状态键必须稳定、可读、可比较。业务维度按名称排序,避免顺序影响。"""
97
+ parts = [scope_id, subject, relation, obj or "-"]
98
+ for k in sorted(dims):
99
+ parts.append(f"{k}={dims[k]}")
100
+ return "|".join(parts)
101
+
102
+
103
+ def relation_of(state_key: str) -> str:
104
+ parts = state_key.split("|")
105
+ return parts[2] if len(parts) >= 3 else ""
106
+
107
+
108
+ # ============================================================ 值对象
109
+ @dataclass(frozen=True)
110
+ class StateView:
111
+ state_key: str
112
+ status: str # known / unknown / disputed / stale / retracted
113
+ value: Any
114
+ last_known: Any
115
+ version: int # 有效事件数,重算后稳定
116
+ watermark: int # 已投影到的最大 sequence_no
117
+ conflict_reason: str | None
118
+ spec_version: str
119
+
120
+ @property
121
+ def is_usable(self) -> bool:
122
+ return self.status in ("known", "stale")
123
+
124
+
125
+ @dataclass
126
+ class CommitResult:
127
+ event_id: str
128
+ state_key: str
129
+ sequence_no: int
130
+ applied: bool # False 表示命中幂等,未重复生效
131
+ state: StateView
132
+ # 这次提交**顺带作废**了几条事件。
133
+ #
134
+ # 目前只有一个来源:改初始值时,之后的余额快照会一起作废(XM-33)。
135
+ # 放在这里而不是写日志,是因为调用方需要知道"我这一步不只改了一个数"——
136
+ # 一次操作动了三条事件却不告诉调用方,就是另一种静默。
137
+ cascaded: int = 0
138
+
139
+
140
+ @dataclass
141
+ class Assertion:
142
+ """一条待提交的领域变更。阶段 1 由调用方直接构造,阶段 3 由闸门产出。"""
143
+ scope_id: str
144
+ state_key: str
145
+ relation: str
146
+ operation: str # append / update / retract / conflict
147
+ effect_kind: str # set / delta / invalidate
148
+ payload: dict[str, Any]
149
+ idempotency_key: str
150
+ evidence_trace_ids: tuple[str, ...]
151
+ actor_id: str
152
+ effective_at: str | None = None # 业务生效时间;缺省等于 recorded_at
153
+ recorded_at: str | None = None # 系统获知时间;一般由内核生成
154
+ expected_version: int | None = None
155
+ spec_version: str | None = None
156
+ gate_decision_id: str | None = None
157
+ operation_group_id: str | None = None
158
+ target_ref: str | None = None
159
+ # 这句话属于哪一类句式(准入熔断用)。空字符串 = 调用方不区分。
160
+ # 它进事件是为了**事后能追责**:一条 auto 提交被纠正时,
161
+ # 要知道该关哪个格子——没有它就只能关掉整个关系。
162
+ pattern: str = ""
163
+
164
+
165
+ # ============================================================ 引擎
166
+ class Engine:
167
+ def __init__(
168
+ self,
169
+ db_path: str | Path,
170
+ registry: SpecRegistry,
171
+ clock: Callable[[], str] | None = None,
172
+ ):
173
+ self.db_path = str(db_path)
174
+ self.registry = registry
175
+ self.registry.assert_acyclic()
176
+ # 时钟可注入。默认是真实时间;测试和"模拟过了很久"的演示传自己的。
177
+ self._clock = clock or utcnow
178
+ self._local = threading.local()
179
+ # 每个线程一条连接,但 close() 必须能关掉全部:
180
+ # 并发示例里 8 个 worker 各建一条,主线程 close() 只关自己那条,
181
+ # 剩下的在 Windows 上会让库文件删不掉(POSIX 不会,所以一直没暴露)。
182
+ self._conns: list[sqlite3.Connection] = []
183
+ self._conns_lock = threading.Lock()
184
+ self._derivations: dict[str, tuple[tuple[str, ...], str, Callable]] = {}
185
+ self._init_schema()
186
+
187
+ # -------------------------------------------------------- 连接
188
+ def _conn(self) -> sqlite3.Connection:
189
+ conn = getattr(self._local, "conn", None)
190
+ if conn is None:
191
+ conn = sqlite3.connect(
192
+ self.db_path, timeout=30.0, isolation_level=None,
193
+ check_same_thread=False,
194
+ )
195
+ conn.row_factory = sqlite3.Row
196
+ conn.execute("PRAGMA foreign_keys = ON")
197
+ # WAL 下 NORMAL 不会损坏库,只可能在断电时丢最后一个事务;
198
+ # FULL 每次提交都 fsync,是旧版慢的原因之一。
199
+ conn.execute("PRAGMA synchronous = NORMAL")
200
+ conn.execute("PRAGMA busy_timeout = 30000")
201
+ self._local.conn = conn
202
+ with self._conns_lock:
203
+ self._conns.append(conn)
204
+ return conn
205
+
206
+ def now(self) -> str:
207
+ """当前时间。全库只通过这里取时间,方便模拟"过了很久"。"""
208
+ return self._clock()
209
+
210
+ def close(self) -> None:
211
+ with self._conns_lock:
212
+ conns, self._conns = self._conns, []
213
+ for conn in conns:
214
+ try:
215
+ conn.close()
216
+ except sqlite3.Error:
217
+ # 另一个线程正在用这条连接时 close 会报错;关闭是收尾动作,
218
+ # 报错没有调用方能处理,忽略即可。
219
+ pass
220
+ self._local.conn = None
221
+
222
+ # 上下文管理不是为了好看:Windows 上还开着的库文件删不掉、改名改不掉,
223
+ # 而"异常路径忘了 close"是最常见的写法。`with Engine(...) as e:`
224
+ # 让关闭走 __exit__,异常也关。
225
+ def __enter__(self) -> "Engine":
226
+ return self
227
+
228
+ def __exit__(self, *exc: object) -> None:
229
+ self.close()
230
+
231
+ def _init_schema(self) -> None:
232
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
233
+ conn = sqlite3.connect(self.db_path, isolation_level=None)
234
+ try:
235
+ # Before the first 0.7 schema migration, keep an SQLite-consistent
236
+ # rollback copy. This backup is private and is NOT erased by forget.
237
+ tables = {r[0] for r in conn.execute(
238
+ "SELECT name FROM sqlite_master WHERE type='table'"
239
+ )}
240
+ if "traces" in tables and "memory_maintenance" not in tables:
241
+ import os
242
+ backup_path = self.db_path + ".pre-0.7.0.bak"
243
+ try:
244
+ fd = os.open(backup_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
245
+ except FileExistsError:
246
+ pass
247
+ else:
248
+ os.close(fd)
249
+ backup = sqlite3.connect(backup_path)
250
+ try:
251
+ conn.backup(backup)
252
+ finally:
253
+ backup.close()
254
+ conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
255
+ self._migrate(conn)
256
+ finally:
257
+ conn.close()
258
+
259
+ # 增量列:CREATE TABLE IF NOT EXISTS 不会给已有库加列,
260
+ # 而"用旧库跑新代码"是最常见的升级路径。每加一列就在这里登记一次。
261
+ _ADDED_COLUMNS = (
262
+ # (表, 列, 定义)
263
+ ("interpretations", "semantic_op", "TEXT"),
264
+ ("commitments", "unit", "TEXT"),
265
+ # 事件来自哪一类句式(准入/熔断用)。老库补列后为 NULL,
266
+ # 熔断只对 pattern 非空的事件生效——不让历史数据参与判定。
267
+ ("events", "pattern", "TEXT"),
268
+ # 澄清只念一次(v0.50.0)。老库补列后为 NULL,
269
+ # 那批历史请求会被当成"还没问过"——最多多说一次,不会丢东西。
270
+ ("clarifications", "asked_at", "TEXT"),
271
+ # v0.52:派生标签记住自己对应哪个槽位(偏好对象 / 称呼),
272
+ # 用户改口时才能精确把旧值标成 corrected,不用整表扫描。
273
+ ("person_tags", "slot", "TEXT"),
274
+ # v0.52:澄清是在哪个会话里念过的。记忆跨会话共享之后,
275
+ # 「一个会话最多问几次」必须按会话算,否则问满 6 次就终身不再问。
276
+ ("clarifications", "asked_session", "TEXT"),
277
+ )
278
+
279
+ def _migrate(self, conn: sqlite3.Connection) -> None:
280
+ for table, column, decl in self._ADDED_COLUMNS:
281
+ # 这里的连接没有 row_factory,PRAGMA 返回的是元组,name 在第 1 列
282
+ cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}
283
+ if column not in cols:
284
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
285
+
286
+ @contextmanager
287
+ def _tx(self, retries: int = 1) -> Iterator[sqlite3.Connection]:
288
+ """BEGIN IMMEDIATE:写事务立即取锁,避免升级锁时的死锁。
289
+
290
+ **可重入**:已经在事务里时用 SAVEPOINT。此前嵌套会抛
291
+ `cannot start a transaction within a transaction`——那是一颗地雷,
292
+ 因为"某个内部方法顺手开了个事务"是很自然的重构,一改就炸,
293
+ 而且错误信息指向 SQL 层,看不出是设计约束。
294
+
295
+ **锁超时转领域异常**:`busy_timeout` 用完时 SQLite 抛裸的
296
+ `OperationalError: database is locked`。调用方拿不到任何信息,
297
+ 只能当致命错误处理——而它其实重试就可能成功。
298
+
299
+ 重试是安全的:`commit` 本身幂等(`idempotency_key`),
300
+ 重跑一次事务不会产生第二次副作用。
301
+ """
302
+ conn = self._conn()
303
+ depth = getattr(self._local, "tx_depth", 0)
304
+ savepoint = f"sp_{depth}" if depth else None
305
+
306
+ last: Exception | None = None
307
+ for attempt in range(retries + 1):
308
+ try:
309
+ if depth:
310
+ conn.execute(f"SAVEPOINT {savepoint}")
311
+ else:
312
+ conn.execute("BEGIN IMMEDIATE")
313
+ break
314
+ except sqlite3.OperationalError as exc:
315
+ if "locked" not in str(exc) or attempt == retries:
316
+ raise LockTimeout(str(exc)) from exc
317
+ last = exc
318
+ time.sleep(0.05 * (2 ** attempt))
319
+ else: # pragma: no cover
320
+ raise LockTimeout(str(last))
321
+
322
+ self._local.tx_depth = depth + 1
323
+ try:
324
+ yield conn
325
+ except BaseException:
326
+ if depth:
327
+ conn.execute(f"ROLLBACK TO {savepoint}")
328
+ conn.execute(f"RELEASE {savepoint}")
329
+ else:
330
+ conn.execute("ROLLBACK")
331
+ raise
332
+ else:
333
+ if depth:
334
+ conn.execute(f"RELEASE {savepoint}")
335
+ else:
336
+ conn.execute("COMMIT")
337
+ finally:
338
+ self._local.tx_depth = depth
339
+
340
+ # ======================================================== 作用域
341
+ def ensure_scope(self, tenant_id: str, agent_id: str, world_id: str) -> str:
342
+ scope_id = "sc_" + digest(tenant_id, agent_id, world_id)[:24]
343
+ with self._tx() as c:
344
+ c.execute(
345
+ "INSERT OR IGNORE INTO scopes(scope_id, tenant_id, agent_id, world_id, created_at)"
346
+ " VALUES (?,?,?,?,?)",
347
+ (scope_id, tenant_id, agent_id, world_id, self.now()),
348
+ )
349
+ return scope_id
350
+
351
+ def get_scope(self, scope_id: str) -> dict[str, str]:
352
+ row = self._conn().execute(
353
+ "SELECT * FROM scopes WHERE scope_id = ?", (scope_id,)
354
+ ).fetchone()
355
+ if row is None:
356
+ raise ScopeNotFound(scope_id)
357
+ return dict(row)
358
+
359
+ # ======================================================== 痕迹
360
+ def record_trace(
361
+ self,
362
+ scope_id: str,
363
+ source_ref: str,
364
+ actor_id: str,
365
+ source_kind: str,
366
+ content: str,
367
+ source_time: str | None = None,
368
+ source_time_precision: str | None = None,
369
+ context_ref: str | None = None,
370
+ ) -> str:
371
+ """写入一条不可变痕迹。(scope_id, source_ref) 唯一:重试返回同一 trace_id。"""
372
+ self.get_scope(scope_id)
373
+ trace_id = "tr_" + digest(scope_id, source_ref)[:24]
374
+ with self._tx() as c:
375
+ existing = c.execute(
376
+ "SELECT trace_id, content_hash FROM traces WHERE scope_id=? AND source_ref=?",
377
+ (scope_id, source_ref),
378
+ ).fetchone()
379
+ if existing:
380
+ if existing["content_hash"] != digest("content", content):
381
+ raise IdempotencyConflict(source_ref, existing["trace_id"])
382
+ return existing["trace_id"]
383
+
384
+ c.execute(
385
+ "INSERT INTO traces(trace_id, scope_id, source_ref, actor_id, source_kind,"
386
+ " content, content_hash, recorded_at, source_time, source_time_precision, context_ref)"
387
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?)",
388
+ (
389
+ trace_id, scope_id, source_ref, actor_id, source_kind,
390
+ content, digest("content", content), self.now(),
391
+ source_time, source_time_precision, context_ref,
392
+ ),
393
+ )
394
+ c.execute(
395
+ "INSERT INTO traces_fts(trace_id, content) VALUES (?,?)",
396
+ (trace_id, content),
397
+ )
398
+ return trace_id
399
+
400
+ def get_trace(self, trace_id: str) -> dict[str, Any] | None:
401
+ row = self._conn().execute(
402
+ "SELECT * FROM traces WHERE trace_id = ?", (trace_id,)
403
+ ).fetchone()
404
+ return dict(row) if row else None
405
+
406
+ def erase_trace(self, trace_id: str) -> dict[str, Any]:
407
+ """显式删除:连同依赖它的事件一起失效,然后重算受影响状态。
408
+
409
+ 流程(12.4):
410
+ 1. 找出引用该痕迹的事件(读,事务外)
411
+ 2. 事务内:事件置 void、状态重算、索引与摘录清除、原文清空
412
+ 3. 事务外:沿依赖图传播派生状态
413
+
414
+ **不追加反向事件**:删除不是"撤销",是"依据没了"。
415
+ 依据没了的状态必须转 unknown,不能靠一条补偿事件把余额算回去——
416
+ 那等于伪造替代证据(12.5)。
417
+
418
+ XM-07:摘要、快照、导出副本的清理仍属部署层,见 ISSUES。
419
+ """
420
+ affected = self._state_keys_depending_on(trace_id)
421
+
422
+ with self._tx() as c:
423
+ rows = c.execute(
424
+ "SELECT DISTINCT event_id FROM event_evidence WHERE trace_id=?",
425
+ (trace_id,),
426
+ ).fetchall()
427
+ for row in rows:
428
+ c.execute(
429
+ "UPDATE events SET void=1, superseded_by='ERASED' WHERE event_id=?",
430
+ (row["event_id"],),
431
+ )
432
+ for key in affected:
433
+ spec = self._spec_or_none(key)
434
+ if spec is not None:
435
+ self._recompute(c, key, spec)
436
+
437
+ c.execute("DELETE FROM traces_fts WHERE trace_id=?", (trace_id,))
438
+ c.execute(
439
+ "UPDATE traces SET content='', context_ref=NULL, erased_at=? WHERE trace_id=?",
440
+ (self.now(), trace_id),
441
+ )
442
+ c.execute(
443
+ "UPDATE observations SET excerpt='', status='archived' WHERE trace_id=?",
444
+ (trace_id,),
445
+ )
446
+ from .privacy import purge_derived
447
+ purge_derived(self, c, trace_id, affected)
448
+
449
+ # 依赖传播要另开事务(recompute_derived 自己管事务)
450
+ for key in affected:
451
+ self.propagate(key)
452
+
453
+ return {
454
+ "trace_id": trace_id,
455
+ "state_keys": sorted(affected),
456
+ "voided_events": len(rows),
457
+ }
458
+
459
+ def _state_keys_depending_on(self, trace_id: str) -> list[str]:
460
+ rows = self._conn().execute(
461
+ "SELECT DISTINCT e.state_key FROM event_evidence ev"
462
+ " JOIN events e ON e.event_id = ev.event_id WHERE ev.trace_id=?",
463
+ (trace_id,),
464
+ ).fetchall()
465
+ return [r["state_key"] for r in rows]
466
+
467
+ def _spec_or_none(self, state_key: str) -> RelationSpec | None:
468
+ try:
469
+ return self.registry.get(relation_of(state_key))
470
+ except SpecNotFound:
471
+ return None
472
+
473
+ # ======================================================== 提交
474
+ def commit(self, a: Assertion) -> CommitResult:
475
+ spec = self._resolve_spec(a)
476
+ self._validate_assertion(a, spec)
477
+ with self._tx() as c:
478
+ return self._commit_in_tx(c, a, spec)
479
+
480
+ def commit_batch(self, assertions: Sequence[Assertion]) -> list[CommitResult]:
481
+ """原子事件组:要么全部提交,要么全部回滚(设计文档 9.5)。
482
+
483
+ 组内各事件共享 operation_group_id;调用方应保证它们属于同一业务操作。
484
+ """
485
+ if not assertions:
486
+ return []
487
+ results: list[CommitResult] = []
488
+ with self._tx() as c:
489
+ for a in assertions:
490
+ spec = self._resolve_spec(a)
491
+ # 组内前序事件会改变版本,因此组内不强制 expected_version
492
+ a.expected_version = None
493
+ self._validate_assertion(a, spec)
494
+ results.append(self._commit_in_tx(c, a, spec))
495
+ return results
496
+
497
+ def _commit_in_tx(
498
+ self, c: sqlite3.Connection, a: Assertion, spec: RelationSpec
499
+ ) -> CommitResult:
500
+ if c.execute("SELECT 1 FROM scopes WHERE scope_id=?", (a.scope_id,)).fetchone() is None:
501
+ raise ScopeNotFound(a.scope_id)
502
+
503
+ payload_hash = digest(canonical_json(a.payload))
504
+ prior = c.execute(
505
+ "SELECT * FROM events WHERE scope_id=? AND idempotency_key=?",
506
+ (a.scope_id, a.idempotency_key),
507
+ ).fetchone()
508
+
509
+ # ---- 幂等:同键同载荷返回原结果;同键异载荷拒绝
510
+ if prior is not None:
511
+ if prior["payload_hash"] != payload_hash:
512
+ raise IdempotencyConflict(a.idempotency_key, prior["event_id"])
513
+ return CommitResult(
514
+ event_id=prior["event_id"], state_key=prior["state_key"],
515
+ sequence_no=prior["sequence_no"], applied=False,
516
+ state=self._read_state(c, prior["state_key"]),
517
+ )
518
+
519
+ # ---- 证据必须可解析(I1)
520
+ self._assert_evidence_usable(c, a.evidence_trace_ids)
521
+
522
+ # ---- 乐观锁:version 为有效事件数,重算后稳定
523
+ current = self._read_state(c, a.state_key)
524
+ actual_version = current.version if current else 0
525
+ if a.expected_version is not None and a.expected_version != actual_version:
526
+ raise VersionConflict(a.state_key, a.expected_version, actual_version)
527
+
528
+ # ---- 写入事件
529
+ seq = c.execute(
530
+ "SELECT COALESCE(MAX(sequence_no), 0) + 1 FROM events WHERE state_key=?",
531
+ (a.state_key,),
532
+ ).fetchone()[0]
533
+ recorded_at = a.recorded_at or self.now()
534
+ effective_at = a.effective_at or recorded_at
535
+ event_id = "ev_" + digest(a.scope_id, a.state_key, a.idempotency_key)[:24]
536
+
537
+ c.execute(
538
+ "INSERT INTO events(event_id, scope_id, state_key, idempotency_key, payload_hash,"
539
+ " operation_group_id, operation, effect_kind, payload_json, gate_decision_id,"
540
+ " actor_id, effective_at, recorded_at, sequence_no, spec_version, target_ref,"
541
+ " pattern, void)"
542
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
543
+ (
544
+ event_id, a.scope_id, a.state_key, a.idempotency_key, payload_hash,
545
+ a.operation_group_id, a.operation, a.effect_kind,
546
+ canonical_json(a.payload), a.gate_decision_id,
547
+ a.actor_id, effective_at, recorded_at,
548
+ seq, spec.spec_version, a.target_ref, a.pattern or None,
549
+ ),
550
+ )
551
+ for trace_id in a.evidence_trace_ids:
552
+ c.execute(
553
+ "INSERT INTO event_evidence(event_id, trace_id) VALUES (?,?)",
554
+ (event_id, trace_id),
555
+ )
556
+
557
+ # ---- 投影:全量重算该状态键(设计文档 9.6 的简单基点)
558
+ state = self._recompute(c, a.state_key, spec)
559
+ return CommitResult(
560
+ event_id=event_id, state_key=a.state_key,
561
+ sequence_no=seq, applied=True, state=state,
562
+ )
563
+
564
+ # -------------------------------------------------------- 提交辅助
565
+ def _resolve_spec(self, a: Assertion) -> RelationSpec:
566
+ spec = self.registry.get(a.relation)
567
+ if a.spec_version and a.spec_version != spec.spec_version:
568
+ raise SpecNotFound(
569
+ f"assertion pins {a.spec_version!r} but registry has {spec.spec_version!r}"
570
+ )
571
+ return spec
572
+
573
+ def _validate_assertion(self, a: Assertion, spec: RelationSpec) -> None:
574
+ if not a.evidence_trace_ids:
575
+ raise EvidenceMissing(f"{a.state_key}: no evidence_trace_ids")
576
+ if a.effect_kind not in ("set", "delta", "invalidate", "none"):
577
+ raise ValueError(f"bad effect_kind {a.effect_kind!r}")
578
+ if a.effect_kind == "delta" and "delta" not in a.payload:
579
+ raise ValueError("delta effect requires payload['delta']")
580
+ if a.effect_kind == "none":
581
+ # 无效果事件只有一种合法形状:承诺。写成别的说明上游搞错了,
582
+ # 而"不知道是什么的无效果事件"会让状态机永远算不清。
583
+ if a.payload.get("kind") != "promise":
584
+ raise ValueError("effect_kind='none' requires payload['kind']='promise'")
585
+ if not isinstance(a.payload.get("quantity"), (int, float)):
586
+ raise ValueError("promise requires numeric payload['quantity']")
587
+ if a.effect_kind == "set" and "value" not in a.payload:
588
+ raise ValueError("set effect requires payload['value']")
589
+
590
+ def _assert_evidence_usable(
591
+ self, c: sqlite3.Connection, trace_ids: Sequence[str]
592
+ ) -> None:
593
+ for tid in trace_ids:
594
+ row = c.execute(
595
+ "SELECT erased_at FROM traces WHERE trace_id=?", (tid,)
596
+ ).fetchone()
597
+ if row is None:
598
+ raise TraceNotFound(tid)
599
+ if row["erased_at"]:
600
+ raise TraceErased(tid)
601
+
602
+ # ======================================================== 纠正 / 撤销
603
+ def correct(
604
+ self,
605
+ target_event_id: str,
606
+ new_payload: dict[str, Any],
607
+ idempotency_key: str,
608
+ evidence_trace_ids: tuple[str, ...],
609
+ actor_id: str,
610
+ effective_at: str | None = None,
611
+ ) -> CommitResult:
612
+ """纠正一条事件:新增替代事件,旧事件置为 void,然后重算。
613
+
614
+ 旧事件保留在流中(审计可查),但不参与求值。
615
+
616
+ XM-01: 不检查「修正后是否与更早的偿付矛盾」。这条要引入「累计量」概念,
617
+ 当前只有余额,判不出来。调用方需自行校验后决定是否调用。
618
+ """
619
+ # 读目标和写替代必须在**同一个事务**里。
620
+ # 早先是事务外读、事务内写:两个线程可以同时看到「目标未失效」,
621
+ # 各自写一条替代事件,`superseded_by` 被后一个覆盖,
622
+ # 前一条替代事件就成了无主孤儿——账是对的,链是断的。
623
+ with self._tx() as c:
624
+ target = self._get_event_in(c, target_event_id)
625
+ if target is None:
626
+ raise TraceNotFound(f"event {target_event_id} not found")
627
+ if target["void"]:
628
+ raise EventAlreadyVoid(target_event_id)
629
+
630
+ a = Assertion(
631
+ scope_id=target["scope_id"],
632
+ state_key=target["state_key"],
633
+ relation=relation_of(target["state_key"]),
634
+ operation="append",
635
+ effect_kind=target["effect_kind"],
636
+ payload=new_payload,
637
+ idempotency_key=idempotency_key,
638
+ evidence_trace_ids=evidence_trace_ids,
639
+ actor_id=actor_id,
640
+ effective_at=effective_at or target["effective_at"],
641
+ target_ref=target_event_id,
642
+ )
643
+ spec = self._resolve_spec(a)
644
+ self._validate_assertion(a, spec)
645
+ self._assert_evidence_usable(c, a.evidence_trace_ids)
646
+
647
+ result = self._commit_in_tx(c, a, spec)
648
+ if result.applied:
649
+ # `AND void=0` 是第二道闸:即使前面所有检查都过了,
650
+ # 条件更新也能保证「只有一个人能替代它」。
651
+ cur = c.execute(
652
+ "UPDATE events SET void=1, superseded_by=? WHERE event_id=? AND void=0",
653
+ (result.event_id, target_event_id),
654
+ )
655
+ if cur.rowcount != 1:
656
+ raise EventAlreadyVoid(target_event_id)
657
+ result.state = self._recompute(c, a.state_key, spec)
658
+ return result
659
+
660
+ def retract_event(
661
+ self,
662
+ target_event_id: str,
663
+ idempotency_key: str,
664
+ evidence_trace_ids: tuple[str, ...],
665
+ actor_id: str,
666
+ ) -> StateView:
667
+ """撤销一条事件:写入 tombstone,旧事件置为 void,然后重算。
668
+
669
+ 不追加反向 delta——那会在存在后续依赖时算错(设计文档 9.3)。
670
+ """
671
+ # 同 correct:读目标和写 tombstone 必须在同一个事务里。
672
+ with self._tx() as c:
673
+ target = self._get_event_in(c, target_event_id)
674
+ if target is None:
675
+ raise TraceNotFound(f"event {target_event_id} not found")
676
+ if target["void"]:
677
+ raise EventAlreadyVoid(target_event_id)
678
+
679
+ spec = self.registry.get(relation_of(target["state_key"]))
680
+ self._assert_evidence_usable(c, evidence_trace_ids)
681
+ seq = c.execute(
682
+ "SELECT COALESCE(MAX(sequence_no), 0) + 1 FROM events WHERE state_key=?",
683
+ (target["state_key"],),
684
+ ).fetchone()[0]
685
+ tomb = "ev_" + digest(target["scope_id"], target["state_key"], idempotency_key)[:24]
686
+ now = self.now()
687
+ c.execute(
688
+ "INSERT INTO events(event_id, scope_id, state_key, idempotency_key, payload_hash,"
689
+ " operation, effect_kind, payload_json, actor_id, effective_at, recorded_at,"
690
+ " sequence_no, spec_version, target_ref, void)"
691
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)",
692
+ (
693
+ tomb, target["scope_id"], target["state_key"], idempotency_key,
694
+ digest(canonical_json({"retracted_event": target_event_id})),
695
+ "retract", "invalidate",
696
+ canonical_json({"retracted_event": target_event_id}),
697
+ actor_id, target["effective_at"], now,
698
+ seq, spec.spec_version, target_event_id,
699
+ ),
700
+ )
701
+ for tid in evidence_trace_ids:
702
+ c.execute(
703
+ "INSERT INTO event_evidence(event_id, trace_id) VALUES (?,?)", (tomb, tid)
704
+ )
705
+ cur = c.execute(
706
+ "UPDATE events SET void=1, superseded_by=? WHERE event_id=? AND void=0",
707
+ (tomb, target_event_id),
708
+ )
709
+ if cur.rowcount != 1:
710
+ raise EventAlreadyVoid(target_event_id)
711
+ return self._recompute(c, target["state_key"], spec)
712
+
713
+ def _get_event_in(self, c: sqlite3.Connection, event_id: str) -> dict[str, Any] | None:
714
+ """在指定事务里读事件。读-判-写必须共用同一个连接,否则又是 TOCTOU。"""
715
+ row = c.execute("SELECT * FROM events WHERE event_id=?", (event_id,)).fetchone()
716
+ return dict(row) if row else None
717
+
718
+ def _get_event(self, event_id: str) -> dict[str, Any] | None:
719
+ row = self._conn().execute(
720
+ "SELECT * FROM events WHERE event_id=?", (event_id,)
721
+ ).fetchone()
722
+ return dict(row) if row else None
723
+
724
+ # ======================================================== 求值
725
+ def _fold(
726
+ self,
727
+ c: sqlite3.Connection,
728
+ state_key: str,
729
+ spec: RelationSpec,
730
+ as_of: str | None = None,
731
+ knowledge_as_of: str | None = None,
732
+ ) -> StateView:
733
+ """按 (effective_at, sequence_no) 重放一条状态流。
734
+
735
+ 两种时间:
736
+ as_of 业务时间上限(effective_at <= as_of)
737
+ knowledge_as_of 知识时间上限(recorded_at <= as_of)
738
+ 两者组合即"当时系统认为的状态"。
739
+ """
740
+ # 排序键:`(生效时间, 它取代的那条事件的序号, 自己的序号)`。
741
+ #
742
+ # 第三个键不是多余的。修正事件的排序如果只看自己的序号,
743
+ # 它会永远排在**最后**——于是「改初始值」被排到两笔还款之后,
744
+ # set 又把它们抹掉了,余额从 20 变成 50,而事件流看起来完全正常。
745
+ #
746
+ # 时间戳精确到微秒时这个问题碰不到(修正事件带着原事件的
747
+ # effective_at,会比后面的还款更早)。但时钟一旦变粗
748
+ # (固定时钟、批量导入、秒级时间戳),三条事件时间相同,
749
+ # 就只能靠序号——这时"取代"语义必须显式写出来。
750
+ sql = (
751
+ "SELECT e.*, COALESCE(t.sequence_no, e.sequence_no) AS sort_seq"
752
+ " FROM events e"
753
+ " LEFT JOIN events t ON t.event_id = e.target_ref AND e.target_ref IS NOT NULL"
754
+ " WHERE e.state_key=? AND e.void=0"
755
+ )
756
+ params: list[Any] = [state_key]
757
+ if as_of:
758
+ sql += " AND e.effective_at <= ?"
759
+ params.append(as_of)
760
+ if knowledge_as_of:
761
+ sql += " AND e.recorded_at <= ?"
762
+ params.append(knowledge_as_of)
763
+ sql += " ORDER BY e.effective_at, sort_seq, e.sequence_no"
764
+ rows = c.execute(sql, params).fetchall()
765
+
766
+ # watermark 用全部事件(含 void)的最大序号,表示"投影到了这里"
767
+ wm_row = c.execute(
768
+ "SELECT COALESCE(MAX(sequence_no), 0) FROM events WHERE state_key=?", (state_key,)
769
+ ).fetchone()
770
+ watermark = wm_row[0]
771
+
772
+ state: StateView | None = None
773
+ totals: dict = {}
774
+ count = 0
775
+ # 这本账用哪个单位记的。单位不是必填,也不是规格里定死的
776
+ # (米按袋、按石、按斗都有人用),但**一本账只能有一个口径**。
777
+ unit: str | None = None
778
+ for row in rows:
779
+ payload = json.loads(row["payload_json"])
780
+ fake = Assertion(
781
+ scope_id=row["scope_id"], state_key=state_key,
782
+ relation=spec.relation, operation=row["operation"],
783
+ effect_kind=row["effect_kind"],
784
+ payload=payload,
785
+ idempotency_key=row["idempotency_key"],
786
+ evidence_trace_ids=(), actor_id=row["actor_id"],
787
+ )
788
+ change = apply_event(state, fake, spec, totals)
789
+ count += 1
790
+
791
+ # 单位变了:两笔账不是同一个计量口径,不能直接加减。
792
+ #
793
+ # 实测:「欠两石米」+「再欠三斗」−「还五斗」算出来是 0,状态 known——
794
+ # 2+3−5。而真实答案是 2 石 − 2 斗 = 18 斗。**账面看不出任何异常。**
795
+ #
796
+ # 内核不做换算:石等于十斗是领域知识,写死在核心里就是把某地的度量
797
+ # 习惯当成普适真理(各朝各代一石并不一样)。但内核也不能装作没看见。
798
+ # 所以:**判 disputed、值停在上一次**,等有人来定口径。
799
+ this_unit = payload.get("unit")
800
+
801
+ # 一旦因为单位卡住,后面的事件都别再改写状态——
802
+ # 否则「还你五斗」会把它盖成 non_negative_violated,
803
+ # 真正的原因(单位不同)就丢了。**账卡住的原因要稳定。**
804
+ if (state is not None and state.status == "disputed"
805
+ and (state.conflict_reason or "").startswith("unit_mismatch")):
806
+ change = StateChange(
807
+ status="disputed", value=state.value, last_known=state.value,
808
+ conflict_reason=state.conflict_reason,
809
+ )
810
+ elif this_unit and unit and this_unit != unit and change.status in (
811
+ "known", "stale"
812
+ ):
813
+ keep = state.value if state is not None else None
814
+ change = StateChange(
815
+ status="disputed",
816
+ value=keep,
817
+ last_known=keep,
818
+ conflict_reason=f"unit_mismatch:{unit}!={this_unit}",
819
+ )
820
+ elif this_unit and not unit:
821
+ unit = this_unit
822
+
823
+ state = StateView(
824
+ state_key=state_key, status=change.status, value=change.value,
825
+ last_known=change.last_known, version=count, watermark=watermark,
826
+ conflict_reason=change.conflict_reason,
827
+ spec_version=row["spec_version"],
828
+ )
829
+
830
+ if state is None:
831
+ return StateView(
832
+ state_key=state_key, status="unknown", value=None, last_known=None,
833
+ version=0, watermark=watermark, conflict_reason=None,
834
+ spec_version=spec.spec_version,
835
+ )
836
+ return state
837
+
838
+ def _recompute(self, c: sqlite3.Connection, state_key: str, spec: RelationSpec) -> StateView:
839
+ # XM-04: 全量重放,不是文档 9.4 的「受影响后缀 + 依赖闭包」。
840
+ # 改成后缀时 states.watermark 的语义会从「最大序号」变成「快照点」,
841
+ # 依赖它做增量同步的调用方会静默出错。
842
+ state = self._fold(c, state_key, spec)
843
+ state = self._flag_delta_before_first_set(c, state_key, state)
844
+ self._write_state(c, state, spec.spec_version)
845
+ # 承诺投影挂在**唯一的重算入口**上,而不是各条写路径各调一次。
846
+ # 之前只在 append 里调,结果 retract / correct 走的是另一条路——
847
+ # 撤销一笔还款之后,承诺还写着 kept(测试当场抓到)。
848
+ self._refresh_commitments(c, state_key)
849
+ return state
850
+
851
+ # -------------------------------------------------------- 承诺投影
852
+ def _refresh_commitments(self, c: sqlite3.Connection, state_key: str) -> None:
853
+ """从事件流重算某个状态键下的承诺。**全量,不做增量。**
854
+
855
+ 规则只有两条,但必须按时间顺序一起跑,否则会错:
856
+
857
+ 承诺事件 → 开一条 open,remaining = quantity
858
+ 还款事件 → 从最早的 open 开始扣,扣完标 kept
859
+
860
+ 为什么是"最早优先"(FIFO):两笔承诺「下月还两袋」「月底还一袋」,
861
+ 今天还了一袋——没有任何信息能区分他先还的是哪一笔,
862
+ 而 FIFO 至少是**可解释**的:先答应的先兑现。
863
+
864
+ 撤销会自动复员:void=1 的事件直接不参与重算,
865
+ 所以撤回一次还款之后,被它结清的承诺会自己回到 open。
866
+ 这是选"全量重算"而不是"增量维护"换来的。
867
+ """
868
+ rows = c.execute(
869
+ "SELECT event_id, scope_id, operation, effect_kind, payload_json, actor_id,"
870
+ " effective_at, recorded_at, sequence_no, void, target_ref"
871
+ " FROM events WHERE state_key=? AND void=0"
872
+ " ORDER BY effective_at, sequence_no",
873
+ (state_key,),
874
+ ).fetchall()
875
+
876
+ c.execute("DELETE FROM commitments WHERE state_key=?", (state_key,))
877
+ open_items: list[dict[str, Any]] = []
878
+ now = self.now()
879
+
880
+ for row in rows:
881
+ payload = json.loads(row["payload_json"] or "{}")
882
+ ek = row["effect_kind"]
883
+ if ek == "none" and payload.get("kind") == "promise":
884
+ qty = payload.get("quantity")
885
+ if not isinstance(qty, (int, float)):
886
+ continue
887
+ open_items.append({
888
+ "commitment_id": "cm_" + row["event_id"][3:],
889
+ "scope_id": row["scope_id"],
890
+ "state_key": state_key,
891
+ "event_id": row["event_id"],
892
+ "actor_id": row["actor_id"],
893
+ "quantity": float(qty),
894
+ "remaining": float(qty),
895
+ "unit": payload.get("unit"),
896
+ "due_hint": payload.get("due_hint"),
897
+ "due_at": payload.get("due_at"),
898
+ "status": "open",
899
+ "settled_by": None,
900
+ "created_at": row["recorded_at"],
901
+ "updated_at": now,
902
+ })
903
+ continue
904
+
905
+ if ek != "delta":
906
+ continue
907
+ d = payload.get("delta")
908
+ if not isinstance(d, (int, float)) or d >= 0:
909
+ continue
910
+ left = float(-d)
911
+ for item in open_items:
912
+ if left <= 0:
913
+ break
914
+ if item["remaining"] <= 0:
915
+ continue
916
+ take = min(item["remaining"], left)
917
+ item["remaining"] -= take
918
+ left -= take
919
+ if item["remaining"] <= 0:
920
+ item["status"] = "kept"
921
+ item["settled_by"] = row["event_id"]
922
+
923
+ for item in open_items:
924
+ if item["status"] == "open" and item["remaining"] < item["quantity"]:
925
+ item["status"] = "partial"
926
+ c.execute(
927
+ "INSERT INTO commitments(commitment_id, scope_id, state_key, event_id,"
928
+ " actor_id, quantity, remaining, unit, due_hint, due_at, status,"
929
+ " settled_by, created_at, updated_at)"
930
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
931
+ (
932
+ item["commitment_id"], item["scope_id"], item["state_key"],
933
+ item["event_id"], item["actor_id"], item["quantity"],
934
+ item["remaining"], item["unit"], item["due_hint"], item["due_at"],
935
+ item["status"], item["settled_by"], item["created_at"],
936
+ item["updated_at"],
937
+ ),
938
+ )
939
+
940
+ def commitments(
941
+ self, scope_id: str, state_key: str | None = None, status: str | None = None
942
+ ) -> list[dict[str, Any]]:
943
+ """列出承诺。默认全给,调用方自己筛。"""
944
+ sql = "SELECT * FROM commitments WHERE scope_id=?"
945
+ params: list[Any] = [scope_id]
946
+ if state_key:
947
+ sql += " AND state_key=?"
948
+ params.append(state_key)
949
+ if status:
950
+ sql += " AND status=?"
951
+ params.append(status)
952
+ sql += " ORDER BY created_at, commitment_id"
953
+ return [dict(r) for r in self._conn().execute(sql, params).fetchall()]
954
+
955
+ def overdue(
956
+ self, scope_id: str, state_key: str | None = None, now: str | None = None
957
+ ) -> list[dict[str, Any]]:
958
+ """到期没还的承诺,最早的在前。
959
+
960
+ `due_at` 为 NULL 的("回头""发工资")永远不进这个列表——
961
+ 没有约定日子的承诺不该被催。这不是遗漏,是 XM-24 的边界。
962
+ """
963
+ at = now or self.now()
964
+ sql = (
965
+ "SELECT * FROM commitments WHERE scope_id=?"
966
+ " AND status IN ('open','partial') AND due_at IS NOT NULL AND due_at <= ?"
967
+ )
968
+ params: list[Any] = [scope_id, at]
969
+ if state_key:
970
+ sql += " AND state_key=?"
971
+ params.append(state_key)
972
+ sql += " ORDER BY due_at, created_at"
973
+ return [dict(r) for r in self._conn().execute(sql, params).fetchall()]
974
+
975
+ def rebuild_commitments(self, scope_id: str) -> int:
976
+ """从事件流重建承诺投影。返回处理的状态键数。
977
+
978
+ 存在的意义不是"修数据",是**证明这张表可以随时扔掉**。
979
+ 只要它和事件流不一致,就是这个投影写坏了,而不是数据坏了。
980
+ """
981
+ keys = [
982
+ r[0] for r in self._conn().execute(
983
+ "SELECT DISTINCT state_key FROM events WHERE scope_id=?", (scope_id,)
984
+ ).fetchall()
985
+ ]
986
+ with self._tx() as c:
987
+ for key in keys:
988
+ self._refresh_commitments(c, key)
989
+ return len(keys)
990
+
991
+ def _flag_delta_before_first_set(
992
+ self, c: sqlite3.Connection, state_key: str, state: StateView
993
+ ) -> StateView:
994
+ """首个 set 之前的 delta:没有基数,永远不可能成立。
995
+
996
+ 它会被后来的 set 覆盖,最终状态看起来"正常"——但那条记录
997
+ 其实从未生效过。不标记的话,审计里完全看不出它是个错误。
998
+
999
+ XM-09:这个检查放在重算的**最后**,因为要等整条流都算完才知道
1000
+ 谁是"首个 set"。放在求值过程中只能看到局部,会把
1001
+ "未来会有 set"的合法情况误判成错误。
1002
+ """
1003
+ row = c.execute(
1004
+ "SELECT MIN(effective_at) AS t FROM events"
1005
+ " WHERE state_key=? AND effect_kind='set' AND void=0",
1006
+ (state_key,),
1007
+ ).fetchone()
1008
+ if row is None or row["t"] is None:
1009
+ # 整条流没有 set:delta 是「从 0 开始」,合法(+5 → 5)。
1010
+ # 减量仍然会被非负约束拦住,不需要这里再判。
1011
+ return state
1012
+
1013
+ n = c.execute(
1014
+ "SELECT COUNT(*) AS n FROM events WHERE state_key=? AND effect_kind='delta'"
1015
+ " AND void=0 AND effective_at < ?",
1016
+ (state_key, row["t"]),
1017
+ ).fetchone()["n"]
1018
+ if not n:
1019
+ return state
1020
+
1021
+ # 已经是 disputed 的保留原原因(那个更具体)。
1022
+ if state.status in ("known", "stale"):
1023
+ return replace(state, status="disputed",
1024
+ conflict_reason="delta_before_first_set")
1025
+ return state
1026
+
1027
+ def _read_state(self, c: sqlite3.Connection, state_key: str) -> StateView:
1028
+ row = c.execute("SELECT * FROM states WHERE state_key=?", (state_key,)).fetchone()
1029
+ if row is None:
1030
+ return StateView(
1031
+ state_key=state_key, status="unknown", value=None, last_known=None,
1032
+ version=0, watermark=0, conflict_reason=None, spec_version="",
1033
+ )
1034
+ return StateView(
1035
+ state_key=row["state_key"], status=row["status"],
1036
+ value=json.loads(row["value_json"]) if row["value_json"] else None,
1037
+ last_known=json.loads(row["last_known_json"]) if row["last_known_json"] else None,
1038
+ version=row["version"], watermark=row["watermark"],
1039
+ conflict_reason=row["conflict_reason"], spec_version=row["spec_version"],
1040
+ )
1041
+
1042
+ def _write_state(self, c: sqlite3.Connection, s: StateView, spec_version: str) -> None:
1043
+ c.execute(
1044
+ "INSERT INTO states(state_key, scope_id, value_json, status, last_known_json,"
1045
+ " conflict_reason, version, watermark, evaluated_as_of, spec_version)"
1046
+ " VALUES (?,?,?,?,?,?,?,?,?,?)"
1047
+ " ON CONFLICT(state_key) DO UPDATE SET"
1048
+ " value_json=excluded.value_json,"
1049
+ " status=excluded.status,"
1050
+ " last_known_json=excluded.last_known_json,"
1051
+ " conflict_reason=excluded.conflict_reason,"
1052
+ " version=excluded.version,"
1053
+ " watermark=excluded.watermark,"
1054
+ " evaluated_as_of=excluded.evaluated_as_of,"
1055
+ " spec_version=excluded.spec_version",
1056
+ (
1057
+ s.state_key, s.state_key.split("|")[0],
1058
+ canonical_json(s.value) if s.value is not None else None,
1059
+ s.status,
1060
+ canonical_json(s.last_known) if s.last_known is not None else None,
1061
+ s.conflict_reason, s.version, s.watermark, self.now(), spec_version,
1062
+ ),
1063
+ )
1064
+
1065
+ # ======================================================== 读取
1066
+ # XM-06: 只有同步投影。加异步后,这里要能区分「读投影」与「等投影」
1067
+ # (read-your-writes),属于接口变更。
1068
+ def get_state(
1069
+ self,
1070
+ state_key: str,
1071
+ as_of: str | None = None,
1072
+ knowledge_as_of: str | None = None,
1073
+ ) -> StateView:
1074
+ """无参数读当前投影;带时间参数则按事件流重放(不写回)。"""
1075
+ if as_of is None and knowledge_as_of is None:
1076
+ return self._read_state(self._conn(), state_key)
1077
+
1078
+ spec = self.registry.get(relation_of(state_key))
1079
+ return self._fold(self._conn(), state_key, spec, as_of, knowledge_as_of)
1080
+
1081
+ def events_of(self, state_key: str, include_void: bool = True) -> list[dict[str, Any]]:
1082
+ sql = "SELECT * FROM events WHERE state_key=?"
1083
+ if not include_void:
1084
+ sql += " AND void=0"
1085
+ sql += " ORDER BY effective_at, sequence_no"
1086
+ rows = self._conn().execute(sql, (state_key,)).fetchall()
1087
+ return [dict(r) for r in rows]
1088
+
1089
+ def evidence_of(self, event_id: str) -> list[str]:
1090
+ rows = self._conn().execute(
1091
+ "SELECT trace_id FROM event_evidence WHERE event_id=? ORDER BY rowid", (event_id,)
1092
+ ).fetchall()
1093
+ return [r["trace_id"] for r in rows]
1094
+
1095
+ def explain(self, state_key: str) -> dict[str, Any]:
1096
+ """状态值 + 事件链 + 证据片段。用于回答"为什么是这个值"。"""
1097
+ chain = []
1098
+ for ev in self.events_of(state_key):
1099
+ chain.append({
1100
+ "event_id": ev["event_id"],
1101
+ "sequence_no": ev["sequence_no"],
1102
+ "operation": ev["operation"],
1103
+ "effect_kind": ev["effect_kind"],
1104
+ "payload": json.loads(ev["payload_json"]),
1105
+ "effective_at": ev["effective_at"],
1106
+ "recorded_at": ev["recorded_at"],
1107
+ "void": bool(ev["void"]),
1108
+ "superseded_by": ev["superseded_by"],
1109
+ "evidence": [
1110
+ {"trace_id": tid, "content": (self.get_trace(tid) or {}).get("content", "")}
1111
+ for tid in self.evidence_of(ev["event_id"])
1112
+ ],
1113
+ })
1114
+ return {"state": self.get_state(state_key), "chain": chain}
1115
+
1116
+ def replay(
1117
+ self,
1118
+ state_key: str,
1119
+ as_of: str | None = None,
1120
+ knowledge_as_of: str | None = None,
1121
+ ) -> StateView:
1122
+ """从事件流重建状态。用于 T04 与重算一致性校验。"""
1123
+ spec = self.registry.get(relation_of(state_key))
1124
+ return self._fold(self._conn(), state_key, spec, as_of, knowledge_as_of)
1125
+
1126
+ # ======================================================== 依赖图
1127
+ def register_derivation(
1128
+ self,
1129
+ derived_key: str,
1130
+ input_keys: Sequence[str],
1131
+ rule_version: str,
1132
+ fn: Callable[[list[StateView]], tuple[str, Any, str | None]],
1133
+ ) -> None:
1134
+ """登记派生状态。
1135
+
1136
+ fn 接收输入状态列表,返回 (status, value, conflict_reason)。
1137
+ 派生状态不保存独立证据,其依据即输入状态的依赖链(设计文档 6.9)。
1138
+ """
1139
+ if derived_key in self._derivations:
1140
+ raise ValueError(f"derivation already registered: {derived_key}")
1141
+ # 环检测:derived_key 不能出现在自己的输入闭包里
1142
+ if derived_key in input_keys:
1143
+ raise DependencyCycle(f"{derived_key} depends on itself")
1144
+ # XM-05: fn 存在内存里,进程重启后必须重新注册。
1145
+ # state_deps 只存了依赖关系,不存实现,别指望从库里恢复。
1146
+ self._derivations[derived_key] = (tuple(input_keys), rule_version, fn)
1147
+ with self._tx() as c:
1148
+ for k in input_keys:
1149
+ c.execute(
1150
+ "INSERT OR REPLACE INTO state_deps(derived_key, input_key, rule_version)"
1151
+ " VALUES (?,?,?)",
1152
+ (derived_key, k, rule_version),
1153
+ )
1154
+
1155
+ def recompute_derived(self, derived_key: str) -> StateView:
1156
+ if derived_key not in self._derivations:
1157
+ raise KeyError(f"no derivation registered for {derived_key}")
1158
+ inputs, rule_version, fn = self._derivations[derived_key]
1159
+ states = [self.get_state(k) for k in inputs]
1160
+ status, value, reason = fn(states)
1161
+ view = StateView(
1162
+ state_key=derived_key, status=status, value=value, last_known=None,
1163
+ version=0, watermark=0, conflict_reason=reason,
1164
+ spec_version=f"derived:{rule_version}",
1165
+ )
1166
+ with self._tx() as c:
1167
+ self._write_state(c, view, f"derived:{rule_version}")
1168
+ return view
1169
+
1170
+ def propagate(self, changed_key: str) -> list[str]:
1171
+ """从被改动的键出发,按拓扑序重算其下游派生状态。"""
1172
+ order = self._dependent_order(changed_key)
1173
+ for key in order:
1174
+ self.recompute_derived(key)
1175
+ return order
1176
+
1177
+ def _dependent_order(self, root: str) -> list[str]:
1178
+ conn = self._conn()
1179
+ rows = conn.execute(
1180
+ "SELECT derived_key, input_key FROM state_deps"
1181
+ ).fetchall()
1182
+ graph: dict[str, list[str]] = {}
1183
+ for r in rows:
1184
+ graph.setdefault(r["input_key"], []).append(r["derived_key"])
1185
+
1186
+ seen: list[str] = []
1187
+ seen_set: set[str] = set()
1188
+
1189
+ def walk(node: str) -> None:
1190
+ for child in graph.get(node, ()):
1191
+ if child in seen_set:
1192
+ continue
1193
+ walk(child) # 先递归下游,保证拓扑序
1194
+ seen_set.add(child)
1195
+ seen.append(child)
1196
+
1197
+ walk(root)
1198
+ seen.reverse()
1199
+ return seen
1200
+
1201
+ # ======================================================== T01 检查
1202
+ def check_source_closure(self, state_key: str) -> list[str]:
1203
+ """来源闭合检查。返回问题列表,空表示通过。"""
1204
+ problems: list[str] = []
1205
+ for ev in self.events_of(state_key):
1206
+ ev_id = ev["event_id"]
1207
+ traces = self.evidence_of(ev_id)
1208
+ if not traces:
1209
+ problems.append(f"{ev_id}: no evidence")
1210
+ continue
1211
+ for tid in traces:
1212
+ t = self.get_trace(tid)
1213
+ if t is None:
1214
+ problems.append(f"{ev_id}: evidence {tid} missing")
1215
+ elif t["erased_at"]:
1216
+ problems.append(f"{ev_id}: evidence {tid} erased")
1217
+ elif t["scope_id"] != ev["scope_id"]:
1218
+ problems.append(f"{ev_id}: evidence {tid} crosses scope")
1219
+ return problems
1220
+
1221
+
1222
+ # ============================================================ 纯函数
1223
+ @dataclass(frozen=True)
1224
+ class StateChange:
1225
+ status: str
1226
+ value: Any = None
1227
+ last_known: Any = None
1228
+ conflict_reason: str | None = None
1229
+
1230
+
1231
+ def apply_event(
1232
+ current: StateView | None,
1233
+ a: Assertion,
1234
+ spec: RelationSpec,
1235
+ totals: dict | None = None,
1236
+ ) -> StateChange:
1237
+ """事件 → 新状态。
1238
+
1239
+ 对 status/value 是纯函数:不依赖时钟、不访问数据库。
1240
+ 唯一被就地改写的是 `totals`——它是折叠过程中的累加器,
1241
+ 用来支持「累计偿付量」这类无法只看余额判断的约束(XM-01)。
1242
+
1243
+ 违反业务约束时不截断、不丢弃事件,而是进入 disputed
1244
+ (设计文档 7.4 conflict_policy / 8.5 第 7 条 / 14.2-H)。
1245
+ """
1246
+ prev_value = current.value if current else None
1247
+
1248
+ # 承诺(effect_kind="none"):事件进流,状态不动。
1249
+ #
1250
+ # 它不是"没有变化",而是**变化发生在以后**。事件流里留着它,
1251
+ # 才能回答「他答应过什么」「答应了几次」;而 states 里不体现它,
1252
+ # 因为余额确实一分没动。把这两件事混在一起,就会出现
1253
+ # 「说了句下个月还,余额先少了」——XM-22 就是这么来的。
1254
+ if a.effect_kind == "none":
1255
+ if current is None:
1256
+ return StateChange(status="unknown", value=None, last_known=None)
1257
+ return StateChange(
1258
+ status=current.status, value=prev_value, last_known=prev_value,
1259
+ )
1260
+
1261
+ if a.operation == "retract" or a.effect_kind == "invalidate":
1262
+ return StateChange(status="retracted", value=None, last_known=prev_value)
1263
+
1264
+ if a.operation == "conflict":
1265
+ return StateChange(
1266
+ status="disputed", value=prev_value, last_known=prev_value,
1267
+ conflict_reason=a.payload.get("reason", "explicit_conflict"),
1268
+ )
1269
+
1270
+ # 累计(只对 delta,且只在该关系声明了守恒量时)
1271
+ if totals is not None and a.effect_kind == "delta" and spec.conservation == "repayment":
1272
+ d = a.payload.get("delta", 0)
1273
+ # 内核必须自己挡类型:编码器给什么都不能让它抛异常。
1274
+ # 非数字的 delta 不是"没有变化",是一个明确的错误,要落成 disputed。
1275
+ if not isinstance(d, (int, float)) or isinstance(d, bool):
1276
+ return StateChange(
1277
+ status="disputed", value=prev_value, last_known=prev_value,
1278
+ conflict_reason="non_numeric_delta",
1279
+ )
1280
+ if d < 0:
1281
+ totals["repaid"] = totals.get("repaid", 0.0) + (-d)
1282
+
1283
+ if a.effect_kind == "set":
1284
+ raw = a.payload.get("value")
1285
+ # XM-01:修正后的初始值不能小于已偿付总额,否则「还得比欠的多」。
1286
+ # 这一类矛盾只看余额发现不了——余额可能仍是正的(因为期间又有新借款)。
1287
+ if (spec.conservation == "repayment" and totals is not None
1288
+ and isinstance(raw, (int, float))
1289
+ and totals.get("repaid", 0) > raw):
1290
+ return StateChange(
1291
+ status="disputed", value=prev_value, last_known=prev_value,
1292
+ conflict_reason="violates_accumulated_repayment",
1293
+ )
1294
+ else: # delta
1295
+ # XM-09(已修):delta 的 effective_at 早于本流首个 set 时,这里会
1296
+ # 在空状态上求值(如 0-5=-5),随后被 set 覆盖。
1297
+ # 现在由 `_recompute` 末尾的 `_flag_delta_before_first_set` 兜住:
1298
+ # 值照算,但状态降级为 disputed / delta_before_first_set。
1299
+ # 不在求值过程中判,因为那时还不知道"未来会不会有 set"。
1300
+ d = a.payload.get("delta", 0)
1301
+ if not isinstance(d, (int, float)) or isinstance(d, bool):
1302
+ return StateChange(
1303
+ status="disputed", value=prev_value, last_known=prev_value,
1304
+ conflict_reason="non_numeric_delta",
1305
+ )
1306
+ base = prev_value if isinstance(prev_value, (int, float)) else 0
1307
+ raw = base + d
1308
+
1309
+ err = _validate_value(spec, raw)
1310
+ if err:
1311
+ return StateChange(
1312
+ status="disputed", value=prev_value, last_known=prev_value,
1313
+ conflict_reason=err,
1314
+ )
1315
+
1316
+ return StateChange(status="known", value=raw, last_known=prev_value)
1317
+
1318
+
1319
+ def _validate_value(spec: RelationSpec, value: Any) -> str | None:
1320
+ vt = spec.value_type
1321
+ if vt in ("integer", "number"):
1322
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1323
+ return f"value_type:{vt}"
1324
+ if vt == "integer" and int(value) != value:
1325
+ return "value_type:integer"
1326
+ if not spec.allow_negative and value < 0:
1327
+ return "non_negative_violated"
1328
+ return None
1329
+ if vt == "string":
1330
+ return None if isinstance(value, str) else "value_type:string"
1331
+ if vt == "enum":
1332
+ return None if value in spec.enum_values else "enum_violated"
1333
+ return None