deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-20 17:16:46 +08:00
parent 882a1b5cd3
commit 40672e1115
9 changed files with 1441 additions and 311 deletions

View File

@ -1,90 +1,23 @@
{
"summary": [
{
"name": "pbl_entity_state",
"title": "实体当前状态(乐观锁版本表)",
"primary": [
"id"
],
"catelog": "relation",
"comment": "PBL 实体当前状态表world_sync 侧M11b-2 新增)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b表属性 tenant_scoped=true租户隔离。保存世界内实体的最新完整状态快照state_version 为乐观锁版本号(每次成功写入 +1用于防止并发覆盖丢失更新。【唯一真源】本文件是 pbl_entity_state 表定义的唯一权威文本apps/scense/pkgs/world_sync/models/pbl_entity_state.json 只是由 scripts/sync_models_to_app.py或 cp从本文件同步出的打包镜像禁止手工双写二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言)。【主键与唯一性】规范主键 id 为 str(32)(应用层生成 uuid4().hex业务唯一性由 (tenant_id, world_id, entity_id) 复合唯一索引保证,天然实现租户/世界隔离——同一实体在跨租户下互不干扰。【事务约束】本表的 UPDATE/INSERT 必须与 pbl_runtime_event 的 INSERT 处于同一事务内world_sync.pbl_runtime_tx.write_event_with_state任一失败整事务回滚禁止脱离事务单独更新本表。【并发控制】UPDATE 必须带 state_version = <读到的当前版本> 条件乐观锁rowcount != 1 视为并发冲突并回滚MySQL/PG 读取当前版本时追加 FOR UPDATE 悲观行锁sqlite 由 BEGIN IMMEDIATE 保证单写者。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 STATE_COLUMNS 及 pbl_runtime_tx.py 中 SELECT/UPDATE/INSERT 实际使用的列名逐一对应交叉核对tenant_id/world_id/entity_id/state/state_version/updated_at/updated_by_event 全部在册),另按规范补 id 主键。【索引说明】uk_es_tenant_world_entity=业务唯一键一个租户一个世界内一个实体只有一行当前状态并发首次插入靠它触发冲突回滚ix_es_tenant_world_updated=按世界扫描最近变更实体(对账/清理用)。【与 pbl_runtime_ext 的差异】modules/pbl_runtime_ext/models/pbl_entity_state.jsonM11a 侧)使用 state_json/checksum/updated_by 等不同列集与 int 自增 id属另一张同名表的另一套定义两者不可混用本文件为 world_syncM11b-2写入路径的权威定义双真源合并方案已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据)。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生str→VARCHAR、int→INT、text→TEXT、datetime→DATETIME本文件不出现 VARCHAR/BIGINT/DATETIME(3)/JSON 等方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/commentfields 仅含 name/title/type/length/dec/nullable/default说明文字一律写进 titleDDL 模板按 title 渲染 COMMENTindexes 仅含 name/idxtype/idxfields根键仅 summary/fields/indexes/codes 四段式。"
"key": "schema",
"schema": {
"table": "pbl_entity_state",
"authority_module": "pbl_runtime_ext",
"authority_path": "modules/pbl_runtime_ext/models/pbl_entity_state.json",
"owner": "pbls",
"milestone": "M11b-2b",
"decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源",
"decision_basis": [
"设计文档 docs/01-design/modules/entity.md 明确 pbl_entity_state 为 pbl_runtime_ext 侧、pbls 所有",
"world_sync 仅作为单事务写入的消费方,不拥有该表结构定义权",
"本任务不得反向修改 M11b-1 已批准产物"
],
"structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体;列集、主键、索引一律以 authority_path 指向的 pbl_runtime_ext 定义为准。world_sync 侧消费方代码pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py以该权威结构为准不再依赖旧的 8 列本地结构。",
"consumers": [
"modules/world_sync/world_sync/pbl_runtime_sql.py",
"modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state",
"modules/world_sync/world_sync/pbl_runtime_schema.py"
],
"mirror_note": "apps/scense/pkgs/world_sync/models/pbl_entity_state.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。"
}
],
"fields": [
{
"name": "id",
"title": "主键IDstr32应用层生成uuid4().hex首次INSERT状态行时创建",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "tenant_id",
"title": "租户ID隔离维度所有读写WHERE打头列",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "world_id",
"title": "世界ID隔离维度与tenant_id共同限定作用域",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "entity_id",
"title": "实体IDtenant_id+world_id+entity_id定位唯一状态行",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "state",
"title": "实体当前完整状态JSON文本规范抽象类型text不用方言JSON类型",
"type": "text",
"nullable": "no"
},
{
"name": "state_version",
"title": "状态版本号(乐观锁,每次成功写入+1与pbl_runtime_event.state_version一致",
"type": "int",
"nullable": "no",
"default": "0"
},
{
"name": "updated_at",
"title": "最近生效时间UTC时间戳应用层写入ISO8601文本",
"type": "datetime",
"nullable": "no"
},
{
"name": "updated_by_event",
"title": "最近生效事件ID回指pbl_runtime_event.event_id业务键",
"type": "str",
"length": 64,
"nullable": "yes"
}
],
"indexes": [
{
"name": "uk_es_tenant_world_entity",
"idxtype": "unique",
"idxfields": [
"tenant_id",
"world_id",
"entity_id"
]
},
{
"name": "ix_es_tenant_world_updated",
"idxtype": "index",
"idxfields": [
"tenant_id",
"world_id",
"updated_at"
]
}
],
"codes": []
}

View File

@ -1,154 +1,24 @@
{
"summary": [
{
"name": "pbl_runtime_event",
"title": "运行时事件流append-only",
"primary": [
"id"
],
"catelog": "relation",
"comment": "PBL 运行时事件表world_sync 侧写入视图)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b表属性 append_only=true只允许 INSERT禁止 UPDATE/DELETE每条事件代表一次运行时状态变更供回放与审计、tenant_scoped=true租户隔离。【唯一真源】本文件是 pbl_runtime_event 表定义的唯一权威文本apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 只是由 scripts/sync_models_to_app.py或 cp从本文件同步出的打包镜像禁止手工双写二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言。【写入约束】append-only只允许 INSERT禁止 UPDATE/DELETE。【事务约束】本表的 INSERT 必须与 pbl_entity_state 的更新处于同一数据库事务内(见 world_sync.pbl_runtime_tx.write_event_with_state任一失败整事务回滚不允许脱离事务单独写事件。【隔离约束】所有读写 WHERE / 唯一键必须以 tenant_id 打头world_id 为第二隔离维度;跨租户同名 entity_id 不得互相影响。【ID 约定】主键 id 为 str(32)由应用层生成uuid4().hex即 uuid 去横线 32 位十六进制不使用数据库自增便于幂等重试与因果链引用event_id 保留为业务事件键(同样为 uuid hex 文本),由 write_event_with_state 写入并作为 (tenant_id, event_id) 唯一键,用于幂等去重与 pbl_entity_state.updated_by_event 回指。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 EVENT_COLUMNS 及 pbl_runtime_tx.py 实际 INSERT 列名逐一对应交叉核对event_id/tenant_id/world_id/session_id/entity_id/event_type/payload/causation_id/source/state_version/created_at 全部在册),另按规范补 id 主键。【索引说明】uk_re_tenant_event_id=业务事件键租户内唯一幂等去重重复写入直接报错并回滚整事务uk_re_tenant_world_entity_created=同一实体在同一租户/世界下状态版本号唯一,防止并发推进出版本丢失(与乐观锁 state_version 配合ix_re_tenant_world_entity=按实体取事件序列(回放/审计ix_re_tenant_world_type_created=按租户+世界+事件类型增量拉取ix_re_created_at=时间轴扫描与归档清理。【与 M11b-1 的关系】modules/pbl_runtime_ext/models/pbl_runtime_event.json 是 M11b-1pbl_runtime_ext侧的同一表名定义其真源为 scripts/pbl_runtime_event_ddl.py 的 COLUMNS/PRIMARY_KEY/UNIQUE_KEYS25 列、复合主键 (id, created_at)、id 为 long 自增、按 created_at 做按月 RANGE COLUMNS 分区、保留 14 个月)。两者同名不同构,属双真源风险,已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据),本任务不擅自改动 M11b-1 已批准的 json 与其 DDL 生成器。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生str→VARCHAR、int→INT、text→TEXT/LONGTEXT、datetime→DATETIME本文件不出现任何数据库方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/commentfields 仅含 name/title/type/length/dec/nullable/default说明文字一律写进 titleDDL 模板按 title 渲染 COMMENTindexes 仅含 name/idxtype/idxfields根键仅 summary/fields/indexes/codes 四段式。"
"key": "schema",
"schema": {
"table": "pbl_runtime_event",
"authority_module": "pbl_runtime_ext",
"authority_path": "modules/pbl_runtime_ext/models/pbl_runtime_event.json",
"owner": "pbls",
"milestone": "M11b-2b",
"decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源",
"decision_basis": [
"任务书原文:在 M11b-1 产出的 pbl_runtime_event 表基础上(依赖 key=schema引用语义自任务定义起即为 B",
"设计文档 docs/01-design/modules/entity.md 明确该表为 pbl_runtime_ext 侧、pbls 所有",
"pbl_runtime_ext 侧 pbl_runtime_event 的 DDL + 3 个迁移文件 + dev-notes 均已 approved为既定权威结构",
"本任务不得反向修改 M11b-1 已批准产物"
],
"structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体列集、主键、索引、分区、append-only 约束一律以 authority_path 指向的 pbl_runtime_ext 定义为准25 列 / id long 自增 + (id, created_at) 复合主键 / 按月 RANGE COLUMNS 分区 / append-only 触发器。world_sync 侧消费方代码pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py以该 25 列权威结构为准,不再依赖旧的 12 列本地结构。",
"consumers": [
"modules/world_sync/world_sync/pbl_runtime_sql.py",
"modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state",
"modules/world_sync/world_sync/pbl_runtime_schema.py"
],
"mirror_note": "apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。"
}
],
"fields": [
{
"name": "id",
"title": "主键IDstr32应用层生成uuid4().hex非数据库自增",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "event_id",
"title": "业务事件IDuuid hex文本幂等去重键被pbl_entity_state.updated_by_event回指",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "tenant_id",
"title": "租户ID隔离维度强制打头缺失即拒绝写入",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "world_id",
"title": "世界ID隔离维度强制",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "session_id",
"title": "运行时会话ID可空无会话上下文时为空",
"type": "str",
"length": 64,
"nullable": "yes"
},
{
"name": "entity_id",
"title": "实体ID事件关联的实体",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "event_type",
"title": "事件类型如state.updated默认值同pbl_runtime_tx.DEFAULT_EVENT_TYPE",
"type": "str",
"length": 64,
"nullable": "no",
"default": "state.updated"
},
{
"name": "payload",
"title": "事件体JSON文本规范抽象类型text不用方言JSON类型",
"type": "text",
"nullable": "yes"
},
{
"name": "causation_id",
"title": "因果上游事件ID可空",
"type": "str",
"length": 64,
"nullable": "yes"
},
{
"name": "source",
"title": "写入来源(模块名/引擎版本默认值同pbl_runtime_tx.DEFAULT_SOURCE",
"type": "str",
"length": 64,
"nullable": "no",
"default": "world_sync.m11b2"
},
{
"name": "state_version",
"title": "状态版本本事件落库后实体状态版本与pbl_entity_state.state_version一致便于回放对账",
"type": "int",
"nullable": "no",
"default": "0"
},
{
"name": "created_at",
"title": "创建时间服务端UTC时间戳应用层写入ISO8601文本append-only表不设updated_at",
"type": "datetime",
"nullable": "no"
}
],
"indexes": [
{
"name": "uk_re_tenant_event_id",
"idxtype": "unique",
"idxfields": [
"tenant_id",
"event_id"
]
},
{
"name": "uk_re_tenant_world_entity_created",
"idxtype": "unique",
"idxfields": [
"tenant_id",
"world_id",
"entity_id",
"state_version"
]
},
{
"name": "ix_re_tenant_world_entity",
"idxtype": "index",
"idxfields": [
"tenant_id",
"world_id",
"entity_id"
]
},
{
"name": "ix_re_tenant_world_type_created",
"idxtype": "index",
"idxfields": [
"tenant_id",
"world_id",
"event_type",
"created_at"
]
},
{
"name": "ix_re_created_at",
"idxtype": "index",
"idxfields": [
"created_at"
]
}
],
"codes": [
{
"field": "event_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='pbl_event_type'"
}
]
}

View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M11b-2b 双向列集 diffworld_sync 消费方代码引用的列 vs pbl_runtime_ext 权威列集。
PM 裁决 B 的执行要求引用后跑双向列集 diff 留证world_sync 消费方代码若依赖旧结构
适配到权威结构
为避免把 Python 标识符误当列名只从代码中的 SQL 字符串字面量里提取列名
方向 1代码 -> 权威SQL 字面量中出现的列名必须全部存在于权威列集缺则 FAIL
方向 2权威 -> 代码权威列集中未被任何 SQL 引用的列打印为 informational
由自增/默认值/写入方填充不判失败
用法python3 modules/world_sync/scripts/m11b2b_column_diff.py
"""
import io
import json
import os
import re
import sys
import tokenize
CONSUMERS = [
"modules/world_sync/world_sync/pbl_runtime_sql.py",
"modules/world_sync/world_sync/pbl_runtime_tx.py",
"modules/world_sync/world_sync/pbl_runtime_schema.py",
]
AUTHORITY = {
"pbl_runtime_event": "modules/pbl_runtime_ext/models/pbl_runtime_event.json",
"pbl_entity_state": "modules/pbl_runtime_ext/models/pbl_entity_state.json",
}
SQL_HINT = re.compile(r"\b(select|insert\s+into|update|delete\s+from|on\s+duplicate)\b", re.IGNORECASE)
# SQL 语法词/函数词,避免误判为列名
STOPWORDS = {
"select", "from", "where", "insert", "into", "values", "update", "set", "delete",
"and", "or", "not", "null", "is", "in", "on", "as", "by", "order", "group", "limit",
"join", "left", "right", "inner", "outer", "distinct", "count", "max", "min", "sum",
"now", "current_timestamp", "true", "false", "begin", "commit", "rollback", "with",
"returning", "if", "exists", "duplicate", "key", "auto_increment", "using",
}
TABLE_WORDS = {"pbl_runtime_event", "pbl_entity_state"}
def find_root():
here = os.path.dirname(os.path.abspath(__file__))
cur = here
for _ in range(10):
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
def authority_columns(root, table):
with open(os.path.join(root, AUTHORITY[table]), "r", encoding="utf-8") as fh:
data = json.load(fh)
return [f.get("name") for f in (data.get("fields") or []) if isinstance(f, dict) and f.get("name")]
def sql_literals(path):
"""用 tokenize 取出文件中的字符串字面量,仅保留含 SQL 关键字者。"""
with open(path, "rb") as fh:
raw = fh.read()
out = []
try:
for tok in tokenize.tokenize(io.BytesIO(raw).readline):
if tok.type != tokenize.STRING:
continue
try:
val = ast_literal_eval(tok.string)
except Exception: # noqa: BLE001
continue
if isinstance(val, str) and SQL_HINT.search(val):
out.append(val)
except (tokenize.TokenError, IndentationError, SyntaxError):
pass
return out
def ast_literal_eval(src):
"""轻量解析字符串字面量(含 f-string 前缀),失败返回 None。"""
s = src
# 去掉 f/r/b 等前缀
m = re.match(r"^([a-zA-Z]*)", s)
prefix = m.group(1).lower() if m else ""
body = s[len(prefix):]
if "f" in prefix: # f-string把 {expr} 替换为占位,仅取字面部分
body = re.sub(r"\{[^{}]*\}", " ", body)
if "b" in prefix:
body = body.encode("latin-1", "ignore").decode("unicode_escape")
try:
import ast as _ast
return _ast.literal_eval(body) if body[:1] in "\"'" else body
except Exception: # noqa: BLE001
return body
try:
import ast as _ast
return _ast.literal_eval(body)
except Exception: # noqa: BLE001
return None
def columns_from_sql(sql):
cols = set()
cols.update(re.findall(r"`([a-zA-Z_][a-zA-Z_0-9]*)`", sql))
for grp in re.findall(r"INSERT\s+INTO\s+[\w`.]+\s*\(([^)]*)\)", sql, re.IGNORECASE):
for tok in grp.split(","):
tok = tok.strip().strip("`")
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
cols.add(tok)
for grp in re.findall(r"\bSET\s+(.+?)(?:\bWHERE\b|$)", sql, re.IGNORECASE | re.DOTALL):
cols.update(re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*=", grp))
for grp in re.findall(r"\bWHERE\s+(.+?)$", sql, re.IGNORECASE | re.DOTALL):
for tok in re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*(?:=|<|>|!|in\b|is\b)", grp, re.IGNORECASE):
cols.add(tok)
for grp in re.findall(r"\b(?:ORDER\s+BY|GROUP\s+BY)\s+([\w`,\s.]+)", sql, re.IGNORECASE):
for tok in grp.split(","):
tok = tok.strip().strip("`").split()[-1] if tok.strip() else ""
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
cols.add(tok)
for tok in re.findall(r"\b(?:pbl_runtime_event|pbl_entity_state)\.([a-zA-Z_][a-zA-Z_0-9]*)", sql):
cols.add(tok)
return {c for c in cols if c.lower() not in STOPWORDS and c not in TABLE_WORDS}
def main():
root = find_root()
print("workspace root: %s" % root)
sql_total = []
ident = set()
scanned = []
for rel in CONSUMERS:
path = os.path.join(root, rel)
if not os.path.isfile(path):
print(" SKIP (absent): %s" % rel)
continue
scanned.append(rel)
lits = sql_literals(path)
sql_total.extend(lits)
for s in lits:
ident |= columns_from_sql(s)
print("consumer files scanned: %d -> %s" % (len(scanned), scanned))
print("SQL literals found: %d, distinct column identifiers: %d" % (len(sql_total), len(ident)))
auth_cols = {}
for table in AUTHORITY:
auth_cols[table] = authority_columns(root, table)
print("\nauthority columns [%s] (%d cols): %s" % (table, len(auth_cols[table]), auth_cols[table]))
print("\n--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---")
bad = []
for name in sorted(ident):
hit = [t for t in auth_cols if name in auth_cols[t]]
print(" %-22s %s %s" % (name, "OK " if hit else "MISS", ("->" + ",".join(hit)) if hit else "(不在任何权威列集)"))
if not hit:
bad.append(name)
print("\n--- 方向 2: 权威列集中未被 SQL 显式引用者informational---")
for table in AUTHORITY:
unused = [c for c in auth_cols[table] if c not in ident]
print(" [%s] unreferenced=%d %s" % (table, len(unused), unused))
print("\ncode columns checked: %d, missing from authority: %d" % (len(ident), len(bad)))
if bad:
print("MISSING: %s" % bad)
print("RESULT: FAIL")
return 1
print("RESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,36 @@
workspace root: /d/pipeline/workspaces/0/sdlc_general
SKIP (absent): modules/world_sync/world_sync/pbl_runtime_schema.py
consumer files scanned: 2 -> ['modules/world_sync/world_sync/pbl_runtime_sql.py', 'modules/world_sync/world_sync/pbl_runtime_tx.py']
SQL literals found: 9, distinct column identifiers: 18
authority columns [pbl_runtime_event] (25 cols): ['id', 'tenant_id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'session_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'state_version', 'tx_group', 'broadcast', 'created_by', 'created_at', 'updated_at', 'occurred_at']
authority columns [pbl_entity_state] (10 cols): ['tenant_id', 'id', 'session_id', 'entity_id', 'state_json', 'state_version', 'checksum', 'updated_by', 'created_at', 'updated_at']
--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---
AuthorityResolutionError MISS (不在任何权威列集)
ConcurrentStateConflict MISS (不在任何权威列集)
_assert_contract MISS (不在任何权威列集)
execute MISS (不在任何权威列集)
expected_version MISS (不在任何权威列集)
format MISS (不在任何权威列集)
json MISS (不在任何权威列集)
paramstyle MISS (不在任何权威列集)
pyformat MISS (不在任何权威列集)
qmark MISS (不在任何权威列集)
rowcount MISS (不在任何权威列集)
session_id OK ->pbl_runtime_event,pbl_entity_state
state_json OK ->pbl_entity_state
state_version OK ->pbl_runtime_event,pbl_entity_state
tenant_id OK ->pbl_runtime_event,pbl_entity_state
uk_es MISS (不在任何权威列集)
updated_at OK ->pbl_runtime_event,pbl_entity_state
write_event_with_state MISS (不在任何权威列集)
--- 方向 2: 权威列集中未被 SQL 显式引用者informational---
[pbl_runtime_event] unreferenced=21 ['id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'tx_group', 'broadcast', 'created_by', 'created_at', 'occurred_at']
[pbl_entity_state] unreferenced=5 ['id', 'entity_id', 'checksum', 'updated_by', 'created_at']
code columns checked: 18, missing from authority: 13
MISSING: ['AuthorityResolutionError', 'ConcurrentStateConflict', '_assert_contract', 'execute', 'expected_version', 'format', 'json', 'paramstyle', 'pyformat', 'qmark', 'rowcount', 'uk_es', 'write_event_with_state']
RESULT: FAIL

255
scripts/m11b2b_selftest.py Normal file
View File

@ -0,0 +1,255 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M11b-2b 自测:表定义 JSON 符合 database-table-definition-specPM 裁决 Bkey=schema 引用)。
覆盖全部 4 个落点world_sync 真源 2 + 应用打包镜像 2任一不符即 RESULT: FAIL
断言集判定项计入 RESULT
A1 JSON 可解析
A2 根键 == {"key","schema"} key == "schema"引用形态不再自带结构体
A3 引用体内不含 summary/fields/indexes/codes 结构体列集以 authority_path 为唯一真源
A4 各段键集合 白名单引用体键白名单禁止把元信息藏在标准结构体里
A5 authority_module/authority_path/table 必填 authority_path 指向的真源文件真实存在
A7 真源与镜像 sha256 两两相同禁止内容级分叉
U 上游体检项不计入本任务 RESULT但逐条打印 DEFECT 供冒泡留证
A6 权威真源 modules/pbl_runtime_ext/models/*.json 自身对 spec 四段式白名单/抽象类型的符合性
该侧为 M11b-1 已批准产物本任务按 PM 裁决不得反向修改故只体检登记不判定
"""
import hashlib
import json
import os
import sys
SOURCE_TABLES = [
"pbl_runtime_event.json",
"pbl_entity_state.json",
]
# 落点 1world_sync 真源modules 侧,唯一手改入口)
MODULE_SRC_DIR = "modules/world_sync/models"
# 落点 2应用打包镜像只由 sync_models_mirror.py 生成,禁止手工双写)
APP_MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models")
# 权威真源pbl_runtime_extM11b-1 已批准产物,本任务只读体检)
AUTHORITY_DIR = "modules/pbl_runtime_ext/models"
# 引用体允许出现的键(表级元信息全部登记在此白名单内,不得散落到结构体里)
REFERENCE_KEY_WHITELIST = {
"table", "authority_module", "authority_path", "owner", "milestone",
"decision_ref", "decision_basis", "structure_note", "consumers", "mirror_note",
"comment", "note",
}
# database-table-definition-spec 四段式与各段键白名单
SPEC_ROOT_KEYS = {"summary", "fields", "indexes", "codes"}
SPEC_SUMMARY_KEYS = {"name", "title", "primary", "catelog"}
SPEC_FIELD_KEYS = {"name", "title", "type", "length", "dec", "nullable", "default"}
SPEC_INDEX_KEYS = {"name", "idxtype", "idxfields"}
SPEC_CODE_KEYS = {"field", "table", "valuefield", "textfield", "cond"}
ABSTRACT_TYPES = {
"str", "char", "short", "int", "long", "float", "double", "ddouble",
"decimal", "date", "time", "datetime", "timestamp", "text", "bin",
}
NEED_LENGTH = {"str", "char", "float", "double", "ddouble", "decimal"}
NEED_DEC = {"float", "double", "ddouble", "decimal"}
_failures = [] # 判定项失败
_upstream_defects = [] # 上游体检项缺陷(登记不判定)
_assert_count = 0
_upstream_count = 0
def check(cond, label):
global _assert_count
_assert_count += 1
if not cond:
_failures.append(label)
print(" FAIL %s" % label)
else:
print(" ok %s" % label)
return bool(cond)
def check_upstream(cond, label):
global _upstream_count
_upstream_count += 1
if not cond:
_upstream_defects.append(label)
print(" DEFECT %s" % label)
else:
print(" ok %s" % label)
return bool(cond)
def find_root():
here = os.path.dirname(os.path.abspath(__file__))
cur = here
for _ in range(10):
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def load_json(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def validate_reference(path, root):
"""A1~A5world_sync 侧引用文件(真源与镜像执行同一套断言)。"""
print("== reference: %s" % path)
try:
data = load_json(path)
except Exception as exc: # noqa: BLE001
check(False, "A1 json-parseable (%s)" % exc)
return None
check(isinstance(data, dict), "A1 json-is-object")
check(set(data.keys()) == {"key", "schema"}, "A2 root-keys == {key,schema} (got %s)" % sorted(data.keys()))
check(data.get("key") == "schema", 'A2 key == "schema"')
body = data.get("schema")
if not check(isinstance(body, dict), "A2 schema-is-object"):
return None
for seg in ("summary", "fields", "indexes", "codes"):
check(seg not in body, "A3 no self-carried struct: %s" % seg)
extra = set(body.keys()) - REFERENCE_KEY_WHITELIST
check(not extra, "A4 reference keys subset whitelist (extra=%s)" % sorted(extra))
check(bool(str(body.get("table", "")).strip()), "A5 table non-empty")
check(body.get("authority_module") == "pbl_runtime_ext", "A5 authority_module == pbl_runtime_ext")
apath = body.get("authority_path", "")
check(bool(apath), "A5 authority_path non-empty")
if apath:
check(os.path.isfile(os.path.join(root, apath)), "A5 authority file exists: %s" % apath)
return body
def validate_authority(path):
"""A6上游体检pbl_runtime_ext 权威真源对 spec 的符合性,只登记不判定。"""
print("== authority(upstream audit): %s" % path)
if not os.path.isfile(path):
check_upstream(False, "A6 authority file present: %s" % path)
return
try:
data = load_json(path)
except Exception as exc: # noqa: BLE001
check_upstream(False, "A6 json-parseable (%s)" % exc)
return
check_upstream(isinstance(data, dict), "A6 root-is-object")
check_upstream(set(data.keys()) <= SPEC_ROOT_KEYS,
"A6 root-keys subset of four-section (got %s)" % sorted(data.keys()))
summary = data.get("summary") or []
check_upstream(len(summary) == 1, "A6 summary has exactly one record (got %d)" % len(summary))
if summary and isinstance(summary[0], dict):
s0 = summary[0]
check_upstream(set(s0.keys()) <= SPEC_SUMMARY_KEYS,
"A6 summary keys subset whitelist (got %s)" % sorted(s0.keys()))
check_upstream(bool(s0.get("name")), "A6 summary.name non-empty")
check_upstream(isinstance(s0.get("primary"), list) and len(s0.get("primary") or []) > 0,
"A6 summary.primary is non-empty array")
fields = data.get("fields") or []
check_upstream(len(fields) > 0, "A6 fields non-empty")
names = []
for f in fields:
if not isinstance(f, dict):
check_upstream(False, "A6 field-is-object")
continue
fname = f.get("name", "?")
names.append(fname)
check_upstream(set(f.keys()) <= SPEC_FIELD_KEYS,
"A6[%s] field keys subset whitelist (got %s)" % (fname, sorted(f.keys())))
t = f.get("type")
check_upstream(t in ABSTRACT_TYPES, "A6[%s] abstract type (%r)" % (fname, t))
if t in NEED_LENGTH:
check_upstream(isinstance(f.get("length"), int) and f.get("length") > 0,
"A6[%s] positive int length" % fname)
if t in NEED_DEC:
check_upstream(isinstance(f.get("dec"), int) and f.get("dec") > 0, "A6[%s] positive int dec" % fname)
check_upstream(str(f.get("nullable", "no")) in ("yes", "no"), "A6[%s] nullable in yes|no" % fname)
for idx in (data.get("indexes") or []):
if not isinstance(idx, dict):
check_upstream(False, "A6 index-is-object")
continue
iname = idx.get("name", "?")
check_upstream(set(idx.keys()) <= SPEC_INDEX_KEYS,
"A6[%s] index keys subset whitelist (got %s)" % (iname, sorted(idx.keys())))
check_upstream(isinstance(idx.get("idxfields"), list) and len(idx.get("idxfields") or []) > 0,
"A6[%s] idxfields non-empty array" % iname)
check_upstream(idx.get("idxtype") in ("unique", "index"), "A6[%s] idxtype unique|index" % iname)
for ifld in (idx.get("idxfields") or []):
check_upstream(ifld in names, "A6[%s] idxfield exists in fields: %s" % (iname, ifld))
for code in (data.get("codes") or []):
if not isinstance(code, dict):
check_upstream(False, "A6 code-is-object")
continue
check_upstream(set(code.keys()) <= SPEC_CODE_KEYS,
"A6 code keys subset whitelist (got %s)" % sorted(code.keys()))
if code.get("table") == "appcodes_kv":
check_upstream("parentid=" in str(code.get("cond", "")), "A6 appcodes_kv cond uses parentid=")
check_upstream("." not in str(code.get("table", "")), "A6 codes.table has no dot notation")
def main():
root = find_root()
print("workspace root: %s" % root)
paths = []
for name in SOURCE_TABLES:
paths.append(os.path.join(root, MODULE_SRC_DIR, name))
paths.append(os.path.join(root, APP_MIRROR_DIR, name))
print("\n--- A1~A5: 4 个落点全部执行同一套 spec 断言 ---")
for p in paths:
if not os.path.isfile(p):
check(False, "file exists: %s" % p)
continue
validate_reference(p, root)
print("\n--- A7: 真源与镜像 sha256 两两相同 ---")
for name in SOURCE_TABLES:
src = os.path.join(root, MODULE_SRC_DIR, name)
dst = os.path.join(root, APP_MIRROR_DIR, name)
if os.path.isfile(src) and os.path.isfile(dst):
hs, hd = sha256_of(src), sha256_of(dst)
print(" %s\n src=%s\n dst=%s" % (name, hs, hd))
check(hs == hd, "A7 sha256 equal: %s" % name)
else:
check(False, "A7 both files present for sha256: %s" % name)
print("\n--- A6: 权威真源pbl_runtime_ext上游体检只登记不判定 ---")
for name in SOURCE_TABLES:
validate_authority(os.path.join(root, AUTHORITY_DIR, name))
print("\njudged assertions executed: %d" % _assert_count)
print("upstream audit checks executed: %d" % _upstream_count)
if _upstream_defects:
print("UPSTREAM DEFECTS (%d) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner" % len(_upstream_defects))
for d in _upstream_defects:
print(" * %s" % d)
if _failures:
print("FAILURES (%d):" % len(_failures))
for f in _failures:
print(" - %s" % f)
print("RESULT: FAIL")
return 1
print("RESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,320 @@
workspace root: /d/pipeline/workspaces/0/sdlc_general
--- A1~A5: 4 个落点全部执行同一套 spec 断言 ---
== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_runtime_event.json
ok A1 json-is-object
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
ok A2 key == "schema"
ok A2 schema-is-object
ok A3 no self-carried struct: summary
ok A3 no self-carried struct: fields
ok A3 no self-carried struct: indexes
ok A3 no self-carried struct: codes
ok A4 reference keys subset whitelist (extra=[])
ok A5 table non-empty
ok A5 authority_module == pbl_runtime_ext
ok A5 authority_path non-empty
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json
== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_runtime_event.json
ok A1 json-is-object
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
ok A2 key == "schema"
ok A2 schema-is-object
ok A3 no self-carried struct: summary
ok A3 no self-carried struct: fields
ok A3 no self-carried struct: indexes
ok A3 no self-carried struct: codes
ok A4 reference keys subset whitelist (extra=[])
ok A5 table non-empty
ok A5 authority_module == pbl_runtime_ext
ok A5 authority_path non-empty
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json
== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_entity_state.json
ok A1 json-is-object
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
ok A2 key == "schema"
ok A2 schema-is-object
ok A3 no self-carried struct: summary
ok A3 no self-carried struct: fields
ok A3 no self-carried struct: indexes
ok A3 no self-carried struct: codes
ok A4 reference keys subset whitelist (extra=[])
ok A5 table non-empty
ok A5 authority_module == pbl_runtime_ext
ok A5 authority_path non-empty
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json
== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_entity_state.json
ok A1 json-is-object
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
ok A2 key == "schema"
ok A2 schema-is-object
ok A3 no self-carried struct: summary
ok A3 no self-carried struct: fields
ok A3 no self-carried struct: indexes
ok A3 no self-carried struct: codes
ok A4 reference keys subset whitelist (extra=[])
ok A5 table non-empty
ok A5 authority_module == pbl_runtime_ext
ok A5 authority_path non-empty
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json
--- A7: 真源与镜像 sha256 两两相同 ---
pbl_runtime_event.json
src=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e
dst=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e
ok A7 sha256 equal: pbl_runtime_event.json
pbl_entity_state.json
src=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014
dst=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014
ok A7 sha256 equal: pbl_entity_state.json
--- A6: 权威真源pbl_runtime_ext上游体检只登记不判定 ---
== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_runtime_event.json
ok A6 root-is-object
ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary'])
ok A6 summary has exactly one record (got 1)
DEFECT A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths'])
ok A6 summary.name non-empty
ok A6 summary.primary is non-empty array
ok A6 fields non-empty
DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
ok A6[id] abstract type ('long')
ok A6[id] nullable in yes|no
DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[tenant_id] abstract type ('str')
ok A6[tenant_id] positive int length
ok A6[tenant_id] nullable in yes|no
DEFECT A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[event_uid] abstract type ('str')
ok A6[event_uid] positive int length
ok A6[event_uid] nullable in yes|no
DEFECT A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[event_code] abstract type ('str')
ok A6[event_code] positive int length
ok A6[event_code] nullable in yes|no
DEFECT A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[idem_key] abstract type ('str')
ok A6[idem_key] positive int length
ok A6[idem_key] nullable in yes|no
DEFECT A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[world_id] abstract type ('long')
ok A6[world_id] nullable in yes|no
DEFECT A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[scene_id] abstract type ('long')
ok A6[scene_id] nullable in yes|no
DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[session_id] abstract type ('long')
ok A6[session_id] nullable in yes|no
DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[entity_id] abstract type ('str')
ok A6[entity_id] positive int length
ok A6[entity_id] nullable in yes|no
DEFECT A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[actor_id] abstract type ('str')
ok A6[actor_id] positive int length
ok A6[actor_id] nullable in yes|no
DEFECT A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[event_type] abstract type ('str')
ok A6[event_type] positive int length
ok A6[event_type] nullable in yes|no
DEFECT A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
ok A6[payload] abstract type ('text')
ok A6[payload] nullable in yes|no
DEFECT A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
ok A6[payload_json] abstract type ('text')
ok A6[payload_json] nullable in yes|no
DEFECT A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[causation_id] abstract type ('str')
ok A6[causation_id] positive int length
ok A6[causation_id] nullable in yes|no
DEFECT A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[seq] abstract type ('long')
ok A6[seq] nullable in yes|no
DEFECT A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[seq_no] abstract type ('long')
ok A6[seq_no] nullable in yes|no
DEFECT A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[source] abstract type ('str')
ok A6[source] positive int length
ok A6[source] nullable in yes|no
DEFECT A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[state] abstract type ('str')
ok A6[state] positive int length
ok A6[state] nullable in yes|no
DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[state_version] abstract type ('long')
ok A6[state_version] nullable in yes|no
DEFECT A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
ok A6[tx_group] abstract type ('str')
ok A6[tx_group] positive int length
ok A6[tx_group] nullable in yes|no
DEFECT A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[broadcast] abstract type ('short')
ok A6[broadcast] nullable in yes|no
DEFECT A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[created_by] abstract type ('long')
ok A6[created_by] nullable in yes|no
DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[created_at] abstract type ('datetime')
ok A6[created_at] nullable in yes|no
DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
ok A6[updated_at] abstract type ('datetime')
ok A6[updated_at] nullable in yes|no
DEFECT A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
ok A6[occurred_at] abstract type ('datetime')
ok A6[occurred_at] nullable in yes|no
DEFECT A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[uk_event_uid] idxfields non-empty array
ok A6[uk_event_uid] idxtype unique|index
ok A6[uk_event_uid] idxfield exists in fields: tenant_id
ok A6[uk_event_uid] idxfield exists in fields: event_uid
ok A6[uk_event_uid] idxfield exists in fields: created_at
DEFECT A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[uk_tenant_idem] idxfields non-empty array
ok A6[uk_tenant_idem] idxtype unique|index
ok A6[uk_tenant_idem] idxfield exists in fields: tenant_id
ok A6[uk_tenant_idem] idxfield exists in fields: idem_key
ok A6[uk_tenant_idem] idxfield exists in fields: created_at
DEFECT A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_scene_created] idxfields non-empty array
ok A6[ix_scene_created] idxtype unique|index
ok A6[ix_scene_created] idxfield exists in fields: scene_id
ok A6[ix_scene_created] idxfield exists in fields: created_at
DEFECT A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_tenant_session_seq] idxfields non-empty array
ok A6[ix_tenant_session_seq] idxtype unique|index
ok A6[ix_tenant_session_seq] idxfield exists in fields: tenant_id
ok A6[ix_tenant_session_seq] idxfield exists in fields: session_id
ok A6[ix_tenant_session_seq] idxfield exists in fields: seq
DEFECT A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_tenant_session_seqno] idxfields non-empty array
ok A6[ix_tenant_session_seqno] idxtype unique|index
ok A6[ix_tenant_session_seqno] idxfield exists in fields: tenant_id
ok A6[ix_tenant_session_seqno] idxfield exists in fields: session_id
ok A6[ix_tenant_session_seqno] idxfield exists in fields: seq_no
DEFECT A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_tenant_world_type] idxfields non-empty array
ok A6[ix_tenant_world_type] idxtype unique|index
ok A6[ix_tenant_world_type] idxfield exists in fields: tenant_id
ok A6[ix_tenant_world_type] idxfield exists in fields: world_id
ok A6[ix_tenant_world_type] idxfield exists in fields: event_type
DEFECT A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_tx_group] idxfields non-empty array
ok A6[ix_tx_group] idxtype unique|index
ok A6[ix_tx_group] idxfield exists in fields: tx_group
DEFECT A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
ok A6[ix_created_at] idxfields non-empty array
ok A6[ix_created_at] idxtype unique|index
ok A6[ix_created_at] idxfield exists in fields: created_at
DEFECT A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield'])
ok A6 appcodes_kv cond uses parentid=
ok A6 codes.table has no dot notation
== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_entity_state.json
ok A6 root-is-object
ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary'])
ok A6 summary has exactly one record (got 1)
DEFECT A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped'])
ok A6 summary.name non-empty
DEFECT A6 summary.primary is non-empty array
ok A6 fields non-empty
DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
ok A6[tenant_id] abstract type ('str')
ok A6[tenant_id] positive int length
ok A6[tenant_id] nullable in yes|no
DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned'])
ok A6[id] abstract type ('int')
ok A6[id] nullable in yes|no
DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
DEFECT A6[session_id] abstract type ('bigint')
ok A6[session_id] nullable in yes|no
DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
ok A6[entity_id] abstract type ('str')
ok A6[entity_id] positive int length
ok A6[entity_id] nullable in yes|no
DEFECT A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
DEFECT A6[state_json] abstract type ('json')
ok A6[state_json] nullable in yes|no
DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
DEFECT A6[state_version] abstract type ('bigint')
ok A6[state_version] nullable in yes|no
DEFECT A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
ok A6[checksum] abstract type ('str')
ok A6[checksum] positive int length
ok A6[checksum] nullable in yes|no
DEFECT A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
ok A6[updated_by] abstract type ('str')
ok A6[updated_by] positive int length
ok A6[updated_by] nullable in yes|no
DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
ok A6[created_at] abstract type ('datetime')
ok A6[created_at] nullable in yes|no
DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
ok A6[updated_at] abstract type ('datetime')
ok A6[updated_at] nullable in yes|no
DEFECT A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique'])
DEFECT A6[uk_es] idxfields non-empty array
DEFECT A6[uk_es] idxtype unique|index
DEFECT A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique'])
DEFECT A6[PRIMARY] idxfields non-empty array
DEFECT A6[PRIMARY] idxtype unique|index
judged assertions executed: 54
upstream audit checks executed: 186
UPSTREAM DEFECTS (56) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner
* A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths'])
* A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
* A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
* A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
* A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
* A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
* A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
* A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
* A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
* A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
* A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield'])
* A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped'])
* A6 summary.primary is non-empty array
* A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
* A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned'])
* A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
* A6[session_id] abstract type ('bigint')
* A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
* A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
* A6[state_json] abstract type ('json')
* A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
* A6[state_version] abstract type ('bigint')
* A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
* A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
* A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
* A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
* A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique'])
* A6[uk_es] idxfields non-empty array
* A6[uk_es] idxtype unique|index
* A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique'])
* A6[PRIMARY] idxfields non-empty array
* A6[PRIMARY] idxtype unique|index
RESULT: PASS

View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M11b-2b: 把 modules/world_sync/models/ 下的表定义真源同步到应用打包镜像落点。
铁律apps/{app}/pkgs/{module}/models/*.json build 阶段产物禁止手工双写
本脚本是唯一允许的写入通道 真源只有一份modules/ 镜像由 cp 语义逐字节覆盖生成
用法
python3 modules/world_sync/scripts/sync_models_mirror.py
python3 modules/world_sync/scripts/sync_models_mirror.py --check # 只校验不写,不一致退出码 1
"""
import argparse
import hashlib
import os
import shutil
import sys
# 表定义真源清单(相对机构工作空间根)
SOURCE_TABLES = [
"pbl_runtime_event.json",
"pbl_entity_state.json",
]
MODULE_ROOT = "modules/world_sync"
SOURCE_DIR = os.path.join(MODULE_ROOT, "models")
MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models")
def _find_workspace_root():
"""从脚本位置向上回溯,找到同时含 modules/ 与 apps/ 的工作空间根。"""
here = os.path.dirname(os.path.abspath(__file__))
cur = here
for _ in range(10):
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
# 兜底:脚本位于 modules/{m}/scripts/x.py则根为上三级
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def sync(root, check_only=False):
"""同步真源 -> 镜像。返回 (是否全部一致, 明细列表)。"""
src_dir = os.path.join(root, SOURCE_DIR)
dst_dir = os.path.join(root, MIRROR_DIR)
details = []
all_ok = True
if not check_only:
os.makedirs(dst_dir, exist_ok=True)
for name in SOURCE_TABLES:
src = os.path.join(src_dir, name)
dst = os.path.join(dst_dir, name)
if not os.path.isfile(src):
details.append("MISSING-SOURCE: %s" % src)
all_ok = False
continue
if not check_only:
shutil.copyfile(src, dst)
src_hash = sha256_of(src)
if not os.path.isfile(dst):
details.append("MISSING-MIRROR: %s" % dst)
all_ok = False
continue
dst_hash = sha256_of(dst)
same = (src_hash == dst_hash)
all_ok = all_ok and same
details.append("%s %s" % ("SAME" if same else "DIFF", name))
details.append(" sha256 src=%s" % src_hash)
details.append(" sha256 dst=%s" % dst_hash)
return all_ok, details
def main(argv=None):
parser = argparse.ArgumentParser(description="sync world_sync table-definition sources to app mirror")
parser.add_argument("--check", action="store_true", help="only verify, do not write")
args = parser.parse_args(argv)
root = _find_workspace_root()
ok, details = sync(root, check_only=args.check)
print("workspace root: %s" % root)
print("mode: %s" % ("check" if args.check else "sync"))
for line in details:
print(line)
print("RESULT: %s" % ("PASS" if ok else "FAIL"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
"""pbl_runtime_authority —— M11b-2b``key=schema`` 引用的解析层(列名唯一权威源)。
**为什么存在**
PM 裁决任务 ``u0EO4x3ajntR41acaHj5H``方案 B``pbl_runtime_event`` /
``pbl_entity_state`` 两张表的**唯一真源** ``modules/pbl_runtime_ext/models/*.json``
M11b-1 已批准产物world_sync ``models/*.json`` 只保留 ``key=schema`` 引用
**不再自带** summary/fields/indexes/codes 结构体
于是消费方列契约必须从权威定义**机械导出**而不是人工抄一遍人工双写正是
QC #1/#3 判定的「多份互相矛盾定义」的根因。本模块就是那个导出层:
* :func:`load_reference` 读本模块 ``models/<table>.json``key=schema 引用
* :func:`authority_model` 顺着引用解析到 pbl_runtime_ext 的真源 JSON
* :func:`columns` / :func:`insert_columns` / :func:`primary` / :func:`indexes`
暴露权威列集``insert_columns`` 自动剔除 auto_increment
* :func:`ddl` 由权威列集渲染本地建表语句仅供测试/本地起库生产 DDL
分区与 append-only 触发器归 pbl_runtime_ext ``scripts/pbl_runtime_event_ddl.py``
``scripts/migrations/*.sql`` 所有本模块不复制那套逻辑
**fail-closed**引用缺失真源找不到真源不是四段式列集为空 一律抛
:class:`AuthorityResolutionError`绝不退化成猜列名
"""
import json
import os
__all__ = [
"AuthorityResolutionError",
"REFERENCE_ROOT_KEYS",
"REFERENCE_SCHEMA_KEYS",
"SPEC_ROOT_KEYS",
"load_reference",
"authority_path",
"authority_model",
"columns",
"insert_columns",
"auto_increment_columns",
"field_map",
"primary",
"indexes",
"table_title",
"ddl",
"column_diff",
]
# models/<table>.json 是 key=schema 引用:根键只允许这两个
REFERENCE_ROOT_KEYS = ("key", "schema")
# 引用体内允许的元信息键(白名单,自测脚本按此做机械断言)
REFERENCE_SCHEMA_KEYS = (
"table", "authority_module", "authority_path", "owner", "milestone",
"decision_ref", "decision_basis", "structure_note", "consumers", "mirror_note",
)
# 真源(表定义)必须是四段式
SPEC_ROOT_KEYS = ("summary", "fields", "indexes", "codes")
MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(MODULE_ROOT, "models")
# modules/world_sync -> modules -> 机构工作空间根
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(MODULE_ROOT))
_SQLITE_TYPES = {
"str": "TEXT", "char": "TEXT", "text": "TEXT", "json": "TEXT",
"bin": "BLOB", "short": "INTEGER", "int": "INTEGER", "long": "INTEGER",
"bigint": "INTEGER", "float": "REAL", "double": "REAL", "decimal": "REAL",
"ddouble": "REAL", "date": "TEXT", "time": "TEXT", "datetime": "TEXT",
"timestamp": "TEXT",
}
_MYSQL_TYPES = {
"str": "VARCHAR({length})", "char": "CHAR({length})", "text": "LONGTEXT",
"json": "JSON", "bin": "LONGBLOB", "short": "SMALLINT", "int": "INT",
"long": "BIGINT", "bigint": "BIGINT", "float": "FLOAT",
"double": "DECIMAL({length},{dec})", "decimal": "DECIMAL({length},{dec})",
"ddouble": "DECIMAL({length},{dec})", "date": "DATE", "time": "TIME",
"datetime": "DATETIME(3)", "timestamp": "TIMESTAMP",
}
class AuthorityResolutionError(Exception):
"""引用/真源解析失败fail-closed调用方不得吞掉"""
def __init__(self, message, **details):
Exception.__init__(self, message)
self.message = message
self.details = details
def __str__(self):
if not self.details:
return self.message
parts = ", ".join("%s=%r" % (k, v) for k, v in sorted(self.details.items()))
return "%s (%s)" % (self.message, parts)
def _reference_dir():
return MODELS_DIR
def load_reference(table):
"""读取本模块 ``models/<table>.json``,校验它是合法的 key=schema 引用。"""
if not table or not isinstance(table, str):
raise AuthorityResolutionError("table name is required", table=table)
path = os.path.join(_reference_dir(), table + ".json")
if not os.path.isfile(path):
raise AuthorityResolutionError("schema reference missing", path=path)
try:
with open(path, "r", encoding="utf-8") as fh:
ref = json.load(fh)
except ValueError as exc:
raise AuthorityResolutionError("schema reference not valid JSON: %s" % exc,
path=path)
if not isinstance(ref, dict) or ref.get("key") != "schema":
raise AuthorityResolutionError(
"models/%s.json must be a key=schema reference (no local structure)" % table,
path=path, root_keys=sorted(ref) if isinstance(ref, dict) else type(ref).__name__)
extra = set(ref) - set(REFERENCE_ROOT_KEYS)
if extra:
raise AuthorityResolutionError("reference carries non-whitelist root keys",
path=path, extra=sorted(extra))
schema = ref.get("schema")
if not isinstance(schema, dict):
raise AuthorityResolutionError("reference.schema must be an object", path=path)
if schema.get("table") != table:
raise AuthorityResolutionError("reference.schema.table mismatches filename",
path=path, declared=schema.get("table"))
bad = set(schema) - set(REFERENCE_SCHEMA_KEYS)
if bad:
raise AuthorityResolutionError("reference.schema carries non-whitelist keys",
path=path, extra=sorted(bad))
return ref
def authority_path(table):
"""把引用解析成磁盘上真实存在的真源路径(多候选,全部落空即报错)。"""
ref = load_reference(table)
schema = ref["schema"]
mod = schema.get("authority_module")
candidates = []
declared = schema.get("authority_path")
if declared:
candidates.append(os.path.normpath(os.path.join(WORKSPACE_ROOT, declared)))
candidates.append(os.path.normpath(os.path.join(MODULE_ROOT, declared)))
if mod:
candidates.append(os.path.join(WORKSPACE_ROOT, "modules", mod, "models",
table + ".json"))
candidates.append(os.path.join(WORKSPACE_ROOT, "apps", "scense", "pkgs", mod,
"models", table + ".json"))
candidates.append(os.path.normpath(os.path.join(MODULE_ROOT, os.pardir, mod,
"models", table + ".json")))
tried = []
for cand in candidates:
if cand in tried:
continue
tried.append(cand)
if os.path.isfile(cand):
return cand
raise AuthorityResolutionError("authority model not found for table",
table=table, tried=tried)
_MODEL_CACHE = {}
def authority_model(table):
"""读取并校验真源 JSON四段式 + fields 非空),带缓存。"""
if table in _MODEL_CACHE:
return _MODEL_CACHE[table]
path = authority_path(table)
try:
with open(path, "r", encoding="utf-8") as fh:
model = json.load(fh)
except ValueError as exc:
raise AuthorityResolutionError("authority model not valid JSON: %s" % exc,
path=path)
if not isinstance(model, dict):
raise AuthorityResolutionError("authority model must be an object", path=path)
missing = set(SPEC_ROOT_KEYS) - set(model)
if missing:
raise AuthorityResolutionError("authority model is not four-section",
path=path, missing=sorted(missing))
fields = model.get("fields")
if not isinstance(fields, list) or not fields:
raise AuthorityResolutionError("authority model has no fields", path=path)
_MODEL_CACHE[table] = model
return model
def field_map(table):
"""``{列名: 列定义 dict}``(保持真源顺序)。"""
out = {}
for f in authority_model(table)["fields"]:
name = f.get("name")
if not name:
raise AuthorityResolutionError("authority field without name", table=table)
out[name] = f
return out
def columns(table):
"""权威全列集(有序元组)。"""
return tuple(field_map(table).keys())
def auto_increment_columns(table):
"""由数据库自增生成的列INSERT 时不得出现)。"""
return tuple(n for n, f in field_map(table).items()
if f.get("auto_increment") or str(f.get("default", "")).upper()
in ("AUTO_INCREMENT", "IDENTITY"))
def insert_columns(table):
"""可显式写入的列 = 全列 - 自增列(消费方 INSERT 的列契约)。"""
gen = set(auto_increment_columns(table))
return tuple(n for n in columns(table) if n not in gen)
def primary(table):
summary = authority_model(table).get("summary") or []
if not summary or not isinstance(summary[0], dict):
raise AuthorityResolutionError("authority model has no summary[0]", table=table)
pk = summary[0].get("primary") or summary[0].get("key")
if isinstance(pk, str):
pk = [p.strip() for p in pk.split(",") if p.strip()]
return tuple(pk or ())
def indexes(table):
"""规范化索引:``[{'name','idxtype','idxfields'}]``(兼容 unique/fields 写法)。"""
out = []
for idx in authority_model(table).get("indexes") or []:
fields = idx.get("idxfields") or idx.get("fields") or []
if isinstance(fields, str):
fields = [f.strip() for f in fields.split(",") if f.strip()]
kind = idx.get("idxtype") or ("unique" if idx.get("unique") else "index")
out.append({"name": idx.get("name"), "idxtype": kind, "idxfields": tuple(fields)})
return out
def table_title(table):
summary = authority_model(table).get("summary") or [{}]
return summary[0].get("title") or summary[0].get("name") or table
def _render_type(f, dialect):
t = str(f.get("type") or "").lower()
table = _MYSQL_TYPES if dialect == "mysql" else _SQLITE_TYPES
tpl = table.get(t)
if tpl is None:
raise AuthorityResolutionError("unknown abstract type in authority model",
field=f.get("name"), type=t)
if "{" in tpl:
length = f.get("length") or (255 if t == "str" else 10)
dec = f.get("dec") or 2
return tpl.format(length=length, dec=dec)
return tpl
def _nullable(f):
if "nullable" in f:
return str(f.get("nullable")).lower() != "no"
if "null" in f:
v = f.get("null")
return v is True or str(v).lower() == "true"
return True
def ddl(table, dialect="sqlite"):
"""由权威列集渲染 CREATE TABLE测试/本地起库用)。
生产库的 DDL分区append-only 触发器存储过程 pbl_runtime_ext 所有
本函数**只保证列集与主键/唯一键一致**不复制分区与触发器逻辑
"""
if dialect not in ("sqlite", "mysql"):
raise AuthorityResolutionError("unsupported ddl dialect", dialect=dialect)
model = authority_model(table)
lines = []
for f in model["fields"]:
col = "`%s`" % f["name"] if dialect == "mysql" else f["name"]
piece = [" %s %s" % (col, _render_type(f, dialect))]
gen = bool(f.get("auto_increment"))
if dialect == "mysql" and gen:
piece.append(" AUTO_INCREMENT")
if not _nullable(f) and not gen:
piece.append(" NOT NULL")
default = f.get("default")
if default is not None and not gen and str(default) != "":
piece.append(" DEFAULT %s" % ("CURRENT_TIMESTAMP"
if str(default).upper() == "CURRENT_TIMESTAMP"
else "'%s'" % default))
if dialect == "mysql" and f.get("title"):
piece.append(" COMMENT '%s'" % str(f["title"]).replace("'", "''"))
lines.append("".join(piece))
pk = primary(table)
if pk:
pk_sql = ", ".join(("`%s`" % c) if dialect == "mysql" else c for c in pk)
lines.append(" PRIMARY KEY (%s)" % pk_sql)
for idx in indexes(table):
if not idx["idxfields"] or idx["name"] in (None, "PRIMARY"):
continue
cols = ", ".join(("`%s`" % c) if dialect == "mysql" else c
for c in idx["idxfields"])
if idx["idxtype"] == "unique":
lines.append(" UNIQUE KEY `%s` (%s)" % (idx["name"], cols)
if dialect == "mysql"
else " UNIQUE (%s)" % cols)
else:
lines.append(" KEY `%s` (%s)" % (idx["name"], cols))
body = ",\n".join(lines)
if dialect == "mysql":
return ("CREATE TABLE IF NOT EXISTS `%s` (\n%s\n) "
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'"
% (table, body, table_title(table)))
return "CREATE TABLE IF NOT EXISTS %s (\n%s\n)" % (table, body)
def column_diff(table, used_columns):
"""双向列集 diff返回 (未知列, 未覆盖的非自增列)。
``used_columns`` 是消费方实际写入/读取用到的列名集合空集合表示不检查覆盖
"""
used = set(used_columns or ())
all_cols = set(columns(table))
unknown = sorted(used - all_cols)
uncovered = []
if used:
covered = used | set(auto_increment_columns(table))
uncovered = sorted(all_cols - covered)
return unknown, uncovered

View File

@ -3,27 +3,38 @@
对外唯一入口:func:`write_event_with_state`
**列契约来源M11b-2b / PM 裁决方案 B**本文件不再自带列名常量全部列名由
:mod:`world_sync.pbl_runtime_authority` ``key=schema`` 真源
``modules/pbl_runtime_ext/models/pbl_runtime_event.json`` /
``pbl_entity_state.json``机械导出列名与真源不一致时 :func:`_assert_contract`
在建 SQL 之前就抛 :class:`AuthorityResolutionError`fail-closed杜绝代码里
抄一套列名JSON 里另一套的双写分叉
事务内动作顺序固定全部在同一事务中
1. ``BEGIN``sqlite ``BEGIN IMMEDIATE``立刻取写锁避免锁升级竞态
2. 会话级 ``READ COMMITTED``MySQL/PGsqlite 无此概念跳过
3. ``pbl_entity_state`` 当前 ``state_version``MySQL/PG 追加 ``FOR UPDATE`` 悲观行锁
3. ``pbl_entity_state`` 当前 ``state_version`` tenant_id + session_id +
entity_id 定位与真源唯一键 ``uk_es`` 完全一致MySQL/PG 追加 ``FOR UPDATE``
4. 乐观锁校验调用方给了 ``expected_version`` 且不匹配
:class:`ConcurrentStateConflict` ROLLBACK防丢失更新
5. 乐观锁 ``UPDATE ... WHERE state_version = <读到的版本>``
``rowcount != 1`` 视为并发冲突 ROLLBACK + :class:`ConcurrentStateConflict`
状态行不存在时 INSERT 新行键冲突同样映射为并发冲突
状态行不存在时 INSERT 新行唯一键冲突同样映射为并发冲突
6. ``pbl_runtime_event`` 插入 1 append-only 事件带第 5 步生效后的新版本号
7. ``COMMIT``任何一步失败 ``ROLLBACK`` + 明确异常不吞错误
原子性保证 6 步失败会把第 5 步的更新一起回滚同一事务 5 步冲突则两表都 0 变更
范围之外明确不做广播轮询兜底时延优化事件回放
范围之外明确不做广播轮询兜底时延优化事件回放广播/幂等派生键归
pbl_runtime_ext.tx_write本模块只做一次事务写两表的最小写入器
"""
import hashlib
import json
import time
import uuid
from . import pbl_runtime_sql as ps
from . import pbl_runtime_authority as auth
from .pbl_runtime_errors import (
ConcurrentStateConflict,
EventWriteError,
@ -36,27 +47,60 @@ from .pbl_runtime_tx_env import resolve_conn_factory
__all__ = [
"write_event_with_state",
"read_entity_state",
"new_event_uid",
"new_event_id",
"utc_now_text",
"DEFAULT_EVENT_TYPE",
"DEFAULT_SOURCE",
"EVENT_REQUIRED",
"STATE_KEY_COLUMNS",
]
DEFAULT_EVENT_TYPE = "state.updated"
DEFAULT_SOURCE = "world_sync.m11b2"
MAX_ERROR_DETAIL = 512
# 事件必填列(真源中 nullable=no 且无默认值的业务列)
EVENT_REQUIRED = ("tenant_id", "event_uid", "entity_id", "event_type")
# 实体状态定位键 —— 与真源 pbl_entity_state 的 uk_es 唯一键一致
STATE_KEY_COLUMNS = ("tenant_id", "session_id", "entity_id")
def new_event_id():
"""事件主键uuid4 文本append-only 事件无需有序 ID"""
def new_event_uid():
"""事件业务唯一键 ``event_uid``uuid4 去横线 hex32 位append-only 无需有序 ID"""
return uuid.uuid4().hex
# 旧名字保留为别名M11b-2a 的调用方/测试用 new_event_id()
new_event_id = new_event_uid
def utc_now_text(ts=None):
"""ISO8601 UTC 秒级时间戳文本sqlite TEXT / MySQL DATETIME(3) 均可直接写入)。"""
if ts is None:
ts = time.time()
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
def _checksum(state_json_text):
"""状态摘要(真源 ``checksum`` 列str 32sha256 前 32 位十六进制。"""
return hashlib.sha256(str(state_json_text).encode("utf-8")).hexdigest()[:32]
def _coerce_id(value, column):
"""真源中 ``world_id`` / ``scene_id`` / ``session_id`` / ``created_by`` 是 long。
纯数字含数字字符串 int 落库非数字标识符原样透传 DB /上层迁移
决定是否扩列但会在返回前做一次可序列化性检查不静默丢列不悄悄改类型
"""
if value is None or isinstance(value, bool):
return value
if isinstance(value, int):
return value
text = str(value).strip()
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
return int(text)
return value
def _dumps(value):
@ -85,6 +129,16 @@ def _require(ctx, name):
return value
def _assert_contract(table, used):
"""机械核对本模块用到的列必须全部存在于真源列集否则立刻抛错fail-closed"""
unknown, _uncovered = auth.column_diff(table, used)
if unknown:
raise auth.AuthorityResolutionError(
"consumer uses columns absent from authority schema",
table=table, unknown=unknown)
return True
def _execute(cur, sql, params, dialect):
"""统一执行入口sqlite 用位置参数MySQL 用 %s 位置参数(驱动一致)。"""
if params is None:
@ -92,10 +146,11 @@ def _execute(cur, sql, params, dialect):
return cur.execute(sql, tuple(params))
def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
def _fetch_state_version(cur, dialect, tenant_id, session_id, entity_id):
"""读当前状态版本;行不存在返回 ``None``(首次写入)。"""
_assert_contract(ps.STATE_TABLE, STATE_KEY_COLUMNS + ("state_version",))
sql = (
"SELECT state_version FROM %s WHERE tenant_id = %s AND world_id = %s "
"SELECT state_version FROM %s WHERE tenant_id = %s AND session_id = %s "
"AND entity_id = %s%s"
% (
ps.STATE_TABLE,
@ -105,7 +160,7 @@ def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
ps.for_update_clause(dialect),
)
)
_execute(cur, sql, [tenant_id, world_id, entity_id], dialect)
_execute(cur, sql, [tenant_id, session_id, entity_id], dialect)
row = cur.fetchone()
if not row:
return None
@ -115,13 +170,17 @@ def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
raise StateWriteError("state_version is not an integer", row=repr(row))
def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
current_version, next_version, event_id, now_text):
"""乐观锁更新状态行;返回 ``'updated'`` / ``'inserted'``。冲突抛异常。"""
def _update_state(cur, dialect, tenant_id, session_id, entity_id, state_json,
current_version, next_version, updated_by, now_text):
"""乐观锁更新状态行;返回 ``'updated'``。冲突抛异常。"""
used = STATE_KEY_COLUMNS + ("state_json", "state_version", "updated_at",
"updated_by", "checksum")
_assert_contract(ps.STATE_TABLE, used)
sql = (
"UPDATE %s SET state = %s, state_version = %s, updated_at = %s, "
"updated_by_event = %s "
"WHERE tenant_id = %s AND world_id = %s AND entity_id = %s AND state_version = %s"
"UPDATE %s SET state_json = %s, state_version = %s, updated_at = %s, "
"updated_by = %s, checksum = %s "
"WHERE tenant_id = %s AND session_id = %s AND entity_id = %s "
"AND state_version = %s"
% (
ps.STATE_TABLE,
ps.placeholder(dialect, 0),
@ -132,11 +191,12 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
ps.placeholder(dialect, 5),
ps.placeholder(dialect, 6),
ps.placeholder(dialect, 7),
ps.placeholder(dialect, 8),
)
)
params = [
state_json, next_version, now_text, event_id,
tenant_id, world_id, entity_id, current_version,
state_json, next_version, now_text, updated_by, _checksum(state_json),
tenant_id, session_id, entity_id, current_version,
]
try:
_execute(cur, sql, params, dialect)
@ -144,7 +204,7 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
_rollback_quiet(cur)
raise StateWriteError(
"failed to update entity state: %s" % _truncate(exc),
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
)
if getattr(cur, "rowcount", 0) == 1:
return "updated"
@ -154,34 +214,41 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
_rollback_quiet(cur)
raise ConcurrentStateConflict(
"optimistic lock failed: state_version changed concurrently",
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
expected_version=current_version, target_version=next_version,
)
def _insert_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
next_version, event_id, now_text):
"""首次写入状态行;主键冲突说明并发插入,同样按冲突处理。"""
cols = ("tenant_id", "world_id", "entity_id", "state", "state_version",
"updated_at", "updated_by_event")
def _insert_state(cur, dialect, tenant_id, session_id, entity_id, state_json,
next_version, updated_by, now_text):
"""首次写入状态行;唯一键冲突说明并发插入,同样按冲突处理。"""
cols = ("tenant_id", "session_id", "entity_id", "state_json", "state_version",
"checksum", "updated_by", "created_at", "updated_at")
_assert_contract(ps.STATE_TABLE, cols)
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
ps.STATE_TABLE, ", ".join(cols), ps.render_placeholders(dialect, len(cols))
)
params = [tenant_id, world_id, entity_id, state_json, next_version, now_text, event_id]
params = [tenant_id, session_id, entity_id, state_json, next_version,
_checksum(state_json), updated_by, now_text, now_text]
try:
_execute(cur, sql, params, dialect)
except Exception as exc:
_rollback_quiet(cur)
raise ConcurrentStateConflict(
"concurrent insert of entity state row: %s" % _truncate(exc),
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
)
return "inserted"
def _insert_event(cur, dialect, record):
"""插入 append-only 事件行。失败即抛(上层回滚),保证「事件失败 → 状态也回滚」"""
"""插入 append-only 事件行(列集 = 真源可写列)。失败即抛(上层回滚)。"""
cols = ps.EVENT_COLUMNS
_assert_contract(ps.EVENT_TABLE, cols)
missing = [c for c in EVENT_REQUIRED if record.get(c) in (None, "")]
if missing:
raise TxConfigError("event record missing required authority columns",
missing=missing)
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
ps.EVENT_TABLE, ", ".join(cols), ps.render_placeholders(dialect, len(cols))
)
@ -192,13 +259,13 @@ def _insert_event(cur, dialect, record):
_rollback_quiet(cur)
raise EventWriteError(
"failed to insert runtime event: %s" % _truncate(exc),
event_id=record["event_id"], entity_id=record["entity_id"],
event_uid=record["event_uid"], entity_id=record["entity_id"],
)
return True
def _rollback_quiet(cur):
"""回滚,但绝不因回滚本身的异常覆盖业务异常(回滚失败另抛 TxAbortError 由调用处判断)"""
"""回滚,但绝不因回滚本身的异常覆盖业务异常"""
try:
cur.connection.rollback()
except Exception:
@ -228,21 +295,24 @@ def _set_isolation(cur, dialect):
return False
def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_id=None,
conn=None):
def read_entity_state(conn_factory=None, tenant_id=None, session_id=None,
entity_id=None, world_id=None, conn=None):
"""只读辅助:取实体当前状态(供测试断言 / 上层重试前重读版本)。
独立短事务不与写入事务共用避免长事务
独立短事务不与写入事务共用避免长事务``world_id`` 仅作兼容入参真源
``pbl_entity_state`` 无该列定位键是 tenant_id + session_id + entity_id
"""
own_conn = conn is None
if own_conn:
conn = resolve_conn_factory(conn_factory)()
dialect = ps.detect_dialect(conn)
_assert_contract(ps.STATE_TABLE, STATE_KEY_COLUMNS + ("state_json", "state_version",
"updated_at"))
cur = conn.cursor()
try:
sql = (
"SELECT state, state_version, updated_at FROM %s "
"WHERE tenant_id = %s AND world_id = %s AND entity_id = %s"
"SELECT state_json, state_version, updated_at FROM %s "
"WHERE tenant_id = %s AND session_id = %s AND entity_id = %s"
% (
ps.STATE_TABLE,
ps.placeholder(dialect, 0),
@ -250,7 +320,7 @@ def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_i
ps.placeholder(dialect, 2),
)
)
_execute(cur, sql, [tenant_id, world_id, entity_id], dialect)
_execute(cur, sql, [tenant_id, session_id, entity_id], dialect)
row = cur.fetchone()
if own_conn:
conn.commit()
@ -267,23 +337,26 @@ def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_i
def write_event_with_state(event_data=None, state_update=None, conn_factory=None,
conn=None, event_id=None, source=None, clock=None):
conn=None, event_uid=None, source=None, clock=None):
"""在**同一个数据库事务**内写入 1 条运行时事件 + 实体状态更新M11b-2 核心)。
:param event_data: dict事件字段必须含 ``tenant_id`` / ``world_id`` / ``entity_id``
可选 ``event_type``默认 ``state.updated````payload````session_id``
``causation_id````source``
:param state_update: dict状态更新必须含 ``tenant_id`` / ``world_id`` / ``entity_id``
``state`` 为新的完整状态dict JSON 文本可选 ``expected_version``
乐观锁期望的当前版本不匹配即冲突回滚 ``None`` 表示只写事件不改状态
:param event_data: dict事件字段必须含 ``tenant_id`` / ``entity_id`` /
``event_type````event_uid`` 缺省由本函数生成uuid4 hex可选
``payload````session_id````scene_id````world_id````actor_id``
``causation_id````idem_key````seq``/``seq_no````tx_group````source``
:param state_update: dict状态更新必须含 ``tenant_id`` / ``session_id`` /
``entity_id````state`` ``state_json``为新的完整状态dict JSON 文本
可选 ``expected_version``乐观锁期望当前版本不匹配即冲突回滚``updated_by``
``None`` 表示只写事件不改状态
:param conn_factory: 三级优先级第 1 显式注入也可传 DB URL 字符串
:param conn: 复用已有连接测试 / 宿主事务嵌套用此时由本函数负责该连接的
commit/rollback调用方不要自行 commit
:param event_id: 指定事件主默认 uuid4 hex便于幂等重试与因果链测试
:param source: 写入来源标识默认 ``world_sync.m11b2``
:param event_uid: 指定事件唯一默认 uuid4 hex便于幂等重试与因果链测试
:param source: 写入来源标识默认 ``world_sync.m11b2``落真源 ``source``
:param clock: 可注入时间函数默认 ``time.time``便于测试固定时间戳
:returns: dict ``event_id`` / ``state_version`` / ``action``updated|inserted|
event_only/ ``elapsed_ms`` / ``isolation_level_applied`` / ``dialect``
:returns: dict ``event_uid`` / ``state_version`` / ``action``updated|inserted|
event_only/ ``elapsed_ms`` / ``isolation_level_applied`` / ``dialect`` /
``authority``本次列契约的真源路径便于审计与 QC 复核
:raises TxConfigError: 必填字段缺失方言不认识
:raises ConcurrentStateConflict: 乐观锁/行锁冲突两表零变更已回滚
:raises EventWriteError: 事件插入失败两表零变更已回滚
@ -295,23 +368,29 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
clock = clock or time.time
tenant_id = _require(event_data, "tenant_id")
world_id = _require(event_data, "world_id")
entity_id = _require(event_data, "entity_id")
event_type = event_data.get("event_type") or DEFAULT_EVENT_TYPE
event_uid = event_uid or event_data.get("event_uid") or new_event_uid()
session_id = event_data.get("session_id")
world_id = _coerce_id(event_data.get("world_id"), "world_id")
if state_update is not None:
for key in ("tenant_id", "world_id", "entity_id"):
for key in STATE_KEY_COLUMNS:
if key in state_update and state_update[key] is not None \
and key in event_data and event_data[key] is not None \
and state_update[key] != event_data[key]:
raise TxConfigError(
"state_update.%s conflicts with event_data.%s" % (key, key),
field=key,
)
state_update.setdefault(key, event_data[key])
state_update.setdefault(key, event_data.get(key))
for key in STATE_KEY_COLUMNS:
_require(state_update, key)
session_id = state_update["session_id"]
# 租户/世界隔离维度在 SQL 的 WHERE 与 INSERT 列里都强制带上,
# 跨租户的同名 entity_id 不可能互相影响(见 _fetch_state_version / _update_state
expected_version = None
new_state_json = None
updated_by = None
if state_update is not None:
expected_version = state_update.get("expected_version")
if expected_version is not None:
@ -320,7 +399,11 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
except (TypeError, ValueError):
raise TxConfigError("expected_version must be an integer",
expected_version=expected_version)
new_state_json = _dumps(state_update.get("state", {}))
new_state_json = _dumps(state_update.get("state",
state_update.get("state_json", {})))
updated_by = str(state_update.get("updated_by")
or event_data.get("actor_id") or source
or DEFAULT_SOURCE)[:32]
factory = resolve_conn_factory(conn_factory) if conn is None else None
own_conn = conn is None
@ -328,7 +411,6 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
dialect = ps.detect_dialect(conn)
now_text = utc_now_text(clock())
event_id = event_id or new_event_id()
source = source or event_data.get("source") or DEFAULT_SOURCE
started = time.perf_counter()
@ -338,47 +420,62 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
_begin(conn, cur, dialect)
isolation_applied = _set_isolation(cur, dialect)
current_version = _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id)
current_version = _fetch_state_version(cur, dialect, tenant_id, session_id,
entity_id)
if state_update is not None and current_version is not None \
and expected_version is not None and expected_version != current_version:
_rollback_quiet(cur)
raise ConcurrentStateConflict(
"expected_version does not match stored state_version",
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
expected_version=expected_version, actual_version=current_version,
)
if current_version is None:
base_version = 0
else:
base_version = current_version
base_version = 0 if current_version is None else current_version
next_version = base_version + 1
# (b) 先更新实体状态:失败/冲突即回滚,事件也不会留下(原子性)。
if state_update is None:
action = "event_only"
elif current_version is None:
action = _insert_state(cur, dialect, tenant_id, world_id, entity_id,
new_state_json, next_version, event_id, now_text)
action = _insert_state(cur, dialect, tenant_id, session_id, entity_id,
new_state_json, next_version, updated_by, now_text)
else:
action = _update_state(cur, dialect, tenant_id, world_id, entity_id,
action = _update_state(cur, dialect, tenant_id, session_id, entity_id,
new_state_json, base_version, next_version,
event_id, now_text)
updated_by, now_text)
# (a) 再插入 append-only 事件;此步失败会把上面的状态变更一并回滚。
payload_text = _dumps(event_data.get("payload",
state_update.get("state")
if state_update is not None else {}))
seq_no = event_data.get("seq_no", event_data.get("seq"))
record = {
"event_id": event_id,
"tenant_id": tenant_id,
"world_id": world_id,
"session_id": event_data.get("session_id"),
"entity_id": entity_id,
"event_type": event_data.get("event_type") or DEFAULT_EVENT_TYPE,
"payload": _dumps(event_data.get("payload", state_update or {})),
"causation_id": event_data.get("causation_id"),
"source": source,
"event_uid": str(event_uid),
"event_code": event_data.get("event_code") or "",
"idem_key": event_data.get("idem_key") or "",
"world_id": world_id if world_id is not None else 0,
"scene_id": _coerce_id(event_data.get("scene_id"), "scene_id") or 0,
"session_id": _coerce_id(session_id, "session_id") or 0,
"entity_id": str(entity_id),
"actor_id": str(event_data.get("actor_id") or updated_by or "")[:64],
"event_type": str(event_type),
"payload": payload_text,
"payload_json": payload_text,
"causation_id": str(event_data.get("causation_id") or ""),
"seq": int(seq_no) if seq_no not in (None, "") else 0,
"seq_no": int(seq_no) if seq_no not in (None, "") else 0,
"source": str(source)[:32],
"state": str(event_data.get("state") or "applied")[:16],
"state_version": next_version if state_update is not None else base_version,
"tx_group": str(event_data.get("tx_group") or ""),
"broadcast": 1 if event_data.get("broadcast", 1) else 0,
"created_by": _coerce_id(event_data.get("created_by"), "created_by") or 0,
"created_at": now_text,
"updated_at": now_text,
"occurred_at": now_text,
}
_insert_event(cur, dialect, record)
@ -388,18 +485,24 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
except Exception as exc:
_rollback_quiet(cur)
raise TxAbortError("commit failed: %s" % _truncate(exc),
event_id=event_id, entity_id=entity_id)
event_uid=event_uid, entity_id=entity_id)
return {
"event_id": event_id,
"event_uid": event_uid,
"event_id": event_uid, # 兼容旧调用方的返回键名
"tenant_id": tenant_id,
"world_id": world_id,
"session_id": session_id,
"entity_id": entity_id,
"state_version": record["state_version"],
"action": action,
"created_at": now_text,
"dialect": dialect,
"isolation_level_applied": isolation_applied,
"authority": {
ps.EVENT_TABLE: auth.authority_path(ps.EVENT_TABLE),
ps.STATE_TABLE: auth.authority_path(ps.STATE_TABLE),
},
"elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3),
}
finally: