审计报告-2026-07-20.md 146 KB

审计报告 · 本体驱动 DDD+EDA 元数据引擎

  • 日期:2026-07-20
  • 范围engine/(17 个 Java 文件 ~1.9k 行)、web/(7 个 TS/TSX ~0.7k 行)、models/(11 份 YAML ~1.3k 行)、deploy/、配置
  • 方法:6 维度并行探查(铁律 / 正确性 / 安全 / EDA 一致性 / 前端 / 模型完整性)→ 每条发现由两名独立验证者对抗核验(一名"复现"、一名专职"反驳")。共 116 个 agent;55 条候选 → 5 条被驳回、50 条通过复现验证、其中 32 条连"反驳者"也无法推翻。全部源码另经人工逐条复核。

置信度分层(本报告术语):

  • A 级·双重确认(32 条):复现者与反驳者判定为真实缺陷 —— 最高置信。
  • B 级·单边确认/严重度存疑(18 条):复现者判定真实、反驳者提出异议(多为严重度或"是否属既定设计"之争)—— 需人工裁定。
  • 驳回(5 条):复现者无法构造真实触发场景,或属"文档态/演示简化"的既定设计。

一句话结论

"铁律"被系统性违背:engine/ 不是通用解释器,而是"带通用脚手架的订单专用解释器"。 README 承诺的"改模型即改行为、不改一行代码"只在极窄切片内成立(M8 的 compType/formLabel/span、MetaRule 三个白名单变量的阈值、M1 类型 → DDL)。一旦新增第二个聚合/场景、重命名字段、或修改跨聚合规则,都必须改 Java/TS 代码。

唯一的数据损坏级问题是超卖链路:库存不足/不存在时订单仍提交,且 demo 中无任何补偿接线。


二、按主题的执行摘要

A. 🔴 铁律违背(本项目最核心问题)

两个"通用执行器"里塞满订单/客户/商品专用逻辑:

位置 硬编码内容
CommandEngine.java:245-255 runRules() 风控变量 totalAmount/customerLevel/stockNum,实体 OrderItem、字段 productId
CommandEngine.java:225-234 validate() 子串匹配中文 "手机号"/"dueDays" 触发校验,写死 DeliveryAddressEntity.receiverPhonePaymentTerm.dueDays
CommandEngine.java:276-294 customerLevel() / productStock() 整段客户域/商品域方法(写死 CustomerAggregate/ProductAggregate/cust_level/stock_num
CommandEngine.java:185-186 用字面量 "OrderCreated" 触发跨聚合一致性,写死 productId/quantity
CommandEngine.java:386 idPrefix() = 聚合首字母(注释 "O for Order")
EventEngine.java:73,89 跨聚合一致性只认 "StockDeduct",写死 Product/stockNum/ProductStockDeducted
EventEngine.java:25 Outbox 表名 t_domain_outbox 写死,无视 ME YAML 里可配的 outbox.table
前端 SceneForm.tsx:96panels.tsx:34/47/143api.ts:27App.tsx:15DataController.java:31 写死 orderId、"订单创建成功"、/data/orders/listOrderAggregate、订单/库存列名

验证:把 M1 里 stockNum 改名,productStock()/deductStock()静默失效(走 getOrDefault 兜底列名,取不到就当"无库存/无规则"),而非报错 —— 即"改模型不该改代码"承诺被打破的直接证据。

B. 🔴 正确性 / 数据一致性(可触发的真实 Bug)

  1. 超卖 / 库存不扣,订单照样提交CommandEngine.java:185-197)——deductStock 返回 FAIL_INSUFFICIENT/SKIP_NOT_FOUND不抛异常@Transactional execute()success=true 提交。用 P002(库存 8) 下单 20 件即可复现。(严重度存疑:若视作"事件最终一致 + 补偿"则可接受,但 demo 里没有任何补偿接线,pollAndPublish 只翻状态,故实为超卖漏洞。)
  2. 并发超卖 / 负库存EventEngine.java:100-112)——先 SELECT 读库存、Java 判 current<qty、再 SET stock=stock-?AND stock>=?、无行锁。
  3. 无幂等CommandEngine.java:67)——无请求键;双击/重试 = 重复订单 + 重复事件 + 重复扣减。
  4. reload() 与在途请求数据竞争ModelRepository.java:51-64)——clear() 后重填普通 LinkedHashMap,读取方无同步。
  5. 非数字入 int/decimal 崩溃DynamicRepository.java:104)——预检通过、落库 new BigDecimal("abc") 抛未捕获异常 → HTTP 500。
  6. Map.of() 遇 null productId/quantity NPE(:183);deductStock 对 NULL 库存列 Long.parseLong("null") 崩溃(EventEngine.java:105);toBig() 吞异常返回 0 → 非数字 dueDays 绕过 >=0:419)。
  7. executeCommand 一律按"新建聚合根"落库:148-168)——SCENE_MODIFY_ORDER_ADDR/SCENE_CANCEL_ORDER插入新订单而非改/删(目前仅 Create 接线,属潜伏问题)。

C. 🟠 安全

  • 身份可伪造CommandController.java:34)——X-Principal 头直取、无认证,任何人可自称 backend_admin
  • 权限失败即放行CommandEngine.java:359)——场景无 permissionBindreturn true
  • 越权读全表——/api/data/{aggregateId}/orders/list 无鉴权吐整表;/api/meta/model/M5 匿名暴露完整安全模型;/api/meta/reload 匿名改状态。
  • storageEncrypt 从未兑现M5:30)——声明手机号加密存储、前端也提示"存储加密",实际明文入库
  • totalAmount 客户端可篡改CommandEngine.java:246)——从不按明细重算,且直接喂给风控 → 既能低报又能绕过限额。
  • CORS allowedOriginPatterns("*") 未按 profile 隔离(含 prod);默认 profile 开无密码 H2 控制台;prod/compose 弱默认口令;负数 quantity 反向"增库存"。
  • 非问题澄清:动态 SQL 表名/列名虽字符串拼接,但来源可信 YAML、值走 ? 参数化,用户 payload 无法注入列名 → 仅纵深防御建议。

D. 🟡 EDA / 事件架构(与宣称的落差)

  • 跨聚合一致性是事务内同步内联调用CommandEngine.java:185),非 Outbox 驱动;pollAndPublish 只把 NEW→PUBLISHED,事件从不被消费 → "事件驱动最终一致"名不副实。
  • ME OrderCancelled→StockDeduct 规则永不触发(引擎只用字面量 "OrderCreated" 调用),且语义相反(取消应回补库存)。
  • M2 CreateOrder.emitEvents 引用 OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSetME 未定义这三个事件。
  • eventPayload 只从 master 取值(:378-380)→ payload 引用子实体/非领域字段的事件值为 null。

E. 🟡 模型完整性

  • M8:20 模板绑定 SCENE_CREATE_CUSTOMERM4 从未定义 → 死模板。
  • M0 自称"全域校验根规范"但从未被引擎执行,各层实际违反 M0 自身模式;MetaRule.vip_discount_limit 定义但无人引用。
  • 合理设计:M6/M7 加载但不被读取 —— README 明确标注"文档态",不算缺陷。

三、修复优先级

  1. 超卖 + 幂等(唯一数据损坏级):库存不足在 persist 前预留并中止、或真正接补偿;execute() 加幂等键。
  2. 收敛安全面:真实认证替代 X-Principal、权限默认拒绝、/api/meta/*/api/data/* 加鉴权、CORS 按 profile 收紧、兑现 storageEncrypt、服务端重算 totalAmount
  3. 兑现"通用"承诺:把 CommandEngine/EventEngine 的订单/商品硬编码抽为"由 M2 结构化 validations、ME crossAggConsistencyRules 通用驱动";否则应在 README 诚实标注当前通用边界。

附录 A · A 级发现 —— 双重确认(复现者与反驳者一致判定为真实,共 32 条)

本级发现两名对抗验证者独立都无法推翻,置信度最高。

A1. Cross-aggregate consistency is a hardcoded stock-deduction, not model-driven

  • 严重度:BLOCKER 维度:iron-rule 位置engine/src/main/java/com/onto/engine/event/EventEngine.java:73
  • 缺陷:The supposedly-generic EDA consistency engine only knows how to execute one specific business command (StockDeduct) and hardcodes the product aggregate root name, the stock field/column, item field names, and the emitted event.
  • 证据 Line 73: `if ("StockDeduct".equals(targetCmd)) {` ; deductStock(): line 86 `repo.fieldMap(productAgg, "Product")`, line 89 `String stockCol = fmap.getOrDefault("stockNum", "stock_num");`, line 90 `Object productId = item.get("productId");`, line 91 `item.getOrDefault("quantity", item.get("deductQty"))`, line 96 `action.put("targetCommand", "StockDeduct");`, line 117 `append(productAgg, "ProductStockDeducted", Map.of("productId", productId, "stockNum", remaining));`
  • 影响 / 触发场景:ME-event.yaml declares targetCommand: StockDeduct generically, but the engine branches on the literal string "StockDeduct" and then applies a hand-coded decrement of the literal field stockNum/column stock_num, keyed by literal productId, emitting literal ProductStockDeducted. Renaming StockDeduct/stockNum/productId/ProductStockDeducted or the Product root in the YAML — or declaring any other cross-agg consistency command (e.g. a credit-limit deduction) — silently does nothing, because the only executable path is this order/product-specific if-branch. The decrement semantics should come from the M2 StockDeduct command definition + M1 field, executed generically (e.g. via CommandEngine), not baked in.
  • 建议修复:Drive the consistency action from the model: resolve the target command from M2, its target field/operation and emitted event from M2/ME, the root entity name via models.rootName(productAgg), and the mutated field via M1/M3 — no literal command/field/event names in Java.
  • 复现核验:All quoted evidence is verbatim-confirmed in engine/src/main/java/com/onto/engine/event/EventEngine.java. Line 73 if ("StockDeduct".equals(targetCmd)) { gates the entire cross-aggregate consistency execution on a literal business command name. deductStock() hardcodes the aggregate-root name (line 86 repo.fieldMap(productAgg, "Product")), the stock field/column (line 89 fmap.getOrDefault("stockNum", "stock_num")), the item keys (line 90 item.get("productId"), line 91 item.getOrDefault("quantity", item.get("deductQty"))), the command label (line 96), and the emitted event with its payload keys (line 117 `append(productAgg, "ProductStockDeducted", Map.of("productId", productId, "stock …
  • 反驳核验:I attempted to kill this finding on five fronts and all failed against the actual code.

REACHABILITY: Not dead code. CommandEngine.java:186 calls eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)) inside the live CreateOrder path, and deductStock() performs a real repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE ...") at EventEngine.java:111. So it is live business logic.

NOT A FALLBACK: EventEngine.java:73 if ("StockDeduct".equals(targetCmd)) is a hard gate — cross-agg consistency runs ONLY when the model's targetCommand is the literal string "StockDeduct". deductStock() then hardcodes the aggregate root "Product" (lines 86-88 r …

A2. CommandEngine feeds the rule engine a hardcoded order/product-specific variable set

  • 严重度:BLOCKER 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:244
  • 缺陷:runRules() — the heart of generic risk control — hardcodes the exact business variable names, entity, and field that MetaRule expressions reference, so the generic RuleEngine is fed a non-generic, order-aware payload.
  • 证据 Lines 244-257: `Object level = customerLevel(ctx);` / `orderVars.put("totalAmount", ctx.master.get("totalAmount"));` / `orderVars.put("customerLevel", level);` ... `for (Map<String, Object> it : ctx.details.getOrDefault("OrderItem", List.of())) { Object pid = it.get("productId"); Long stock = productStock(pid); ... for (RuleHit h : ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock)))`
  • 影响 / 触发场景:RuleEngine.evaluate() is genuinely generic (it can enumerate a rule's referenced variables via exp.getVariableNames() and only fires when all are present), but CommandEngine hand-builds a map containing exactly totalAmount, customerLevel, stockNum and iterates the literal OrderItem entity reading literal productId. These are precisely the variables in MetaRule-business.yaml's matchConditions. Rename totalAmount/stockNum/customerLevel in M1, rename the OrderItem entity, or add a new rule referencing any other field, and the rule silently never fires — defeating the 'edit-YAML-only' promise for risk rules. The variable set should be derived from the rules' referenced variable names and resolved generically from payload/master/details/referenced aggregates.
  • 建议修复:Collect required variable names from the scene's MetaRule expressions, then resolve each generically (master field, detail field, or via M1 refAggregate lookup) instead of the hardcoded totalAmount/customerLevel/stockNum/OrderItem/productId literals.
  • 复现核验:Evidence verified verbatim in engine/src/main/java/com/onto/engine/command/CommandEngine.java, runRules() lines 241-260: line 246 orderVars.put("totalAmount", ctx.master.get("totalAmount")), line 247 orderVars.put("customerLevel", level), line 251 ctx.details.getOrDefault("OrderItem", List.of()), line 252 it.get("productId"), line 255 Map.of("stockNum", stock). Helper methods extend the violation: customerLevel() (276-284) hardcodes customerId/CustomerAggregate/Customer/customerLevel; productStock() (286-294) hardcodes ProductAggregate/Product/stockNum. These identifiers are precisely those the iron rule enumerates as forbidden in engine/.\n\nThe finding's core mechanism is confirm …
  • 反驳核验:Confirmed by reading engine/.../command/CommandEngine.java:241-260 and rule/RuleEngine.java:34-67 and models/MetaRule-business.yaml. runRules() hand-builds the rule-engine payload with Java string literals: orderVars.put("totalAmount", ctx.master.get("totalAmount")) and orderVars.put("customerLevel", level) (lines 246-247), iterates ctx.details.getOrDefault("OrderItem", List.of()) reading it.get("productId") (lines 251-252), and calls ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock)) (line 255). These literals — totalAmount, customerLevel, stockNum, the OrderItem entity, the productId field — are exactly the business identifiers the iron rule forbids in engine/ (the task itself nam …

A3. No authentication; authorization trusts spoofable X-Principal header

  • 严重度:BLOCKER 维度:security 位置engine/src/main/java/com/onto/engine/web/CommandController.java:34
  • 缺陷:The caller's identity is taken verbatim from a client-supplied HTTP header with no authentication layer, so any client can act as any principal (including backend_admin).
  • 证据 @RequestHeader(value = "X-Principal", required = false) String principal) { String p = (principal == null || principal.isBlank()) ? defaultPrincipal : principal; // CommandController L34-35; permitted() only does allow.contains(principal) (CommandEngine L362)
  • 影响 / 触发场景:M5 declares authMode: oauth2_jwt, but pom.xml has NO spring-boot-starter-security and there is no filter/JWT validation anywhere. The principal is whatever the caller puts in X-Principal. Attack: curl -X POST http://host:8080/api/scene/SCENE_CANCEL_ORDER/execute -H 'X-Principal: backend_admin' -d '{...}' passes every permission check. Even with no header, the default 'normal_user' is authorized for order_create/modify/cancel, so all command scenes are effectively open to anonymous callers. The entire M5 permission model is decorative.
  • 建议修复:Introduce real authentication (Spring Security + JWT per the declared oauth2_jwt mode); derive the principal from a verified token/session, never from an arbitrary request header. Reject requests without a validated identity.
  • 复现核验:Every element of the finding is verified in the actual code.
  1. Spoofable identity (CommandController.java:34-36): execute(...) takes the principal verbatim from a client header — @RequestHeader(value = "X-Principal", required = false) String principal, then String p = (principal == null || principal.isBlank()) ? defaultPrincipal : principal; return commandEngine.execute(sceneId, payload, p);. The identity is 100% client-controlled with no validation.

  2. Authorization only does a membership test (CommandEngine.java:357-365): permitted() reads the scene's permissionBind, then for each perm checks allow.contains(principal) (L362) against M5's allowPrincipals. Nothing verifies t …

  3. 反驳核验:The finding is factually confirmed by the actual files and survives refutation. CommandController.java:34-36 reads the caller identity verbatim from the client-controlled X-Principal header and, when absent/blank, falls back to defaultPrincipal = normal_user (L16-17, L35). CommandEngine.permitted() (L357-364) performs only authorization: allow.contains(principal) against M5 allowPrincipals — there is no authentication. I verified there is no auth layer: grep spring-boot-starter-security on engine/pom.xml returns 0, and there is no SecurityFilterChain / OncePerRequestFilter / HandlerInterceptor / @PreAuthorize anywhere in engine/src. WebConfig.java only sets permissive CORS. models/ …

A4. convert() throws NumberFormatException on non-numeric int/decimal field; precheck approves it

  • 严重度:HIGH 维度:correctness 位置engine/src/main/java/com/onto/engine/persist/DynamicRepository.java:105
  • 缺陷:A non-numeric (or comma/locale-formatted) value in any int/decimal domain field passes validation and precheck but crashes with an uncaught NumberFormatException at persist time, rolling back the transaction and returning HTTP 500.
  • 证据 case "int" -> s.isEmpty() ? null : new BigDecimal(s).longValue(); case "decimal" -> s.isEmpty() ? null : new BigDecimal(s); // new BigDecimal("6,999.00") / new BigDecimal("abc") throws NumberFormatException, unguarded
  • 影响 / 触发场景:CommandEngine.validate() never checks that numeric domain fields are actually numeric (only required/phone/dueDays NumberFormatException -> 500. Same crash for totalAmount="abc" or dueDays="abc". The 'real-time validation shares one source' guarantee is violated.
  • 建议修复:In convert(), catch NumberFormatException and either reject via a validation error or coerce safely; and/or add a generic numeric-format validation in CommandEngine.validate() (driven by M1 field type) so precheck and execute agree before reaching persist.
  • 复现核验:Verified the full chain against the actual source. DynamicRepository.java:104-105 does new BigDecimal(s).longValue() (int) and new BigDecimal(s) (decimal) with no guard; BigDecimal rejects grouping separators ("6,999.00") and non-numeric ("abc"), throwing NumberFormatException. totalAmount is type: decimal in M1 (M1-domain.yaml:57-58, an aggregate-root attribute) and is present in the M3 root fieldMap as total_amt (M3-deploy.yaml:60), so CommandEngine.executeCommand -> repo.insertDomain(rootName) -> convert("decimal", value) is on the persist path (CommandEngine.java:155).

CommandEngine.validate() (lines 205-239) never validates numeric parseability: it checks only M8 required blank-ness, r …

  • 反驳核验:The finding survives refutation; every link in its chain is confirmed by the code. (1) DynamicRepository.java:104-105 does new BigDecimal(s) for int/decimal with no try/catch — this throws NumberFormatException on "6,999.00" (grouping separators are rejected by BigDecimal) and on "abc". Contrast the guarded parseDateTime (lines 118-126) and CommandEngine.toBig (lines 418-420), showing convert() is genuinely unprotected. (2) totalAmount is type decimal (M1-domain.yaml:58) and mapped totalAmount->total_amt (M3-deploy.yaml:60), so it is passed to convert("decimal", value) during persist (CommandEngine:155 insertDomain). (3) CommandEngine.validate() (lines 205-239) performs no numeric-parse …

A5. Non-atomic check-then-decrement on stock allows oversell / negative stock under concurrency

  • 严重度:HIGH 维度:eda 位置engine/src/main/java/com/onto/engine/event/EventEngine.java:100
  • 缺陷:deductStock reads current stock with a plain SELECT, checks current= qty' guard and no row lock, so two concurrent orders can both pass the check and drive stock negative.
  • 证据 L100: Map<String, Object> row = repo.findByPk(table, pkCol, productId); L105: long current = Long.parseLong(String.valueOf(row.get(stockCol))); L106: if (current < qty) { ...FAIL_INSUFFICIENT...; return action; } L111: repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE " + pkCol + " = ?", qty, productId); The SELECT (findByPk) takes no lock; the UPDATE has no stock>=qty predicate.
  • 影响 / 触发场景:Product P001 stock=10. Two concurrent execute() calls each with quantity=8. Under READ_COMMITTED both findByPk read 10; both evaluate current 2. T2's UPDATE then applies to the committed value: 2-8 = -6, commits. Final stock = -6: 16 units sold from 10 in stock. TOCTOU race with no compensating guard.
  • 建议修复:Make the decrement conditional and atomic: 'UPDATE t SET stock = stock - ? WHERE pk = ? AND stock >= ?' and treat rowsAffected==0 as FAIL_INSUFFICIENT, or SELECT ... FOR UPDATE before the check.
  • 复现核验:Verified all quoted evidence in engine/.../event/EventEngine.java: L100 findByPk (plain SELECT), L105-106 Java-side current= qtyguard. Cross-checked supporting facts: (1) DynamicRepository.findByPk (L80-84) usesSELECT * ... WHERE pk = ?with NOFOR UPDATE`, so the read takes no lock. (2) SchemaInitializer.sqlType (L102) maps int->BIGINT with no CHECK/UNSIGNED, so the DB accepts negative stock. (3) The path is genuinely concurrent and reachable: CommandController.execute (L31-37) calls CommandEngine.execute with no synchronization; execute is @Transactional and reaches deductStock via appl …
  • 反驳核验:Refutation failed on every angle; the finding is a genuine, reachable TOCTOU that I reproduced against the exact stack.

Reachability: deductStock (EventEngine.java:85-119) runs synchronously in the request thread: CommandEngine.execute (@Transactional, L67) -> executeCommand -> applyCrossConsistency("OrderCreated",...) (L186) -> deductStock (L77). Spring MVC/Tomcat is multi-threaded and HikariCP gives each concurrent request its own transaction, so two concurrent POST /command for one product race.

No guard at any layer: findByPk issues a lock-free plain SELECT (DynamicRepository.java:80-84); the Java check if (current < qty) (EventEngine.java:106) is check-then-act; the UPDATE `SET stoc …

A6. reload() mutates a shared non-thread-safe map in place while request threads read it unsynchronized

  • 严重度:HIGH 维度:eda 位置engine/src/main/java/com/onto/engine/model/ModelRepository.java:54
  • 缺陷:load()/reload() do layers.clear() then re-populate a plain LinkedHashMap under 'synchronized', but every reader (scene(), command(), aggregates(), etc.) accesses that same map with no synchronization, so a reload racing an in-flight execute() can observe a half-empty/corrupt model.
  • 证据 L48: private final Map<String, Object> layers = new LinkedHashMap<>(); L50: @PostConstruct public synchronized void load() { L54: layers.clear(); L54-62: loop re-puts each layer. L67: public synchronized void reload() { load(); } Readers are NOT synchronized, e.g. L108-110 aggregates(): return asList(asMap(layers.get("M1")).get("aggregates")); and scene()/command()/templateByScene() all read layers directly. 'synchronized' guards writers against each other but not against concurrent readers.
  • 影响 / 触发场景:Thread A is inside execute() calling models.scene()/command()/templateByScene() while an admin POSTs /api/meta/reload. Thread B enters synchronized load() and runs layers.clear(). Thread A now sees layers.get("M4") == null -> scene() returns empty map -> permitted() (empty permissionBind) returns true, flowSteps is empty, and execute() returns success=true having created no order; or SceneSchemaService throws 'no scene'. Concurrent clear()+put()/get() on a HashMap can also lose entries or, during resize, spin. Model reads mid-flight are not a consistent snapshot.
  • 建议修复:Build a new immutable map locally and publish it atomically to a volatile reference (this.layers = Map.copyOf(fresh)) instead of clear()+put on a shared map; readers then always see a complete prior or new snapshot.
  • 复现核验:Evidence verified in ModelRepository.java. layers (L48) is a plain LinkedHashMap. load()/reload() are synchronized (L50-51, L67) and do layers.clear() (L53, finding said L54 — trivial off-by-one) then re-put 11 layers in a disk-I/O + YAML-parse loop (L54-62). Every reader is unsynchronized: scenes()/scene() L222-232, command() L184-195, templateByScene() L262-268, aggregates() L108-110 all do bare layers.get(...) with no lock held. synchronized locks this and only mutually excludes writers; readers never acquire that monitor, so reader-vs-writer is an unguarded data race.\n\nReachability is real: MetaController.reload() is POST /api/meta/reload (L76-85) on one Tomcat thread; Comm …
  • 反驳核验:The finding is a genuine, reachable data race that my best refutation could not kill. ModelRepository.java:48 declares private final Map<String,Object> layers = new LinkedHashMap<>() — final pins only the reference; contents are mutated. synchronized load() (L51) does layers.clear() (L53) then re-puts 11 entries (L54-62); reload() (L67) calls it. Every reader (scenes() L222-224, scene() L226, command() L184, aggregates() L108, templateByScene() L262) reads layers.get(...) with no synchronization, so there is no happens-before edge from writer to readers under the JMM. Both paths are reachable and concurrent: reload is exposed at MetaController.java:76 (POST /api/meta/reload) and is …

A7. OrdersEventsPanel hardcodes order/customer business fields

  • 严重度:HIGH 维度:frontend 位置web/src/panels.tsx:146
  • 缺陷:The 'orders' table columns are hardcoded to order/customer-specific field names instead of being derived from any model layer.
  • 证据 columns={[ { title: '订单号', dataIndex: 'orderId' }, { title: '客户', dataIndex: 'customerId' }, { title: '金额', dataIndex: 'totalAmount' }, { title: '币种', dataIndex: 'totalCurrency' }, ]}
  • 影响 / 触发场景:orderId/customerId/totalAmount/totalCurrency are business identifiers baked into the generic renderer. Renaming any of these in M1/M3 YAML (or switching the demo to a non-order aggregate) leaves this list showing empty columns — the panel is not schema-driven, directly violating the iron rule that changing requirements means editing YAML only.
  • 建议修复:Drive the list columns from a schema (e.g. OrderAggregate root attributes from /api/meta or a dedicated list-schema endpoint) so keys/labels come from M1/M8, not string literals.
  • 复现核验:Evidence verified exactly at web/src/panels.tsx:146-151: OrdersEventsPanel's 'orders' table columns are hardcoded to dataIndex: 'orderId' | 'customerId' | 'totalAmount' | 'totalCurrency' (plus rowKey="orderId" at line 144). These are order/customer business identifiers baked into web/, which the iron rule requires to be a generic renderer with no hardcoded business fields. The task definition literally lists customerId and totalAmount as example violations.

Reproduction chain confirmed against the actual data path: api.orders() (api.ts:27) -> GET /data/orders/list -> DataController.orders() (DataController.java:32) -> DomainReadService.listRoot("OrderAggregate"), whose toDomain() (Dom …

  • 反驳核验:Code confirmed at web/src/panels.tsx:146-151: a static antd columns literal hardcodes business field names as dataIndex — {title:'订单号', dataIndex:'orderId'}, {title:'客户', dataIndex:'customerId'}, {title:'金额', dataIndex:'totalAmount'}, {title:'币种', dataIndex:'totalCurrency'}. customerId and totalAmount are listed verbatim in the iron rule as prohibited identifiers, and the rule covers all of web/. Every refutation angle fails: (1) Reachable — OrdersEventsPanel is rendered as the '订单/事件' tab in App.tsx:100. (2) Not a fallback — it is a fixed column list with no schema derivation; antd Table maps dataIndex straight onto row keys with no guarding default. (3) Triggerable impact — d …

A8. Success message hardcodes 'orderId' key and '订单' wording

  • 严重度:HIGH 维度:frontend 位置web/src/engine/SceneForm.tsx:96
  • 缺陷:On submit success the generic form reads r.orderId and says '订单创建成功', but the backend returns the root id under a dynamic field name.
  • 证据 message.success(`订单创建成功:${r.orderId}`)
  • 影响 / 触发场景:CommandEngine.execute returns the root id via out.put(rootIdentifierField, rootId) where rootIdentifierField = models.rootIdentifier(aggregateId) — 'orderId' only by coincidence for OrderAggregate. For any other scene the toast shows '订单创建成功:undefined'. The order-specific field name and '订单' wording are hardcoded in the generic renderer, violating the iron rule.
  • 建议修复:Read the id generically (from the aggregate's root identifier field, or have the engine return a fixed key like result.rootId) and use schema.sceneName instead of the literal '订单创建成功'.
  • 复现核验:Evidence verified verbatim. web/src/engine/SceneForm.tsx:96 reads message.success(订单创建成功:${r.orderId}), and this file's own header (lines 29-33) declares it the "场景通用渲染引擎" (generic scene render engine) — the web/src/engine/ layer the iron rule requires to be a generic renderer. The backend does NOT return the root id under a fixed orderId key: CommandEngine.java:149 computes String rootIdentifierField = models.rootIdentifier(aggregateId) and line 192 does out.put(rootIdentifierField, rootId). ModelRepository.java:125-127 shows rootIdentifier reads aggregateRoot(aggregateId).get("identifier") from M1. M1-domain.yaml:51 has identifier: orderId for OrderAggregate only; other aggre …
  • 反驳核验:I could not kill this finding; it is a genuine, confirmed iron-rule violation. web/src/engine/SceneForm.tsx:96 reads message.success(订单创建成功:${r.orderId}), hardcoding both the order-specific field name orderId and the "订单" wording inside a file whose own docstring (lines 29-33) declares it the generic scene render engine ("场景通用渲染引擎"). The backend contract confirms the coupling is wrong: CommandEngine.java:149,192 returns the root id under a DYNAMIC key — String rootIdentifierField = models.rootIdentifier(aggregateId); ... out.put(rootIdentifierField, rootId);. ModelRepository.java:125-127 shows rootIdentifier returns the aggregate's identifier from M1; it equals "orderId" only for …

A9. customerLevel() is a dedicated customer-domain method inside the generic engine

  • 严重度:HIGH 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:276
  • 缺陷:A whole private method hardcodes the CustomerAggregate/Customer names, the customerId reference field, and the customerLevel field (with fallback column cust_level) — pure order-domain business logic in a file that must be a generic interpreter.
  • 证据 Lines 276-284: `Object cid = ctx.master.get("customerId");` / `String table = repo.tableName("CustomerAggregate", "Customer");` / `String pk = repo.pkColumn("CustomerAggregate", "Customer");` / `String levelCol = repo.fieldMap("CustomerAggregate", "Customer").getOrDefault("customerLevel", "cust_level");`
  • 影响 / 触发场景:The knowledge that 'the order references a customer, which has a level attribute used by a rule' is encoded in Java, not YAML. M1 already models this relationship (Order.customerId has refAggregate: CustomerAggregate, and Customer has attribute customerLevel). Renaming customerId/customerLevel/CustomerAggregate/Customer in the YAML — or pointing the rule at a different referenced aggregate — silently breaks the large-order risk rule. Even the physical column fallback 'cust_level' is hardcoded.
  • 建议修复:Generically resolve referenced-aggregate attribute values using the M1 refAggregate/refIdentifier metadata of the field the rule needs, so no aggregate/entity/field/column literal appears in Java.
  • 复现核验:VERIFIED verbatim at CommandEngine.java:276-284. The private method customerLevel(Ctx) hardcodes five distinct business identifiers that the iron rule says must live only in YAML:
  • Line 277: Object cid = ctx.master.get("customerId"); — the order's reference-field name.
  • Line 279: repo.tableName("CustomerAggregate", "Customer") — aggregate id + root name.
  • Line 280: repo.pkColumn("CustomerAggregate", "Customer").
  • Line 281: repo.fieldMap("CustomerAggregate", "Customer").getOrDefault("customerLevel", "cust_level") — the domain attribute name AND a hardcoded physical column fallback cust_level.

This is genuine order/customer-domain business knowledge ("an order references a custo …

  • 反驳核验:Confirmed genuine iron-rule violation at engine/.../command/CommandEngine.java:276-284. The method customerLevel() hardcodes business identifiers as Java string literals: line 277 ctx.master.get("customerId"), lines 279-280 repo.tableName("CustomerAggregate","Customer") / repo.pkColumn("CustomerAggregate","Customer"), and line 281 repo.fieldMap("CustomerAggregate","Customer").getOrDefault("customerLevel","cust_level"). The iron rule text explicitly lists CustomerAggregate and customerId as example violations, so this is a direct match — and CommandEngine's own header (lines 19-20) plus CLAUDE.md declare this file must contain NO customer-specific code.

Every refutation angle fai …

A10. productStock() is a dedicated product-domain method inside the generic engine

  • 严重度:HIGH 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:286
  • 缺陷:A whole private method hardcodes ProductAggregate/Product names and the stockNum field (fallback column stock_num) to look up inventory — product-specific business logic baked into the generic executor.
  • 证据 Lines 286-294: `String table = repo.tableName("ProductAggregate", "Product");` / `String pk = repo.pkColumn("ProductAggregate", "Product");` / `String stockCol = repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num");`
  • 影响 / 触发场景:The generic engine 'knows' there is a Product aggregate with a stockNum attribute and reads it directly to power the low-stock rule. Renaming ProductAggregate/Product/stockNum in the YAML silently breaks the stock-warning rule; adding a second inventory-bearing aggregate is impossible without code. Even the fallback column 'stock_num' is a hardcoded M3 detail.
  • 建议修复:Resolve the referenced aggregate + attribute needed by the rule from M1 refAggregate metadata generically; drop the ProductAggregate/Product/stockNum literals.
  • 复现核验:Evidence verified verbatim at CommandEngine.java:286-294. The private method productStock(Object productId) hardcodes the product-domain aggregate id "ProductAggregate", root name "Product", domain field "stockNum", and a magic fallback M3 column "stock_num": L288 String table = repo.tableName("ProductAggregate", "Product"); L289 String pk = repo.pkColumn("ProductAggregate", "Product"); L290 String stockCol = repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num"); It is reachable production logic: runRules() L251-257 iterates OrderItem details, calls productStock(pid) L253, and feeds the result as the rule variable "stockNum" (also hardcoded, L255 `M …
  • 反驳核验:Confirmed by reading engine/.../command/CommandEngine.java:286-294. The method productStock(Object productId) contains literal hardcoded business identifiers: line 288 repo.tableName("ProductAggregate", "Product"), line 289 repo.pkColumn("ProductAggregate", "Product"), line 290 repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num"). These are exactly the identifiers the iron rule enumerates as violations (aggregate ProductAggregate, field stockNum), plus a concrete physical column name stock_num. Every refutation angle fails: (1) they are literal strings, not model-resolved values, so renaming ProductAggregate/Product/stockNum in YAML silently breaks t …

A11. validate() infers phone validation by string-matching Chinese free text and hardcodes the entity/field

  • 严重度:HIGH 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:224
  • 缺陷:Phone-format validation is triggered by searching the M2 validations free-text for the word '手机号', then applied to the literal DeliveryAddressEntity entity and receiverPhone field with an 11-digit regex.
  • 证据 Line 30: `private static final Pattern PHONE = Pattern.compile("^\\d{11}$");` ; lines 225-231: `if (cmdValidations.contains("手机号")) { for (Map<String, Object> row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of())) { Object p = row.get("receiverPhone"); if (p != null && !PHONE.matcher(String.valueOf(p)).matches()) errors.add("收货手机号格式不合法:" + p); } }`
  • 影响 / 触发场景:Three separate business bindings baked into supposedly-generic validation: (1) the field name receiverPhone, (2) the entity name DeliveryAddressEntity, (3) the very existence of the check keyed on a fuzzy substring match of the Chinese phrase '手机号' in the M2 validations prose. Renaming receiverPhone or DeliveryAddressEntity in the YAML, or rewording the M2 validation text, silently disables/misfires the check. Format constraints should be structured model data (e.g. an M1 field format/regex), not scraped from prose.
  • 建议修复:Add a structured format/regex constraint to M1 fields (or M2 structured validations) and validate generically by iterating model-declared constraints; remove the receiverPhone/DeliveryAddressEntity/'手机号' literals and the hardcoded 11-digit pattern.
  • 复现核验:Evidence verified verbatim in engine/src/main/java/com/onto/engine/command/CommandEngine.java. Line 30: private static final Pattern PHONE = Pattern.compile("^\\d{11}$");. Lines 224-231: String cmdValidations = String.join(" ", stringList(models.command(ctx.cmdAggregate, ctx.cmdId).get("validations"))); then if (cmdValidations.contains("手机号")) { for (Map<String,Object> row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of())) { Object p = row.get("receiverPhone"); if (p != null && !PHONE.matcher(String.valueOf(p)).matches()) errors.add("收货手机号格式不合法:" + p); } }.

All three bindings are genuine business identifiers pulled from the single-source-of-truth YAML, not generic tokens: …

  • 反驳核验:Refutation failed on every angle. The code at CommandEngine.java:30 and 224-231 hardcodes three business bindings into the generic engine: the entity literal "DeliveryAddressEntity" (line 226), the field literal "receiverPhone" (line 227), and an 11-digit PHONE regex \d{11}$ (line 30), triggered by a substring scrape of Chinese prose "手机号" (line 225). The task's iron rule names receiverPhone and DeliveryAddressEntity verbatim as violations, and CLAUDE.md forbids "某个具体聚合的分支".\n\nReachability is confirmed, not a dead path: validate(ctx) is called from precheck() (line 52) and executeCommand() (line 119) on every command. M2-command.yaml:124 lists "收货手机号格式合法" in the CreateOrder validations, so …

A12. validate() hardcodes the dueDays field and PaymentTerm entity for a non-negative check

  • 严重度:HIGH 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:232
  • 缺陷:A 'dueDays >= 0' check is triggered by substring-matching 'dueDays' in the M2 validations text, then applied to the literal PaymentTerm entity and dueDays field.
  • 证据 Lines 232-237: `if (cmdValidations.contains("dueDays")) { for (Map<String, Object> row : ctx.details.getOrDefault("PaymentTerm", List.of())) { Object d = row.get("dueDays"); if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负"); } }`
  • 影响 / 触发场景:The engine hardcodes the PaymentTerm entity name and dueDays field name, and gates the rule on the presence of the literal token 'dueDays' inside M2 prose. Renaming dueDays or PaymentTerm in the YAML silently drops the validation. A numeric non-negativity constraint should be declared in the model, not inferred from free text plus hardcoded field names.
  • 建议修复:Express min/range constraints structurally in M1/M2 and enforce generically over model-declared numeric constraints; remove the PaymentTerm/dueDays literals.
  • 复现核验:Verified verbatim at engine/src/main/java/com/onto/engine/command/CommandEngine.java:232-237: if (cmdValidations.contains("dueDays")) { for (Map<String,Object> row : ctx.details.getOrDefault("PaymentTerm", List.of())) { Object d = row.get("dueDays"); if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负"); } }. This hardcodes the order-domain business entity name "PaymentTerm" and business field name "dueDays" inside the generic engine, and gates the check on a substring match against M2 free-text prose (models/M2-command.yaml:123: "dueDays >= 0, depositRatio ∈ [0,1]"). Both identifiers are legitimate business model names (M1-domain.yaml:88,93; M3-deploy.yaml:71,77; M8-fron …
  • 反驳核验:The finding is confirmed; my refutation fails on every angle. CommandEngine.java:232-237 hardcodes three business identifiers inside the supposedly-generic engine: the entity name "PaymentTerm" (getOrDefault("PaymentTerm", List.of())), the field name "dueDays" (row.get("dueDays")), and gates the whole check on cmdValidations.contains("dueDays") — a substring match against M2 free-text prose. The iron rule in the task brief lists BOTH "dueDays" and "PaymentTerm" as canonical examples of violating identifiers, so these are not acceptable generic fallbacks.\n\nReachability is proven, not speculative: validate() is invoked from precheck() (line 52) and executeCommand() (line 119); the gate fires …

A13. Cross-consistency trigger event and item field names are hardcoded

  • 严重度:HIGH 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:181
  • 缺陷:The engine triggers cross-aggregate consistency with the literal event name 'OrderCreated' and builds item maps with literal productId/quantity field names.
  • 证据 Lines 182-186: `for (Map<String, Object> it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); } List<Map<String, Object>> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));`
  • 影响 / 触发场景:Although events are emitted generically from cmd.emitEvents just above (lines 173-177), the cross-consistency call passes the hardcoded literal 'OrderCreated', plus hardcoded item field names productId/quantity. Rename OrderCreated in ME/M2 (or the OrderItem fields) and the down-stream stock deduction silently stops, even though ME.crossAggConsistencyRules still lists the rule. The trigger event(s) should be taken from the emitted events, and item field names from M1.
  • 建议修复:Iterate the events actually emitted (from M2 emitEvents/ME) and invoke consistency per matching ME rule; source line-item field names from M1 attributes rather than the productId/quantity/'OrderCreated' literals.
  • 复现核验:Evidence verified verbatim at CommandEngine.java:180-186. Line 186 passes the literal event name "OrderCreated" to eventEngine.applyCrossConsistency, and line 183 builds items using literal business field names "productId"/"quantity" read from the domain row (it.get("productId"), it.getOrDefault("quantity",0)). These are all business identifiers declared in the YAML models, not generic engine concepts: ME-event.yaml declares OrderAggregate.OrderCreated and crossAggConsistencyRules[triggerEvent: OrderCreated]; M1-domain.yaml:76,82 declares OrderItem fields productId and quantity; M2-command.yaml:127 declares CreateOrder emitEvents: OrderCreated. The iron rule EXPLICITLY names "OrderCreated" a …
  • 反驳核验:Confirmed verbatim at engine/.../command/CommandEngine.java:186 eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)) and :183 items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))). This is a textbook iron-rule violation — the contract explicitly names "event name like 'OrderCreated'" and business field names as prohibited hardcodings inside engine/.

Every refutation fails: (1) Not a fallback — "OrderCreated" is the only literal passed; in EventEngine.applyCrossConsistency (lines 66-70) it is matched exactly against rule.get("triggerEvent") from ME.crossAggConsistencyRules (ME-event.yaml:59 triggerEvent: OrderCreated), and …

A14. 跨聚合一致性触发事件名被硬编码为 OrderCreated,ME 的 OrderCancelled 规则永不触发

  • 严重度:HIGH 维度:model-integrity 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:186
  • 缺陷:CommandEngine 用字面量 "OrderCreated" 调用 applyCrossConsistency,而不是遍历本次实际发出的事件,导致 ME.crossAggConsistencyRules 中 triggerEvent: OrderCancelled → StockDeduct 这条规则被引擎完全忽略。
  • 证据 CommandEngine.java:186 `eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));`;ME-event.yaml:58-66 定义了两条规则,其中 63-66 `- triggerEvent: OrderCancelled / targetAggregate: ProductAggregate / targetCommand: StockDeduct`。
  • 影响 / 触发场景:SCENE_CANCEL_ORDER 执行 CancelOrder 时发出 OrderCancelled,但引擎只会用硬编码的 "OrderCreated" 去匹配跨聚合规则,OrderCancelled 那条一致性规则永远不会执行。违背『改 ME 增删跨聚合规则即改行为』的承诺;同时把业务事件名硬编码进通用引擎,违反铁律。
  • 建议修复:改为对本命令 emitEvents 中每个事件调用 applyCrossConsistency,由 ME.crossAggConsistencyRules 的 triggerEvent 自行匹配,而非硬编码 "OrderCreated"。
  • 复现核验:Evidence verified at CommandEngine.java:186 — the literal eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); (and the matching hardcoded comment at line 180). executeCommand() is a single generic method invoked for every bindCommand step (lines 92-102), used identically for CreateOrder, ModifyOrderDeliveryAddress, and CancelOrder — the "OrderCreated" literal is applied unconditionally, never derived from the command being run.\n\nConcrete manifestation: executing SCENE_CANCEL_ORDER (M4-scene.yaml:32-40) runs executeCommand for CancelOrder (M2-command.yaml:160-169), which emits OrderCancelled. The engine even computes the real emitted events into events (lines 173- …
  • 反驳核验:CommandEngine.java:186 does literally hardcode the business event name: eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); (comment line 180 confirms it is order-specific, not a generic fallback). By the project's iron-rule definition this is a genuine, reachable hardcoded business event name inside the generic engine, so I cannot honestly refute the finding to real=false. HOWEVER, the finding's claimed HIGH-severity functional impact — that the ME OrderCancelled→StockDeduct rule being skipped breaks cancel-order stock consistency — does not hold in any reachable scenario: (1) the items list (lines 181-184) is built from OrderItem details in the payload, but Cance …

A15. Client-controlled totalAmount is stored as-is and drives risk rules — total tampering + MetaRule bypass

  • 严重度:HIGH 维度:security 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:246
  • 缺陷:The order total is taken directly from the caller's master payload, never recomputed from line items, and that same untrusted value is fed to the REJECT/ALERT risk rules — so a caller can both understate the total and evade amount-based controls.
  • 证据 orderVars.put("totalAmount", ctx.master.get("totalAmount")); // L246, untrusted payload value drives runRules; validate() (L205-239) never checks that item amounts sum to totalAmount
  • 影响 / 触发场景:insertDomain writes master.totalAmount straight into total_amt (it is a fieldMap column), and runRules evaluates MetaRule thresholds (e.g. high-amount REJECT) against the same client value. Attack: submit items worth 100000 but master.totalAmount=1 — the high-amount risk rule never fires and the order persists with total_amt=1. M2 lists validation '订单明细金额累加 ≈ 订单总金额' but it is not implemented, so the integrity/pricing control is absent.
  • 建议修复:Compute totalAmount server-side from persisted line items (price*qty) and use that computed value for both storage and rule evaluation; reject payloads whose declared total diverges from the computed sum.
  • 复现核验:Verified every link of the claim against the actual files. CommandEngine.java:246 orderVars.put("totalAmount", ctx.master.get("totalAmount")) takes totalAmount straight from the client payload (ctx.master is built from payload.get("master") at L322-323). That same map is fed to runRules (called on the real execute path at L130, and precheck L53). MetaRule-business.yaml:27-32 rule R_ORDER_AMOUNT_LIMIT gates a REJECT on totalAmount > #{single_order_max_amount} && customerLevel != "VIP" (threshold 50000) — so the untrusted value is the risk gate. The value is also persisted verbatim: M3-deploy.yaml:60 maps totalAmount: total_amt, and DynamicRepository.insertDomain (L46-69) writes any fiel …
  • 反驳核验:Every technical claim is verified against source, so the finding cannot be killed. CommandEngine.java:246 (orderVars.put("totalAmount", ctx.master.get("totalAmount"))) takes the total straight from the client payload.master (Ctx L322-323), which arrives unfiltered from POST /api/scene/{sceneId}/execute (CommandController.java:32-37, principal merely defaults to normal_user with no auth binding). That untrusted value both (a) drives the REJECT control R_ORDER_AMOUNT_LIMIT in MetaRule-business.yaml:30 ('totalAmount > #{single_order_max_amount} && customerLevel != "VIP"'), so understating it evades the 50000 cap, and (b) is persisted as-is: M3-deploy.yaml:60 maps totalAmount->total_amt and Dyna …

A16. TracePanel hardcodes StockDeduct / product-stock business fields

  • 严重度:MEDIUM 维度:frontend 位置web/src/panels.tsx:47
  • 缺陷:The stock-deduction visualization hardcodes the event name 'StockDeduct' and product/stock field names as table columns.
  • 证据 <Text strong>跨聚合最终一致:StockDeduct 扣减库存</Text> ... columns={[ { title: '商品', dataIndex: 'productId' }, { title: '扣减', dataIndex: 'deductQty' }, { title: '剩余', dataIndex: 'remainingStock' },
  • 影响 / 触发场景:'StockDeduct', 'productId', 'deductQty', 'remainingStock' are business/event identifiers embedded in the frontend. A YAML change to ME event names or product field names would not reflect here, and the panel is meaningless for any non-order domain — an iron-rule violation in the render engine.
  • 建议修复:Render cross-consistency actions generically from the engine's returned action shape (label the section from ME event metadata, render whatever keys the rows carry) rather than fixed column literals.
  • 复现核验:Evidence confirmed verbatim in web/src/panels.tsx (TracePanel). Line 47: <Text strong>跨聚合最终一致:StockDeduct 扣减库存</Text> hardcodes the ME event/command name "StockDeduct" plus Chinese product-stock business copy. Lines 51-53 hardcode three product/inventory field names as fixed table columns: { dataIndex: 'productId' }, { dataIndex: 'deductQty' }, { dataIndex: 'remainingStock' }. These are business identifiers embedded in web/ — exactly the class the iron rule forbids (the contract explicitly lists "StockDeduct" and "stockNum"/product fields as example violations).

Cross-checked against the backend: engine/src/main/java/com/onto/engine/event/EventEngine.java builds the stockActions map …

  • 反驳核验:Confirmed real by reading web/src/panels.tsx:45-56. The block literally hardcodes the business event name and product/inventory field names: line 47 跨聚合最终一致:StockDeduct 扣减库存, and columns dataIndex productId (51), deductQty (52), remainingStock (53). These are order/product-domain identifiers, verified as domain concepts in the YAML single source of truth: models/ME-event.yaml:38,61,65 (StockDeduct / ProductStockDeducted / productId,stockNum), models/M2-command.yaml:88-98 (cmdId StockDeduct, productId, deductQty), models/M1-domain.yaml:27,76 (identifier productId). Editing those YAML names would not reflect here, which is precisely the iron-rule failure mode the contract forbids for w …

A17. TracePanel result summary hardcodes orderId

  • 严重度:MEDIUM 维度:frontend 位置web/src/panels.tsx:34
  • 缺陷:The success Descriptions item is hardcoded to label '订单号 orderId' and read result.orderId.
  • 证据 <Descriptions.Item label="订单号 orderId">{result.orderId}</Descriptions.Item>
  • 影响 / 触发场景:Same dynamic-root-id problem as the submit toast: result.orderId is only populated when the root identifier field happens to be 'orderId'. For any other aggregate this shows blank, and '订单号' is order-specific hardcoding in a supposedly generic trace panel.
  • 建议修复:Surface the root id under a stable generic key from the engine (e.g. result.rootId) and take the label from the aggregate's root-identifier metadata.
  • 复现核验:Evidence verified verbatim at web/src/panels.tsx:34: <Descriptions.Item label="订单号 orderId">{result.orderId}</Descriptions.Item>. The impact is real: the backend writes the root id under a DYNAMIC key, not a fixed "orderId". CommandEngine.java:148-149 computes rootIdentifierField = models.rootIdentifier(aggregateId) and line 192 does out.put(rootIdentifierField, rootId). ModelRepository.rootIdentifier (lines 125-127) returns aggregateRoot(...).get("identifier") — read straight from the M1 YAML, so it is "orderId" only for the order aggregate. App.tsx passes the full execute result unchanged into TracePanel (line 37 setResult(r); line 98 ). Concrete manife …
  • 反驳核验:I tried to kill this and the core survives. The mechanism is verified: CommandEngine.java:192 does out.put(rootIdentifierField, rootId) where rootIdentifierField = models.rootIdentifier(aggregateId) (ModelRepository.java:125-127 returns aggregateRoot.identifier). So the backend returns the root id under a DYNAMIC key — literally "orderId" for OrderAggregate, but a different key for any other aggregate. panels.tsx:34 hardcodes result.orderId and the label "订单号 orderId", so for a non-order aggregate this row would be blank and mislabeled. That is genuine order-specific hardcoding sitting in web/, exactly the pattern the iron rule names as a violation, and it is reachable via the project' …

A18. Default sceneId hardcoded to SCENE_CREATE_ORDER

  • 严重度:MEDIUM 维度:frontend 位置web/src/App.tsx:15
  • 缺陷:The initial selected scene is a hardcoded order-specific scene id rather than derived from the scenes list.
  • 证据 const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER')
  • 影响 / 触发场景:If the scene is renamed in M4 YAML, the first schema fetch 404s and the form fails to load until the user manually clicks another scene. The business scene id is hardcoded in the frontend, so a YAML rename is not transparently reflected — contrary to the iron rule.
  • 建议修复:Initialize sceneId to null and, after api.scenes() resolves, default to the first scene where hasTemplate is true.
  • 复现核验:Evidence verified exactly at web/src/App.tsx:14: const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER'). Grep over web/src confirms this is the ONLY place in the frontend source where the business scene id appears; all other occurrences are in models/*.yaml (M4:10, M8:76, M5:16, M7:17, MetaRule:25) and backend tests. The default is never derived from the fetched scenes list — the useEffect on lines 23-27 fires on mount with the hardcoded sceneId and calls api.sceneSchema('SCENE_CREATE_ORDER') → GET /api/meta/scene/SCENE_CREATE_ORDER (api.ts:22, which throws on non-2xx at api.ts:6). Concrete manifestation: renaming SCENE_CREATE_ORDER in M4-scene.yaml plus its bindings (a legit …
  • 反驳核验:Confirmed against source. web/src/App.tsx:14 declares const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER') — a literal, order-specific scene id. grep confirms SCENE_CREATE_ORDER is a real M4 business scene (models/M4-scene.yaml:10, also referenced in M5/M7/M8/MetaRule), so it is unambiguously a business identifier living inside web/, which the repo's iron rule explicitly names as a violation. Reachability: App.tsx:23-27 fires on first render because sceneId is truthy, calling api.sceneSchema('SCENE_CREATE_ORDER'); api.ts:5-7 throws on non-2xx, so a rename in M4 YAML makes the first schema fetch fail, App.tsx:26 shows an error toast, and the content card drops to the empty b …

A19. DataController hardcodes an order-specific list endpoint and aggregate name

  • 严重度:MEDIUM 维度:iron-rule 位置engine/src/main/java/com/onto/engine/web/DataController.java:31
  • 缺陷:A dedicated /orders/list endpoint hardcodes the aggregate id 'OrderAggregate', unlike the generic /data/{aggregateId} options endpoint next to it.
  • 证据 Lines 30-33: `@GetMapping("/orders/list") public List<Map<String, Object>> orders() { return readService.listRoot("OrderAggregate"); }`
  • 影响 / 触发场景:A business-named route ('orders') and a hardcoded aggregate literal in a controller that is otherwise generic (the sibling method takes {aggregateId}). Renaming OrderAggregate in M1 breaks this endpoint; listing any other aggregate's roots requires new Java. Should be a generic parameterized route like /data/{aggregateId}/list.
  • 建议修复:Replace with a generic @GetMapping("/{aggregateId}/list") that forwards the path variable to listRoot, removing the OrderAggregate literal and the 'orders' path.
  • 复现核验:Verified evidence at engine/src/main/java/com/onto/engine/web/DataController.java:30-33 exactly matches the quote: @GetMapping("/orders/list") and return readService.listRoot("OrderAggregate");. This is an unambiguous iron-rule violation: "OrderAggregate" is a hardcoded business aggregate identifier (the exact forbidden category), and grep confirms it is a real aggregate declared across 7 YAML models (M1/M2/M3/M4/M7/M8/ME) while this is the sole .java occurrence. The violation is genuine and not merely stylistic because the downstream DomainReadService.listRoot(String aggregateId) (line 27) is fully parameterized/generic, and the sibling endpoint /{aggregateId} (line 24-27) is genuinely …
  • 反驳核验:Refutation attempt fails on every angle. DataController.java:30-33 literally contains @GetMapping("/orders/list") and readService.listRoot("OrderAggregate"). (1) "OrderAggregate" is a real business aggregate id, confirmed present in models/M1,M2,M3,M4,M7,M8 as one of three business aggregates (CustomerAggregate/ProductAggregate/OrderAggregate) — the iron rule explicitly names hardcoded aggregate names as a violation. (2) The path is live, not dead: web/src/api.ts:27 (orders: () => get('/data/orders/list')) is called from panels.tsx:138 and shown in the "订单/事件" tab (App.tsx:100). (3) No guarding genericity — the opposite: DomainReadService.listRoot(String aggregateId) is generic and the …

A20. api.ts hardcodes the order-specific data URL, contradicting its own contract

  • 严重度:MEDIUM 维度:iron-rule 位置web/src/api.ts:27
  • 缺陷:The API client hardcodes '/data/orders/list', despite the file's header comment asserting the frontend never hardcodes business addresses.
  • 证据 Line 1: `// ...所有 URL 均由后端场景 Schema 给出(commandApi/optionsSource),前端不硬编码业务地址。` ; line 27: `orders: () => get('/data/orders/list'),`
  • 影响 / 触发场景:A business-specific URL baked into the generic renderer's API layer, directly violating the documented promise. Renaming the Order aggregate/endpoint requires editing this TS file. (Pairs with the DataController hardcode.)
  • 建议修复:Expose a generic list call list: (aggregateId) => get(/data/${aggregateId}/list) and have panels pass the aggregate id resolved from the scene schema.
  • 复现核验:Evidence verified verbatim. web/src/api.ts:1 asserts 所有 URL 均由后端场景 Schema 给出...前端不硬编码业务地址 (frontend does not hardcode business addresses), yet api.ts:27 orders: () => get('/data/orders/list') bakes the order-specific business word orders into a URL. The generic sibling on line 26 (options: (aggregateId) => get('/data/'+aggregateId), parameterized by aggregate) proves a generic route pattern exists, so orders/list is a genuine special-case, not a necessary constant. It pairs with DataController.java:30-32, which hardcodes @GetMapping("/orders/list")readService.listRoot("OrderAggregate") (business-literal in engine too). Concrete manifestation: per the iron rule, changing requ …
  • 反驳核验:The hardcode is verifiably present and reachable, so it cannot be killed as a false positive. web/src/api.ts:27 reads orders: () => get('/data/orders/list') — an order-specific path baked into the frontend. It is live, not dead code: panels.tsx:138 (api.orders().then(setOrders)) inside OrdersEventsPanel is mounted as a real tab at App.tsx:100 (key 'orders'). Renaming the order aggregate/endpoint would force editing this TS line plus DataController.java:30-33 (/orders/list -> listRoot("OrderAggregate")), so the frontend-to-order coupling the iron rule forbids genuinely exists. My best refutations only partially hold: (a) the line-1 comment's parenthetical does scope "所有 URL" to commandA …

A21. panels.tsx hardcodes Order/Product business field names as table columns

  • 严重度:MEDIUM 维度:iron-rule 位置web/src/panels.tsx:143
  • 缺陷:The results/inspection panels hardcode order and stock-action field names (orderId, customerId, totalAmount, totalCurrency, productId, deductQty, remainingStock) instead of deriving columns from the model.
  • 证据 Lines 144-151: rowKey="orderId" with columns `{ title: '订单号', dataIndex: 'orderId' }, { title: '客户', dataIndex: 'customerId' }, { title: '金额', dataIndex: 'totalAmount' }, { title: '币种', dataIndex: 'totalCurrency' }`; also lines 34 `label="订单号 orderId">{result.orderId}` and 51-54 `dataIndex: 'productId' / 'deductQty' / 'remainingStock'`.
  • 影响 / 触发场景:The 'generic renderer' hardwires the Order aggregate root's field names and the stock-deduct action shape into React. Rename any of these in M1/M3, or drive the panel from a different aggregate, and the columns silently render empty. Column definitions for a root list should come from the model (M1 attributes / M8), like the main SceneForm does.
  • 建议修复:Build the orders/root table columns from the scene schema's aggregate attributes (M1) rather than literal dataIndex strings; render the created-id using the schema's rootIdentifier.
  • 复现核验:Evidence verified verbatim in web/src/panels.tsx. Line 34: label="订单号 orderId">{result.orderId}. Lines 144-150 (OrdersEventsPanel): rowKey="orderId" plus columns dataIndex 'orderId','customerId','totalAmount','totalCurrency'. Lines 51-53 (TracePanel stockActions table): dataIndex 'productId','deductQty','remainingStock'. These are Order-aggregate M1 field names and the StockDeduct action shape hardcoded into React.

The data genuinely flows under these keys today, so the columns are live, not dead: DomainReadService.listRoot (lines 27-37) reverse-maps physical columns to M1 domain field names, so /data/orders/list yields rows keyed orderId/customerId/totalAmount/totalCurrency; EventEngine. …

  • 反驳核验:Verified in web/src/panels.tsx: line 34 renders result.orderId under label "订单号 orderId"; lines 51-53 hardcode stock-action columns dataIndex: 'productId' / 'deductQty' / 'remainingStock'; lines 147-150 hardcode order columns dataIndex: 'orderId' / 'customerId' / 'totalAmount' / 'totalCurrency' with rowKey="orderId" (line 144). These are exactly the business field names the iron rule names as violations ("customerId"/"totalAmount"), sitting inside web/. My best refutations all fail: (1) the "demo panel, not generic renderer" defense is void because the iron rule covers all of web/ with no carve-out, and the adjacent ProvenancePanel (line 83) even claims "本页面无任何业务硬编码" while OrdersEvents …

A22. SceneForm hardcodes the root-id field name and order-specific UI copy

  • 严重度:MEDIUM 维度:iron-rule 位置web/src/engine/SceneForm.tsx:96
  • 缺陷:The generic form reads the created id as r.orderId and labels its actions '新增订单', assuming the scene is always order creation.
  • 证据 Line 96: `message.success(`订单创建成功:${r.orderId}`)` ; line 147: `<Button type="primary" loading={submitting} onClick={doSubmit}>提交「新增订单」</Button>`
  • 影响 / 触发场景:The backend returns the created id under a dynamic key (models.rootIdentifier(aggregateId), i.e. it would be customerId for a customer scene), so hardcoding r.orderId shows 'undefined' for any non-order scene. The button/message copy '新增订单' likewise ignores schema.sceneName. Renaming orderId in M1, or reusing the renderer for another aggregate/scene, breaks the success feedback despite the renderer being 'generic'.
  • 建议修复:Read the created id via the schema's rootIdentifier (or have the backend return it under a stable generic key like rootId), and derive button/message text from schema.sceneName/template.
  • 复现核验:Evidence verified verbatim. web/src/engine/SceneForm.tsx:96 reads the created id as r.orderIdmessage.success(订单创建成功:${r.orderId}) — and line 147 hardcodes order copy — 提交「新增订单」. This component's own docstring (lines 29-33) declares it a "场景通用渲染引擎" ("完全由 M8 模板结构驱动…改模板即改页面"), i.e. it is supposed to be the generic renderer, so hardcoding the business field name orderId and the order-specific label 新增订单 is exactly the class of iron-rule violation the contract forbids in web/ (it enumerates field names like customerId as violations; orderId is the same category).

The impact is confirmed against the backend: CommandEngine.executeCommand returns the created id under a DYNAMIC key, not …

  • 反驳核验:Finding survives refutation. Verified in web/src/engine/SceneForm.tsx:96 message.success(订单创建成功:${r.orderId}) and line 147 button copy 提交「新增订单」, both inside the file CLAUDE.md designates as the ★ generic renderer. The backend confirms the coupling: CommandEngine.java:149 String rootIdentifierField = models.rootIdentifier(aggregateId) and line 192 out.put(rootIdentifierField, rootId) return the created id under a DYNAMIC key — orderId only for the order aggregate, customerId for a customer scene. So r.orderId is hard-bound to the order aggregate; any other aggregate yields undefined. types.ts:32 shows the schema already exposes a generic sceneName that the button/message i …

A23. MetaRule 可用变量被硬编码白名单限定,引用其他字段的规则被静默跳过

  • 严重度:MEDIUM 维度:model-integrity 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:246
  • 缺陷:runRules 只向规则引擎注入 totalAmount / customerLevel / stockNum 三个硬编码变量;RuleEngine 对『变量未在 env 中出现』的规则直接 continue,因此任何在 MetaRule 中新增、引用其他领域字段的规则都会被静默忽略。
  • 证据 CommandEngine.java:246-247 `orderVars.put("totalAmount", ...)` `orderVars.put("customerLevel", level)`,第 255 行 `ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock))`;RuleEngine.java:50 `if (!env.keySet().containsAll(vars)) { continue; }`。MetaRule-business.yaml:17 定义的 vip_discount_limit 参数从未被任何 matchCondition 引用。
  • 影响 / 触发场景:README/HelpButton 宣称『改 MetaRule 表达式即改前后端行为』,但若在 MetaRule 增一条如 dueDays > 60 的规则,dueDays 从未注入 env,规则永远命中不了、也不报错——只有恰好用到这三个变量的规则才生效。这是『改 YAML→改行为』未被通用兑现,且 vip_discount_limit 为悬空死参数。
  • 建议修复:根据 payload(master+details) 通用地把所有领域字段注入规则 env(或至少注入规则 matchCondition 实际引用的变量),而非固定注入三个字段。
  • 复现核验:All quoted evidence verified in the actual files. CommandEngine.runRules (lines 241-260) injects rule variables at exactly two call sites: line 248 evaluates with orderVars={totalAmount, customerLevel} and line 255 evaluates with Map.of("stockNum", stock). These are the ONLY places variables reach RuleEngine.evaluate. RuleEngine.java:50 does if (!env.keySet().containsAll(vars)) { continue; } with no log at that continue, so any rule whose referenced variable is absent from env is silently skipped (env also holds the three global params single_order_max_amount/stock_warn_min/vip_discount_limit, but no business field beyond the hardcoded trio). Concrete manifestation: add a rule to ORDER_RIS …
  • 反驳核验:Core mechanism confirmed by reading the files. CommandEngine.runRules (lines 241-260) injects only hardcoded business variables into the rule env: totalAmount + customerLevel (lines 246-247, order-level) and stockNum per OrderItem (line 255); the extraction is itself order-specific (ctx.details.getOrDefault("OrderItem"...), it.get("productId"), ctx.master.get("totalAmount")). RuleEngine.evaluate line 50 if (!env.keySet().containsAll(vars)) { continue; } skips with NO log/error. So a MetaRule referencing any other modeled field is silently no-op'd. This is reachable and in-scope for THIS demo: dueDays is a real domain field (PaymentTerm, validated at CommandEngine:232-236) yet is never inje …

A24. 通用 executeCommand 一律按『新建聚合根』落库,修改/取消类命令被误执行为插入

  • 严重度:MEDIUM 维度:model-integrity 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:155
  • 缺陷:executeCommand 无视 M2 命令语义(bizSteps),对任何 bindCommand 步骤都生成新 rootId 并 insertDomain 主记录+子实体,因此 SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER 会插入一条全新订单而非修改/取消既有订单。
  • 证据 CommandEngine.java:148 `String rootId = IdGen.next(idPrefix(aggregateId));` 154 `master.putIfAbsent("createTime", ...)` 155 `repo.insertDomain(aggregateId, rootName, null, master, null);`(无 UPDATE 分支);M2-command.yaml ModifyOrderDeliveryAddress(132) 与 CancelOrder(160) 的 bizSteps 表达的是修改/作废语义;M4 场景 SCENE_MODIFY_ORDER_ADDR(22) / SCENE_CANCEL_ORDER(32) 都走 bindCommand。
  • 影响 / 触发场景:只要在 M4 定义一个非新增语义的场景并绑定命令,引擎就会错误地新建一条聚合根记录(ModifyOrderDeliveryAddress 会造出一条只有 orderId/createTime 的空订单,CancelOrder 亦然),并照常发事件、扣库存。引擎并未通用解释 M2 命令语义——与『通用执行任意场景命令』的承诺不符。
  • 建议修复:依据 M2 命令是否包含聚合根标识入参 / bizSteps 判定 create vs update/cancel,分别实现 insert 与 update,或在 M2/M0 引入命令类型字段供引擎分派。
  • 复现核验:Evidence verified against source. In CommandEngine.java, execute() (lines 82-106) dispatches every flowStep of stepType "bindCommand" to executeCommand(), which unconditionally does: line 148 String rootId = IdGen.next(idPrefix(aggregateId));, line 153 master.put(rootIdentifierField, rootId), line 154 master.putIfAbsent("createTime", ...), line 155 repo.insertDomain(aggregateId, rootName, null, master, null). There is no UPDATE/cancel branch anywhere; the command's semantics (desc/bizSteps) are never consulted — only its validations text is scanned for the literals "手机号"/"dueDays" (lines 224-237). So the engine treats ALL commands as "create new aggregate root + insert".

Concrete, …

  • 反驳核验:The finding survives every refutation attempt. CommandEngine.executeCommand (engine/.../command/CommandEngine.java:113-201) has a single, unconditional code path: line 148 String rootId = IdGen.next(idPrefix(aggregateId)) generates a fresh root id, line 153 master.put(rootIdentifierField, rootId) overwrites any caller-supplied orderId, line 154 adds createTime, and line 155 repo.insertDomain(aggregateId, rootName, null, master, null) inserts a NEW aggregate-root row (plus children). There is no UPDATE/soft-delete branch and no reading of M2 command semantics (bizSteps/desc). So ModifyOrderDeliveryAddress and CancelOrder are executed as inserts.

Reachability confirmed end-to-end: (1) B …

A25. 引擎在校验逻辑中硬编码 M1 实体名/字段名,重命名即静默失效

  • 严重度:MEDIUM 维度:model-integrity 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:226
  • 缺陷:validate/runRules 用字面量 "DeliveryAddressEntity"."receiverPhone"."PaymentTerm"."dueDays"."OrderItem"."productId" 定位数据,一旦 M1/M3/M8 中这些实体或字段被改名,对应的手机号/账期/库存校验会无声失效。
  • 证据 CommandEngine.java:226 `ctx.details.getOrDefault("DeliveryAddressEntity", ...)` 227 `row.get("receiverPhone")` 233 `getOrDefault("PaymentTerm", ...)` / `row.get("dueDays")` 251 `getOrDefault("OrderItem", ...)` 183 `it.get("productId")`。这些名称均来自 M1-domain.yaml(如 receiverPhone:114, DeliveryAddressEntity:101)。
  • 影响 / 触发场景:作为『通用解释引擎』,这些应从 M2 validations/M1 结构动态推导。当前若把 M1 的 receiverPhone 或 DeliveryAddressEntity 改名(正是『改模型』场景),手机号格式校验直接找不到数据、静默跳过——latent break,且违反铁律。
  • 建议修复:校验目标应从 M1 attributes/M2 validations 结构化推导(例如为 M2 validations 引入结构化断言而非中文文本匹配),不在引擎内硬编码实体名与字段名。
  • 复现核验:Evidence verified exactly as quoted in CommandEngine.java:

  • Line 226: for (Map<String, Object> row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of())) then line 227 Object p = row.get("receiverPhone");

  • Line 232-234: if (cmdValidations.contains("dueDays"))ctx.details.getOrDefault("PaymentTerm", List.of())row.get("dueDays")

  • Line 251-252: for (Map<String, Object> it : ctx.details.getOrDefault("OrderItem", List.of()))it.get("productId")

  • Line 183: items.add(Map.of("productId", it.get("productId"), ...))

Every one of these literals is a business identifier that originates in models/M1-domain.yaml (confirmed: OrderItem@73, PaymentTerm@88, DeliveryAddres …

  • 反驳核验:I tried to kill this finding and could not. The cited literals are present verbatim in engine/.../command/CommandEngine.java: line 226 ctx.details.getOrDefault("DeliveryAddressEntity", List.of()), 227 row.get("receiverPhone"), 232-234 getOrDefault("PaymentTerm", ...) / row.get("dueDays"), 251 getOrDefault("OrderItem", ...), 252/183 it.get("productId"). All are business-specific M1 identifiers (M1-domain.yaml:101 DeliveryAddressEntity, :114 receiverPhone, :88 PaymentTerm, :93 dueDays, :73 OrderItem, :27/:76 productId) — exactly the class of literal the project's iron rule names as forbidden inside engine/.\n\nRefutation attempts all fail: (1) The literal is the lookup KEY into ctx …

A26. M5 encryptRules.storageEncrypt 从未被引擎兑现,敏感字段明文入库

  • 严重度:MEDIUM 维度:model-integrity 位置models/M5-security.yaml:30
  • 缺陷:M5 声明 contactPhone/receiverPhone 需存储加密,前端控件也提示『存储加密』,但引擎落库时无任何加密逻辑,手机号以明文写入数据库。
  • 证据 M5-security.yaml:29-30 `encryptRules:` `storageEncrypt: [contactPhone, receiverPhone]`;DynamicRepository.insertDomain(46-69) 仅按 M1 类型 convert 后原样 jdbc.update,无加密;全仓 grep `encrypt` 在 engine/web 源码零命中;FieldControl.tsx:80 Tooltip 文案 `M5 敏感字段:存储加密、展示脱敏` 属虚假宣称。
  • 影响 / 触发场景:M5 模型的 storageEncrypt 指令是未被引擎读取/执行的死配置,敏感 PII 明文落库,与模型声明和前端提示矛盾;『改 M5 即改安全行为』对加密维度不成立。
  • 建议修复:在 DynamicRepository 写入/DomainReadService 读取时按 M5.encryptRules.storageEncrypt 通用地加解密,或移除该未实现的模型声明与前端『存储加密』提示。
  • 复现核验:All quoted evidence verified directly in the source. models/M5-security.yaml:29-30 declares encryptRules: storageEncrypt: [contactPhone, receiverPhone]. DynamicRepository.insertDomain (lines 46-69) only type-converts values via convert(type, ...) (lines 54-56) and calls jdbc.update(sql, args.toArray()) (line 68) with no encryption step. grep -rniI "encrypt" over engine/src and web/src yields zero hits (only match repo-wide is the React DOM event onEncrypted in the minified web/dist bundle). Crucially, ModelRepository (lines 234-250) exposes securityModel(), maskRules(), and functionPermission() but has NO accessor for encryptRules/storageEncrypt — so the directive is neve …
  • 反驳核验:The finding is factually airtight and survives refutation. Verified: (1) M5-security.yaml:29-30 declares encryptRules.storageEncrypt:[contactPhone,receiverPhone]; (2) M0-meta-schema.yaml:245 makes encryptRules a REQUIRED schema member, so it is a first-class declared contract; (3) ModelRepository.java:236-242 exposes securityModel()/maskRules() but has NO encryptRules/storageEncrypt accessor, and grep -rin "encrypt" over engine/src and web/src returns ZERO hits — the directive is never read by any engine or web code; (4) DynamicRepository.insertDomain (46-69) only convert()s by M1 type then plain jdbc.update — no encryption; (5) contactPhone/receiverPhone are real persisted fields (M1:13/114 …

A27. Negative/zero item quantity is unvalidated and inflates inventory via StockDeduct

  • 严重度:MEDIUM 维度:security 位置engine/src/main/java/com/onto/engine/event/EventEngine.java:106
  • 缺陷:Item quantity is never validated to be positive; a negative quantity passes the sufficiency check and the deduct SQL subtracts a negative number, increasing stock.
  • 证据 long qty = ... Long.parseLong(...); // L92, may be negative if (current < qty) { ... FAIL_INSUFFICIENT } // L106, negative qty passes repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE " + pkCol + " = ?", qty, productId); // L111: stock - (-N) = stock + N
  • 影响 / 触发场景:CommandEngine.validate() checks only phone format and dueDays; it never checks item quantity. Attack: submit an order item with quantity -1000; the sufficiency guard (current < qty) is false, and UPDATE stock_num = stock_num - (-1000) inflates product inventory by 1000. Zero/negative quantities also let an attacker create orders that deduct nothing while still passing.
  • 建议修复:Validate quantity > 0 (and enforce numeric bounds) in the command validation stage before persistence and before cross-aggregate stock actions.
  • 复现核验:Verified all quoted evidence exists as claimed. EventEngine.deductStock (engine/.../event/EventEngine.java): L92 parses qty via Long.parseLong preserving negative sign; L106 if (current < qty) returns FAIL_INSUFFICIENT only when stock is below qty — for a negative qty (e.g. -1000) with positive current stock, current < qty is false so it passes; L111 UPDATE ... SET stock_num = stock_num - ? with qty=-1000 computes stock_num-(-1000)=stock_num+1000, inflating inventory.

Reachability confirmed end-to-end: CommandController.execute (POST /api/scene/{sceneId}/execute) forwards the raw request body to commandEngine.execute with principal defaulting to normal_user. CommandEngine.validate() ( …

  • 反驳核验:Refutation failed; the finding is a genuine, reachable defect. Mechanics: EventEngine.deductStock L92 parses qty via Long.parseLong without a sign check; a negative value (e.g. -1000) passes the L106 sufficiency guard if (current < qty) because a positive current is never < a negative qty, then L111 UPDATE ... SET stock = stock - ? computes stock - (-1000) = stock + 1000, inflating inventory. Reachability: CommandEngine.executeCommand step e (L181-186) builds items from raw payload detail rows via it.getOrDefault("quantity",0) and calls applyCrossConsistency("OrderCreated", items) -> deductStock; the Ctx constructor (L326-331) copies all payload keys verbatim, so a client-supplied quanti …

A28. Submit button label hardcodes '新增订单'

  • 严重度:LOW 维度:frontend 位置web/src/engine/SceneForm.tsx:147
  • 缺陷:The primary submit button text is the order-specific literal '提交「新增订单」' regardless of which scene is rendered.
  • 证据 <Button type="primary" loading={submitting} onClick={doSubmit}>提交「新增订单」</Button>
  • 影响 / 触发场景:A generic renderer that loads any M4 scene still labels its submit action '新增订单'; changing the scene in YAML does not change the wording. Minor, but a business term hardcoded in the generic engine.
  • 建议修复:Use schema.sceneName or the command desc for the button label, e.g. 提交「${schema.sceneName}」.
  • 复现核验:Verified: web/src/engine/SceneForm.tsx:147 contains exactly <Button type="primary" loading={submitting} onClick={doSubmit}>提交「新增订单」</Button>. This lives in SceneForm, self-documented (lines 29-33) as the "场景通用渲染引擎" (generic scene renderer), which the iron rule requires to be business-agnostic. "新增订单" (New Order) is order-specific terminology hardcoded in the generic engine.

Concrete manifestation: SceneSchema (web/src/types.ts:33) already carries a scene-agnostic sceneName, and App.tsx:73/90 renders scenes generically from it. If an operator edits M4/M8 YAML to add or rename a non-order scene (e.g. a purchase-order or repair-ticket scene) and hot-reloads, the left nav and card title cor …

  • 反驳核验:Verified against web/src/engine/SceneForm.tsx:147: <Button type="primary" loading={submitting} onClick={doSubmit}>提交「新增订单」</Button>. The literal '新增订单' (new order) is unconditionally rendered as the primary submit button for EVERY scene. My best refutation attempts all fail: (1) not unreachable — it's the main submit button in the returned JSX (lines 144-148); (2) not an acceptable generic fallback — the component's own docstring (lines 29-33) calls it "场景通用渲染引擎" and CLAUDE.md lists it as "★ 通用渲染引擎"; the useEffect is keyed on schema.sceneId (line 65) and all real logic is schema-driven, so loading any non-order M4 scene still mislabels the button; a truly generic caption would be "提交"; (3) …

A29. firstItemEntity() falls back to the literal 'OrderItem'

  • 严重度:LOW 维度:iron-rule 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:368
  • 缺陷:When no composition child is found, the engine defaults the order-line entity to the hardcoded name 'OrderItem'.
  • 证据 Lines 368-371: `private String firstItemEntity(String aggregateId) { List<String> ch = models.compositionChildren(aggregateId); return ch.isEmpty() ? "OrderItem" : ch.get(0); }`
  • 影响 / 触发场景:The primary path is model-driven (first composition child), but the fallback literal 'OrderItem' and the design assumption that 'the first composition child is the line-item collection carrying productId/quantity' embed order semantics. For an aggregate with a different or reordered composition layout, the wrong entity is chosen or a non-existent 'OrderItem' is queried.
  • 建议修复:Remove the literal fallback; if line items are meaningful they should be identified via an explicit model marker rather than positional 'first child' + hardcoded default.
  • 复现核验:Evidence verified verbatim at CommandEngine.java:367-371. The ternary return ch.isEmpty() ? "OrderItem" : ch.get(0); embeds the literal order-domain entity name "OrderItem" directly in engine code. The iron rule explicitly names "OrderItem" as a canonical example of a forbidden hardcoded entity identifier inside engine/, so this is an unambiguous textual iron-rule violation: the generic interpreter "knows" a domain entity name. Renaming/removing the order-line entity in the YAML would not update this fallback — i.e. a requirements change would require touching engine code, exactly what the rule forbids. Reachability: firstItemEntity is called unconditionally at line 182 for every command …
  • 反驳核验:Verified at CommandEngine.java:370 the literal return ch.isEmpty() ? "OrderItem" : ch.get(0);. The iron rule explicitly names "OrderItem" as a prohibited hardcoded entity name in engine/, so this is a genuine (if trivial) iron-rule violation — an order-specific entity name compiled into the generic interpreter. It is reinforced by line 251 (ctx.details.getOrDefault("OrderItem", ...)) and lines 183/186 (it.get("productId"), applyCrossConsistency("OrderCreated", ...)) which hardcode order semantics unconditionally in the same method. My best refutation only partially lands: (1) the fallback branch is effectively unreachable for the configured order aggregate because compositionChildren …

A30. App.tsx defaults the initial scene to the literal SCENE_CREATE_ORDER

  • 严重度:LOW 维度:iron-rule 位置web/src/App.tsx:15
  • 缺陷:The generic shell hardcodes which scene opens first instead of selecting from the fetched scene list.
  • 证据 Line 15: `const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER')`
  • 影响 / 触发场景:Scenes are otherwise fetched dynamically, but the initial selection is a business-specific scene id. If SCENE_CREATE_ORDER is renamed/removed in M4, the first schema load 404s until the user manually clicks another scene. Minor, but it means changing the demo's primary scene requires editing frontend code.
  • 建议修复:Initialize sceneId to the first scene with a bound template from the fetched /meta/scenes list, rather than a hardcoded id.
  • 复现核验:Evidence confirmed at web/src/App.tsx:14 (finding said 15, but the exact quoted code const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER') is on line 14 — "at/near" the cited line). SCENE_CREATE_ORDER is a genuine business-specific scene identifier: it is defined in models/M4-scene.yaml:10 as the "用户创建订单完整流程" (create-order) scene and cross-referenced from M5/M7/M8/MetaRule. The iron rule explicitly forbids hardcoded business identifiers in web/; the generic shell should derive its initial scene from the dynamically fetched scene list, not pin a specific order scene.\n\nManifestation is reachable: the schema-loading useEffect (App.tsx:23-27) fires on mount with the hardcode …
  • 反驳核验:Confirmed at web/src/App.tsx line 14 (finding cites line 15 — off by one; line 15 is the schema state, but the quoted evidence matches line 14): const [sceneId, setSceneId] = useState<string>('SCENE_CREATE_ORDER'). This literal is a business-specific M4 scene id hardcoded in the generic frontend shell. My refutation attempts all fail: (1) The value is NOT overridden after fetch — the scenes effect at line 21 (api.scenes().then(setScenes)) never calls setSceneId, so the effect at lines 23-27 fires api.sceneSchema('SCENE_CREATE_ORDER') on mount and the literal persists until a manual click. (2) It is not an unavoidable seed — the existing if (!sceneId) return guard at line 24 already …

A31. SchemaInitializer.seed() hardcodes Customer/Product aggregate and entity names

  • 严重度:LOW 维度:iron-rule 位置engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java:121
  • 缺陷:Demo seeding is written for exactly two named aggregates (CustomerAggregate/Customer, ProductAggregate/Product) instead of iterating the seed file generically.
  • 证据 Lines 122-136: `repo.tableName("CustomerAggregate", "Customer")` ... `repo.insertDomain("CustomerAggregate", "Customer", null, row, null)` and `repo.tableName("ProductAggregate", "Product")` ... `repo.insertDomain("ProductAggregate", "Product", null, row, null)`
  • 影响 / 触发场景:seed-data.yaml is explicitly non-ontology demo scaffolding, so this is the mildest case, but the loader still hardcodes both aggregate ids and their root entity names ('Customer'/'Product'). Adding a seeded reference aggregate, or renaming these in M1, requires editing Java even though the seed file is keyed by aggregate id and could be driven generically (for each key → aggregateId, look up rootName, insert).
  • 建议修复:Iterate the seed map's keys as aggregate ids, resolve rootName via models.rootName(key), and insert generically — no CustomerAggregate/ProductAggregate/Customer/Product literals.
  • 复现核验:Evidence verified verbatim in engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java. seed() (lines 113-140) hardcodes the aggregate ids and root entity names in four places: line 122 repo.tableName("CustomerAggregate", "Customer"), line 124 seed.getOrDefault("CustomerAggregate", ...), line 127 repo.insertDomain("CustomerAggregate", "Customer", null, row, null), and lines 132/134/136 the mirror image for "ProductAggregate"/"Product". Line 126 additionally hardcodes a Customer-specific business field name: row.putIfAbsent("registerTime", ...). The task's own iron-rule definition explicitly names "CustomerAggregate"/"ProductAggregate" as example aggregate identifiers w …
  • 反驳核验:Confirmed real iron-rule violation. In engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java, seed() hardcodes business aggregate/entity identifiers as string literals: line 122 repo.tableName("CustomerAggregate", "Customer"), line 127 repo.insertDomain("CustomerAggregate", "Customer", null, row, null), line 132 repo.tableName("ProductAggregate", "Product"), line 136 repo.insertDomain("ProductAggregate", "Product", null, row, null), plus line 126 hardcodes the business field name registerTime. These are exactly the aggregate/entity/field identifiers the iron rule enumerates as forbidden inside engine/.

Refutation attempts all fail: (1) The "non-ontology demo scaffold …

A32. ME OrderCancelled→StockDeduct 与 M2 CancelOrder『归还库存』语义相反

  • 严重度:LOW 维度:model-integrity 位置models/ME-event.yaml:63
  • 缺陷:取消订单本应回补库存,但 ME 把 OrderCancelled 的 targetCommand 设为 StockDeduct(扣减),而 ProductAggregate 根本没有『增加库存』命令;模型层面自相矛盾。
  • 证据 ME-event.yaml:63-66 `- triggerEvent: OrderCancelled / targetAggregate: ProductAggregate / targetCommand: StockDeduct`;M2-command.yaml:167 CancelOrder.bizSteps `...3.远程调用Product聚合增加对应库存...`;M2 ProductAggregate 仅有 StockDeduct(88) 一个库存命令,无 StockReturn/StockAdd。EventEngine.deductStock(85-118) 也只做 `stock = stock - qty`。
  • 影响 / 触发场景:即便引擎将来正确按 OrderCancelled 触发跨聚合规则,也会对取消订单继续扣减库存,与业务意图完全相反;这是 ME↔M2 的内在建模不一致,且缺少回补库存的命令定义。
  • 建议修复:在 M2 ProductAggregate 增加 StockReturn/StockAdd 命令并在 ME 将 OrderCancelled 的 targetCommand 指向它;引擎 applyCrossConsistency 依 targetCommand 区分扣减/回补。
  • 复现核验:All quoted evidence verified. ME-event.yaml:63-66 maps OrderCancelled → targetAggregate: ProductAggregate → targetCommand: StockDeduct (deduct). M2-command.yaml:160-169 defines CancelOrder whose desc (161) is 取消订单,归还库存 and bizSteps step 3 (167) is 远程调用Product聚合增加对应库存 (INCREASE stock). M4-scene.yaml:32-40 SCENE_CANCEL_ORDER is likewise named 取消订单、归还库存. So the cancel lifecycle intent is stated as "return/increase stock" in three model files, while ME routes it to StockDeduct — a direct semantic contradiction. Further, M2 ProductAggregate exposes only StockDeduct (line 88) with no StockReturn/StockAdd, and EventEngine.deductStock (85-118) only performs stock = stock - qty (line 111) w …
  • 反驳核验:The finding is a model-integrity claim, and the evidence is incontrovertibly verified. ME-event.yaml:63-66 routes OrderCancelled to targetCommand StockDeduct, which is a stock DECREMENT: M2-command.yaml:88 (only stock command in ProductAggregate) has validation "可用库存 >= deductQty" and emits ProductStockDeducted; EventEngine.java:111 executes stock = stock - qty. Yet three other model files unanimously say cancel must RETURN/increase stock: M4-scene.yaml:33 sceneName "取消订单、归还库存", M2-command.yaml:161 desc "取消订单,归还库存" and :167 bizSteps "...远程调用Product聚合增加对应库存...", and MetaRule-business.yaml:44-49 R_CANCEL_STOCK_RETURN effect COMPENSATE "订单取消自动返还锁定商品库存". No StockReturn/StockAdd command exists …

附录 B · B 级发现 —— 单边确认 / 严重度存疑(复现者判真、反驳者提异议,共 18 条)

复现者确认可触发,但反驳者对其严重度或"是否属既定设计"提出异议,需人工裁定。

B1. Insufficient/absent stock silently commits the order (cross-aggregate consistency computed then discarded)

  • 严重度:BLOCKER 维度:eda 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:185
  • 缺陷:When StockDeduct fails (insufficient stock or product not found), the failure is only recorded in a log map; the order insert + outbox event still commit, so orders exist against stock that was never deducted.
  • 证据 CommandEngine.executeCommand(): L185: List<Map<String, Object>> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); L187: if (!stockActions.isEmpty()) { trace.add(...); } L191: out.put("success", true); EventEngine.deductStock(): L106: if (current < qty) { L107: action.put("status", "FAIL_INSUFFICIENT"); L109: return action; // returns WITHOUT throwing } L101: if (row.isEmpty()) { action.put("status", "SKIP_NOT_FOUND"); return action; } The returned status is never inspected; no exception is raised, so the @Transactional execute() commits.
  • 影响 / 触发场景:Order for product P001 (stock=1) with quantity=5: master + OrderItem rows are INSERTed, OrderCreated is written to the outbox, deductStock returns status=FAIL_INSUFFICIENT, executeCommand still sets success=true, and @Transactional on execute() commits everything. Result: an order committed for goods that were never deducted (oversell), with stock unchanged. Same for a bad/renamed productId -> SKIP_NOT_FOUND, order still commits. The cross-aggregate consistency the design promises is computed and thrown away.
  • 建议修复:Have deductStock throw a domain exception on FAIL_INSUFFICIENT/SKIP_NOT_FOUND (or return a failure that executeCommand checks) so the surrounding @Transactional rolls back the order, or move stock reservation before persist and abort the flow with stage=STOCK_INSUFFICIENT.
  • 复现核验:Evidence verified verbatim. CommandEngine.executeCommand L185-186 calls eventEngine.applyCrossConsistency("OrderCreated", ...); L187-189 only adds a trace when stockActions is non-empty and never inspects the per-item status; L191 unconditionally sets out.put("success", true). execute() is @Transactional (L67) and treats a non-FALSE success as normal flow, so absent any exception the transaction commits. EventEngine.deductStock returns action maps with status SKIP_NOT_FOUND (L101-103, product not found) or FAIL_INSUFFICIENT (L106-110, current<qty) WITHOUT throwing — the master insert (L155), child inserts (L165), and OrderCreated outbox append (L175) all precede this and thus commit.\n\nConc …
  • 反驳核验:The finding mischaracterizes an intentional eventual-consistency design as a blocker bug. (1) models/ME-event.yaml declares the OrderCreated -> ProductAggregate.StockDeduct rule with consistencyType: eventual. Under DDD/EDA, a transaction mutates one aggregate and cross-aggregate effects are decoupled; an order committing independently of stock deduction is the normal saga intermediate state, not corruption. Rolling the order transaction back on another aggregate's command failure (what the finding demands) would turn declared eventual consistency into synchronous strong consistency across aggregates, contradicting the model. (2) The finding's central factual claim — "the failure is only r …

B2. No idempotency / duplicate-submission protection on command execution

  • 严重度:HIGH 维度:eda 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:67
  • 缺陷:execute() accepts no client request/idempotency key and generates a fresh root id per call, so a retried or double-clicked POST creates duplicate orders, duplicate outbox events, and double stock deductions.
  • 证据 L67: @Transactional public Map<String,Object> execute(String sceneId, Map<String,Object> payload, String principal) { L148: String rootId = IdGen.next(idPrefix(aggregateId)); // new id every call CommandController.execute() (L31-37) passes the raw payload through with no dedup token. Nothing in the flow checks for a prior identical request.
  • 影响 / 触发场景:Client network retry or a double-click on submit sends the same order payload twice. Each call gets a new orderId, inserts a full order, appends OrderCreated to the outbox, and deducts stock again. Two orders + double stock deduction for one business intent; no way to reconcile because there is no shared idempotency key.
  • 建议修复:Require a client-supplied idempotency key (or hash of payload) and persist it in a unique-constrained dedup table inside the same transaction; on conflict return the prior result instead of re-executing.
  • 复现核验:Evidence verified in-file. CommandEngine.execute() (L67-68) takes only (sceneId, payload, principal) with no idempotency/request key. L148 String rootId = IdGen.next(idPrefix(aggregateId)); mints a fresh id per call. CommandController.execute() (L31-37) only reads the X-Principal header and forwards the raw payload; nothing accepts or checks a dedup token. The execute()->executeCommand() path has no prior-request lookup, no unique constraint on the payload, and no idempotency store. Downstream mutations are genuinely non-idempotent: executeCommand inserts the root+children via repo.insertDomain (L155/165), appends OrderCreated to the outbox (eventEngine.append, L175), and triggers cross-ag …
  • 反驳核验:I read CommandEngine.java, CommandController.java, EventEngine.java and SchemaInitializer.java. The finding's factual claims are all accurate: execute() (L67) takes no idempotency token, rootId=IdGen.next(...) (L148) mints a fresh id per call, the controller (L31-37) only reads X-Principal, EventEngine.append (L42) always generates a new eventId, deductStock (L111) always re-subtracts, and the only PRIMARY KEY on domain tables is the freshly-generated root id (SchemaInitializer L91) — no natural-key uniqueness. So there is genuinely no dedup at any layer.

However, this is a MISSING HARDENING FEATURE, not a defect. Reasons the finding should not stand at the claimed 'high' severity:

1) No c …

B3. M8 客户模板绑定的 SCENE_CREATE_CUSTOMER 在 M4 中不存在

  • 严重度:HIGH 维度:model-integrity 位置models/M8-front-schema.yaml:20
  • 缺陷:M8 template_customer_single 绑定 bindSceneId: SCENE_CREATE_CUSTOMER,但 M4 sceneModel 从未定义该场景,导致该模板永远无法被通用引擎渲染或执行。
  • 证据 M8-front-schema.yaml:20 `bindSceneId: SCENE_CREATE_CUSTOMER`;M4-scene.yaml 仅定义 SCENE_CREATE_ORDER / SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER(无 SCENE_CREATE_CUSTOMER)。grep 全仓 SCENE_CREATE_CUSTOMER 仅出现在 M8。
  • 影响 / 触发场景:MetaController.scenes() 只遍历 M4 scenes,SCENE_CREATE_CUSTOMER 不在其中,故前端侧边栏永远不列出客户模板;若前端直接请求 /api/meta/scene/SCENE_CREATE_CUSTOMER,SceneSchemaService.buildSceneSchema 第 26 行 if (scene.isEmpty()) throw new NoSuchElementException 直接抛异常。M2 的 CreateCustomer/CreateProduct 命令、M5 无对应权限,全部成为无法触达的死元数据——违背『M8 模板↔M4 场景』契约。
  • 建议修复:在 M4 增加 sceneId: SCENE_CREATE_CUSTOMER(含 permissionBind + flowSteps bindCommand: CreateCustomer),并在 M5 functionPermissions 增补对应 permId;或修正 M8 的 bindSceneId 指向已存在场景。
  • 复现核验:Every link in the claimed chain is verified against the actual files. (1) models/M8-front-schema.yaml:20 does contain bindSceneId: SCENE_CREATE_CUSTOMER on template template_customer_single. (2) models/M4-scene.yaml defines only SCENE_CREATE_ORDER (l.10), SCENE_MODIFY_ORDER_ADDR (l.22), SCENE_CANCEL_ORDER (l.32); repo-wide grep shows SCENE_CREATE_CUSTOMER exists nowhere except M8, so it is a dangling reference. (3) MetaController.scenes() (l.30) iterates models.scenes(), which reads strictly M4.sceneModel.sceneDefinitions (ModelRepository.java:222-224); the React sidebar is built entirely from that list (web/src/App.tsx:21,53), so the customer template can never be listed. There is no al …
  • 反驳核验:The finding's raw facts check out but its "high"/reachable-defect framing does not survive inspection.

FACTS (confirmed): M4-scene.yaml only defines SCENE_CREATE_ORDER / SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER (M4:10,22,32); M8-front-schema.yaml:20 bindSceneId: SCENE_CREATE_CUSTOMER has no matching M4 scene. True.

WHY IT IS NOT A REACHABLE DEFECT:

  1. No forward dereference exists. The ONLY consumer of M8 templates is a reverse lookup: ModelRepository.templateByScene (ModelRepository.java:262-267) iterates pageTemplates() and matches sceneId.equals(tm.get("bindSceneId")), keyed on a REAL scene id passed in. pageTemplates()'s only caller is templateByScene itself (grep confirms line …

B4. M2 CreateOrder.emitEvents 引用了 ME 未定义的事件

  • 严重度:HIGH 维度:model-integrity 位置models/M2-command.yaml:128
  • 缺陷:CreateOrder 声明发出 OrderItemAdded / OrderPaymentTermSet / OrderDeliveryAddressSet,但 ME-event.yaml 的 OrderAggregate 只定义了 OrderCreated / OrderDeliveryAddressModified / OrderCancelled,三个事件在事件模型中不存在。
  • 证据 M2-command.yaml:128-130 `- OrderItemAdded` `- OrderPaymentTermSet` `- OrderDeliveryAddressSet`;ME-event.yaml:45/49/53 OrderAggregate.events 仅有 `OrderCreated:` `OrderDeliveryAddressModified:` `OrderCancelled:`。
  • 影响 / 触发场景:CommandEngine 第 174-177 行遍历 cmd.emitEvents 调用 eventEngine.append;EventEngine.append 第 41 行对 ME 未声明的事件走 def.isEmpty() ? deriveTopic(eventName),凭空派生出 topic_order_item_added 等 topic,且 eventPayload 因 eventDef 为空只塞 rootId。这些是 ME 从未声明的『幻影事件』,topic/payload/consumers 全部脱离事件本体——M2↔ME 不一致,消费者(product-service 等)也无从订阅。
  • 建议修复:要么在 ME-event.yaml 的 OrderAggregate 补齐 OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSet 的 topic+payload+consumers,要么从 M2 CreateOrder.emitEvents 删除这三个未建模事件。
  • 复现核验:Evidence fully verified. models/M2-command.yaml:126-130 declares CreateOrder.emitEvents = [OrderCreated, OrderItemAdded, OrderPaymentTermSet, OrderDeliveryAddressSet]. models/ME-event.yaml:43-56 defines OrderAggregate.events with only OrderCreated (45), OrderDeliveryAddressModified (49), OrderCancelled (53) — so OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSet are undefined. Grep over models/, engine/, web/ confirms those three names appear ONLY at M2:128-130. The runtime chain is real: CommandEngine.java:174-177 loops cmd.emitEvents calling eventEngine.append; eventPayload (373-384) resolves an empty eventDef (ModelRepository.eventDef:276-285 returns emptyMap for undefined events) …
  • 反驳核验:The literal observation is true: M2-command.yaml:126-130 declares CreateOrder.emitEvents = [OrderCreated, OrderItemAdded, OrderPaymentTermSet, OrderDeliveryAddressSet], while ME-event.yaml:44-56 defines only OrderCreated/OrderDeliveryAddressModified/OrderCancelled for OrderAggregate. But the finding's characterization as a high-severity defect does not survive. (1) The engine INTENTIONALLY supports events not declared in ME: EventEngine.append:40-41 comments "ME 已声明 topic 则用之;未声明的事件按命名约定派生 topic_snake_case" and branches def.isEmpty()?deriveTopic(...); ModelRepository.eventDef:284 returns emptyMap by design; CommandEngine.eventPayload:382 falls back to {rootId}. Nowhere is emitEvents⊆ME enfor …

B5. permitted() fails open when a scene has no permissionBind

  • 严重度:HIGH 维度:security 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:359
  • 缺陷:When a scene's permissionBind list is empty/absent the permission check returns true for everyone (default-allow / fail-open).
  • 证据 List<Object> perms = asList(scene.get("permissionBind")); if (perms.isEmpty()) return true; // any scene lacking permissionBind is executable by any principal
  • 影响 / 触发场景:Because the model is the source of truth and scenes are hot-reloadable (POST /api/meta/reload), any new/edited scene that omits permissionBind — or a typo that makes asList() return empty — becomes fully unauthenticated-executable. The safe default for an authorization gate must be deny, not allow. Attack: add a scene without permissionBind, reload, and anyone can run its bound command (persist rows, emit events, deduct stock).
  • 建议修复:Default to deny: return false (or throw) when permissionBind is empty/unresolved, and require every executable scene to bind at least one functionPermission.
  • 复现核验:Evidence confirmed at CommandEngine.java:357-364: permitted() does if (perms.isEmpty()) return true;, so a scene whose permissionBind is empty/absent authorizes every principal. It is reached from the execute() gate (line 76-77). There is genuinely no guardrail: ModelRepository.load()/reload() (lines 50-69) only calls new Yaml().load(in) with no schema validation, and the engine has no JSON-schema dependency. M0-meta-schema.yaml:226 lists permissionBind under required, but that is documentary and never enforced — a scene that omits the key or mistypes it (e.g. permissonBind:) loads fine, then scene.get("permissionBind") is null, asList()->empty, and permitted() returns true. Concrete …
  • 反驳核验:The literal code is as quoted: CommandEngine.java:359 if (perms.isEmpty()) return true; default-allows when permissionBind is empty. But the finding's framing as a high-severity, reachable authorization bypass does not hold up.

(1) There is no authorization boundary to fail open against. principal is read verbatim from a client-controlled X-Principal header (CommandController.java:34-35); grep of engine/src and pom.xml shows NO Spring Security, no SecurityFilterChain/filter/@PreAuthorize, and CORS is wide open (WebConfig.java:12-15). Any anonymous caller can set X-Principal to any value, and allowPrincipals values are served publicly (MetaController GET /api/meta/model/{code} raw M5 a …

B6. Generic /api/data/{aggregateId} dumps entire root table of any aggregate with no authz

  • 严重度:HIGH 维度:security 位置engine/src/main/java/com/onto/engine/web/DataController.java:24
  • 缺陷:The options/list read endpoints perform no authentication or permission check and return every row of the requested aggregate's root table, exposing all customers/orders to anonymous callers.
  • 证据 @GetMapping("/{aggregateId}") public List<Map<String, Object>> options(@PathVariable String aggregateId) { return readService.options(aggregateId); // DomainReadService.listRoot -> repo.listAll(table) = SELECT * FROM <table>; opt.put("data", d) returns ALL domain fields
  • 影响 / 触发场景:No principal is even passed to DataController; there is no ownership/row-level check. GET /api/data/CustomerAggregate returns every customer (name, level, masked phone); GET /api/data/OrderAggregate returns every order root row (customerId, totalAmount, etc.); GET /api/data/orders/list returns all orders. Any aggregateId is accepted, so the endpoint is a generic anonymous table-dump primitive. Only two fields (contactPhone/receiverPhone) are masked — all other columns are returned in the clear.
  • 建议修复:Require authentication and enforce a read permission (and per-record ownership for order data) before returning rows; restrict which aggregates are exposed as option sources rather than accepting an arbitrary aggregateId.
  • 复现核验:Evidence verified verbatim. DataController.java:24-27 exposes GET /api/data/{aggregateId} -> DomainReadService.options() -> listRoot() -> DynamicRepository.listAll() which runs SELECT * FROM <table> (DynamicRepository.java:76-78) and returns all rows. options() attaches every domain field via opt.put("data", d) (DomainReadService.java:48). M5-security-model.yaml maskRules contains only contactPhone and receiverPhone, so all other columns (customer name/level, order customerId/totalAmount) are returned in clear. There is NO authorization on this path: no @RequestHeader/principal param on DataController, no permission() call, and no Spring Security dependency in pom.xml (grep found none). Th …
  • 反驳核验:The finding is factually accurate (DataController.java:25-26 options() and :30-33 orders() return every root row with no principal), but it mischaracterizes an app-wide, intentional demo posture as a high-severity access-control regression in the reviewed engine code. Refutation on four grounds:

1) There is NO authentication anywhere in the system to bypass. pom.xml has no spring-boot-starter-security (only web, jdbc, h2, mysql, snakeyaml, aviator). The only "identity" is the self-asserted, unverified X-Principal HTTP header on the command path (CommandController.java:34-35), defaulting to normal_user (application.yml:23). Any caller can send X-Principal: admin, so CommandEngine.p …

B7. Map.of() with user-derived nullable values throws NPE when building stock-deduct items

  • 严重度:MEDIUM 维度:correctness 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:183
  • 缺陷:Map.of() rejects null keys/values, so an OrderItem whose productId (or explicitly-null quantity) is null causes a NullPointerException while assembling cross-consistency items, aborting execute after the order was already persisted.
  • 证据 for (Map<String, Object> it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); } // Map.of throws NPE if it.get("productId") is null, or if key "quantity" is present with a null value (getOrDefault only substitutes for ABSENT keys)
  • 影响 / 触发场景:In the shipped M8, productId/quantity are required so validate() blocks the null case. But making a line-item field optional is exactly the supported 'edit YAML only' workflow: remove required: true from select_product in M8, then POST an OrderItem like {"quantity":1} (no productId). validate() passes, persist succeeds, then line 183 executes Map.of("productId", null, ...) -> NPE -> transaction rollback / HTTP 500. Also triggers if a client sends {"productId":"P001","quantity":null} once quantity is non-required. The generic engine should not assume these payload fields are non-null.
  • 建议修复:Use a null-tolerant map (e.g. new LinkedHashMap<>() with put/putIfAbsent, or HashMap) instead of Map.of, and skip/handle items with missing productId explicitly.
  • 复现核验:Evidence verified verbatim at CommandEngine.java:183: items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0)));. Both NPE mechanisms are correct Java: Map.of (ImmutableCollections.MapN) calls Objects.requireNonNull on every value, and getOrDefault returns the map's null value when the key is PRESENT-but-null (it only substitutes the default for ABSENT keys). So a null/absent productId, or an explicit "quantity":null, both crash line 183.

Reachability confirmed end-to-end: (1) M8 currently marks both fields required (M8-front-schema.yaml:144-146 productId required:true, 154-156 quantity required:true), and validate() (lines 208-222) rejects blan …

  • 反驳核验:The Map.of NPE pattern at CommandEngine.java:183 is real in the abstract (Map.of rejects null values, and getOrDefault only substitutes for ABSENT keys, not present-but-null), but it is NOT reachable in the delivered system, and the finding itself concedes this.

Guard verified: validate() (lines 205-222) drives off ctx.requiredFields, populated by collectRequired() (lines 337-352) from every M8 childComponent carrying required:true, keyed by domainFieldName + the container's domainEntityName ("OrderItem"). In the shipped M8 (M8-front-schema.yaml:144-146 productId, 153-156 quantity) BOTH fields are required:true bound to OrderItem. So any OrderItem row with a null/blank productId or quantity …

B8. Cross-consistency is a hardcoded inline call decoupled from the outbox and from actually-emitted events

  • 严重度:MEDIUM 维度:eda 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:186
  • 缺陷:StockDeduct is triggered by a literal "OrderCreated" and item fields "productId"/"quantity" pulled straight from the payload in the same transaction, while the outbox poller only flips status NEW->PUBLISHED; the appended events are never consumed to drive side effects, so the 'event-driven eventual consistency' is neither event-driven nor eventual.
  • 证据 CommandEngine L182-186: for (Map<String, Object> it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); } eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); EventEngine.pollAndPublish() L131-134 only logs and does UPDATE ... SET status='PUBLISHED'; no consumer reads payload_json to invoke StockDeduct. The emitted events (cmd.emitEvents, L174) and the cross-consistency trigger are wired independently.
  • 影响 / 触发场景:(a) If M2 emitEvents is renamed away from OrderCreated, the outbox no longer carries OrderCreated yet stock is still deducted (trigger ignores what was actually appended); conversely renaming keeps deduction firing off a stale literal. (b) If the item entity's field is renamed from productId/quantity in the model, items carry null productId -> deductStock returns SKIP_NOT_FOUND and stock is silently never deducted while the order commits. (c) Because deduction runs inline in execute()'s transaction, it is strong-consistent, not the 'eventual' path the outbox implies; the outbox is write-only decoration.
  • 建议修复:Drive cross-consistency from the outbox relay: have pollAndPublish (or a real consumer) read appended events and dispatch crossAggConsistencyRules by matching the persisted event_name/payload, instead of a literal "OrderCreated" and hardcoded item field names.
  • 复现核验:Evidence verified verbatim. CommandEngine.java L180-186 (inside the @Transactional execute() path, step "e"): List<Map<String,Object>> items = new ArrayList<>(); for (Map<String,Object> it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); } List<Map<String,Object>> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); The trigger event is the string literal "OrderCreated" and the item fields "productId"/"quantity" are pulled from the payload by hardcoded key. EventEngine.applyCrossConsistency (L66-82) matches on `rule.get("trig …
  • 反驳核验:The finding is an architecture/design-fidelity critique dressed as an EDA correctness defect; as an "eda" defect it produces no reachable wrong behavior in the system as designed and shipped.

1) The mapping is in fact model-driven, contradicting "neither event-driven." At CommandEngine.java:186 the literal "OrderCreated" is only a lookup key; the actual decision OrderCreated→StockDeduct→targetAggregate is resolved from ME.yaml via EventEngine.applyCrossConsistency (L66-82: for (Object r : models.crossConsistencyRules()) matching rule.get("triggerEvent")), and ModelRepository.crossConsistencyRules() (L287-289) reads ME-event.yaml's crossAggConsistencyRules (ME L58-62). So the consistency …

B9. Control type ignores M1 fieldType, contradicting advertised model-change behavior

  • 严重度:MEDIUM 维度:frontend 位置web/src/engine/FieldControl.tsx:24
  • 缺陷:FieldControl selects the widget solely from M8 compType and never consults the M1 fieldType, so changing a field's domain type does not change its control.
  • 证据 switch (comp.compType) { case 'number_input': ... case 'input': default: return <Input ... />
  • 影响 / 触发场景:App.tsx Help step 3 tells users '把某字段 type 由 string 改为 int → 热重载 → 该组件输入类型随之改变', but fieldType is only used for the small '[int]' tag in FieldLabel (SceneForm.tsx:161); the actual control stays whatever M8 compType says. This documented 'change model → change behavior' scenario silently does not work, undermining the core demo claim.
  • 建议修复:Either derive/override the control from fb.fieldType when compType is generic (int/decimal -> InputNumber), or correct the Help text to state the widget is driven by M8 compType, not M1 type.
  • 复现核验:Verified in the actual files. FieldControl.tsx:24 does switch (comp.compType) and never reads fb.fieldType; the only fieldBind props it uses are placeholder/formLabel/optionsSource/masked. Grep confirms fieldType is referenced in web/src only at types.ts:12 and SceneForm.tsx:161 (the small [{fb.fieldType}] secondary tag). SceneSchemaService.enrich() proves the two are decoupled: line 115 copies M8 compType verbatim while line 128 separately sets fbOut.fieldType from M1, so a change to M1 type cannot alter compType. App.tsx:122 (Help modal titled 验证「改模型→改行为」) explicitly promises step 3: change a field's M1 type string→int → hot reload → 该组件输入类型随之改变. Concrete repro: M8 line 101–106 bin …
  • 反驳核验:The finding files a code defect against FieldControl.tsx:24, but the code there is correct by design, not broken. FieldControl is deliberately a GENERIC widget registry keyed on M8 compType, and SceneSchemaService.enrich() (SceneSchemaService.java:116) copies compType verbatim from M8 — out.put("compType", comp.get("compType")). M8-front-schema.yaml authors compType explicitly per component (input/select/number_input/date_picker). So widget selection is a presentation concern owned by M8, while domain type is owned by M1. That separation is defensible DDD layering, arguably MORE correct than auto-deriving the widget from the domain type (an int field might legitimately want a slide …

B10. M0 元校验根规范从未被引擎执行,且各层实际不符合 M0 的模式约束

  • 严重度:MEDIUM 维度:model-integrity 位置models/M0-meta-schema.yaml:10
  • 缺陷:M0 自称『全域校验根规范』,但 ModelRepository 只把 M0 当作原始层加载展示,从不对任何层做 JSON-Schema 校验;而其它层还大面积违反 M0 自身定义的模式。
  • 证据 M0-meta-schema.yaml:10 Identifier `pattern: "^[a-z][a-zA-Z0-9]*$"`(禁止下划线),但 M8 的 compId(如 24:`card_customer_base`)、templateId(16:`template_customer_single`) 及 M4 permissionBind(order_create) 全是 snake_case;M0:21 SceneId `pattern: "^SCENE_[A-Z0-9_]+$"`,而 M8:278 template3 `bindSceneId: ""` 为空串不匹配。ModelRepository.java:50-64 load() 仅 `new Yaml().load(in)` 存入 layers,全仓无任何按 rootAllOntologySchema 的校验调用。
  • 影响 / 触发场景:M0 承诺的『统一前后端数据/组件/页面描述标准』只是装饰性文档:既不会在加载/热重载时拦截不合规模型,其它层与 M0 的 pattern 也系统性冲突。任何『先改 M0 收紧约束』的操作对运行时行为零影响,给出错误的合规安全感。
  • 建议修复:在 ModelRepository.load()/reload() 中用 M0 的 definitions 对各层做 JSON-Schema 校验并在不合规时报错;同时统一 Identifier/SceneId 模式与实际命名(snake_case 或改 M0 pattern)。
  • 复现核验:All quoted evidence verified against the actual files. M0-meta-schema.yaml:10 defines Identifier: pattern: "^[a-z][a-zA-Z0-9]*$" (no underscores) and M0:21 defines SceneId: pattern: "^SCENE_[A-Z0-9_]+$". These patterns are bound via $ref to real fields: compId→Identifier (M0:298), templateId→Identifier (M0:310), permissionBind items→Identifier (M0:237), bindSceneId→SceneId (M0:314, required at M0:308). M0 also declares rootAllOntologySchema (M0:372-384) and its header calls itself the "全域校验根规范". Sibling models systematically violate these: M8:16 templateId template_customer_single, M8:24 compId card_customer_base, M4:13 permissionBind order_create all use snake_case (illegal unde …
  • 反驳核验:The finding's facts are accurate but it does not describe a reachable functional defect nor an iron-rule violation; it is an aspirational-documentation / cosmetic-consistency observation.

Confirmed facts: ModelRepository.load() (lines 50-64) only calls new Yaml(new LoaderOptions()).load(in) and stores raw objects; grep shows no JSON-Schema library and no reference to rootAllOntologySchema anywhere in engine/ or web/. The only 3 code hits for "M0" are two doc comments and LAYER_FILES.put("M0","M0-meta-schema.yaml"), which loads it purely as a display layer (rawLayer). compIds/templateIds are snake_case (M8 lines 16,24,34...), bindSceneId: "" at M8:278, and `permissionBind:[order_crea …

B11. H2 web console enabled without authentication in the default profile

  • 严重度:MEDIUM 维度:security 位置engine/src/main/resources/application.yml:13
  • 缺陷:The default profile (used by start.sh and mvn spring-boot:run) exposes the H2 console at /h2-console with the sa account and an empty password.
  • 证据 username: sa password: "" h2: console: enabled: true path: /h2-console
  • 影响 / 触发场景:Anyone who can reach the engine port can browse to /h2-console and connect with jdbc:h2:mem:trade_db, user sa, empty password — full read/write/arbitrary-SQL on the running database (and H2 console SQL can reach the filesystem/RCE in some configs). There is no auth in front of it. This is the day-to-day run mode per CLAUDE.md.
  • 建议修复:Disable the H2 console by default (only enable behind auth on an explicitly local bind), and never ship an empty DB password on a network-reachable service.
  • 复现核验:Evidence verified at engine/src/main/resources/application.yml:11-16: username: sa, password: "", and h2.console.enabled: true / path: /h2-console. No Spring Security exists (grep for SecurityFilterChain/spring-boot-starter-security/@EnableWebSecurity/httpBasic returns nothing; pom.xml has only starter-web/starter-jdbc/h2), and WebConfig.java only adds permissive CORS — so nothing guards /h2-console. Concrete reproduction: ./start.sh or mvn spring-boot:run uses the default profile; browsing to http://localhost:8080/h2-console and connecting with jdbc:h2:mem:trade_db, user sa, empty password yields full SQL access to the live in-memory DB (same JVM, shared named mem DB), and H2 co …
  • 反驳核验:The config line is factually present (application.yml:11-16: username sa, password "", h2.console.enabled: true, path /h2-console), but it is not a reachable security defect. (1) Production disables it and the deployment forces the prod profile: deploy/Dockerfile.engine:15 hardcodes ENTRYPOINT java -jar app.jar --spring.profiles.active=prod, and application-prod.yml:9-11 sets h2.console.enabled: false (and switches to MySQL). deploy/README.md:16 confirms the engine runs with the prod profile. So no deployed topology exposes the console. (2) The enabled-console profile is only reachable via a local ./start.sh / mvn spring-boot:run run against jdbc:h2:mem:trade_db — an in-memory, ephemeral DB …

B12. CORS allows any origin on all /api/** endpoints in every profile

  • 严重度:MEDIUM 维度:security 位置engine/src/main/java/com/onto/engine/config/WebConfig.java:12
  • 缺陷:The CORS policy allows all origins, methods and headers for /api/**, and this config is not profile-scoped, so it applies to prod as well as dev.
  • 证据 registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*");
  • 影响 / 触发场景:Combined with the lack of authentication and the header-based principal, any malicious website loaded in a victim's browser can issue cross-origin GET/POST to the engine (including /execute with X-Principal: backend_admin, since allowedHeaders('*') permits the custom header) and read all /api/data responses. There is no origin allow-list even for production.
  • 建议修复:Restrict allowedOrigins to the known front-end origin(s) per environment (profile-scoped config), and only allow the headers/methods actually required.
  • 复现核验:Evidence verified verbatim at engine/src/main/java/com/onto/engine/config/WebConfig.java:12-15: addMapping("/api/*").allowedOriginPatterns("").allowedMethods("GET","POST","PUT","DELETE","OPTIONS").allowedHeaders("*"). WebConfig is an unconditional @Configuration (no @Profile), so the wide-open CORS applies to every profile including prod — application-prod.yml exists and points at a real MySQL, confirming a production deployment path.

The impact is reachable because there is NO authentication in the app: grep shows no spring-security dependency in pom.xml, no SecurityFilterChain/@EnableWebSecurity anywhere. The only principal mechanism is the attacker-controllable X-Principal request head …

  • 反驳核验:Read WebConfig.java:12-15 and grepped all of engine/ for allowCredentials/SecurityFilterChain/cookies — none exist. The refutation stands on four points: (1) The CORS mapping uses allowedOriginPatterns("*") WITHOUT allowCredentials(true). Wildcard CORS is only a recognized vulnerability when paired with credentials; without it, the browser attaches no cookies/ambient auth and no credentialed data can be exfiltrated. (2) The engine has NO authentication at all (no Spring Security, no session, no cookies — confirmed by grep). The "principal" is a purely client-supplied X-Principal header (CommandController.java:34, default normal_user at application.yml:23). CORS exists to protect ambient auth …

B13. Model viewer /api/meta/model/{code} exposes the full security model unauthenticated

  • 严重度:MEDIUM 维度:security 位置engine/src/main/java/com/onto/engine/web/MetaController.java:66
  • 缺陷:Any caller can retrieve the raw parsed content of any model layer, including M5 with its principals, functionPermissions and mask/encrypt rules.
  • 证据 @GetMapping("/model/{code}") public Map<String, Object> model(@PathVariable String code) { ... out.put("content", models.rawLayer(code)); // returns raw M5-security.yaml incl. allowPrincipals: [normal_user, backend_admin]
  • 影响 / 触发场景:GET /api/meta/model/M5 discloses the exact principal strings and which permissions map to which scenes. Because the principal is a spoofable header (see other finding), this hands an attacker the precise value (backend_admin) needed to escalate, plus the full mask/encrypt configuration. All of it is unauthenticated.
  • 建议修复:Gate the model-viewer and metadata endpoints behind authentication/authorization, and avoid returning the security layer (principals/permissions) to unprivileged callers.
  • 复现核验:Evidence verified. MetaController.java:66-72 returns models.rawLayer(code) with no auth; ModelRepository.java:94 confirms rawLayer just returns layers.get(code) (full parsed content, unfiltered). M5-security.yaml contains the exact sensitive data claimed: principals [normal_user, backend_admin], functionPermissions with allowPrincipals mapped to scenes, maskRules, and encryptRules. There is no authentication layer: engine/pom.xml has no spring-boot-starter-security, WebConfig only adds a wildcard CORS mapping, and no filter/interceptor exists. Reproducible manifestation: curl http://localhost:8080/api/meta/model/M5 returns the full raw M5 security model to any anonymous caller. So the endp …
  • 反驳核验:The endpoint exists and does return raw M5 content unauthenticated, but the finding's substantive claims (medium-severity info disclosure that enables privilege escalation) are refuted by the actual code.

1) It is an intentional core feature, not a leak. MetaController.java:66-73 /api/meta/model/{code} powers the frontend "模型查看器" (Model Viewer) tab — web/src/panels.tsx:110 comment: "直接查看任意本体层解析后的内容,证明「页面即模型」" and App.tsx:125 "「模型查看器」标签页可实时查看引擎当前加载的各层模型内容". The whole design contract (CLAUDE.md) is that the models are the openly-displayed SINGLE SOURCE OF TRUTH. Displaying every layer is the endpoint's designed purpose.

2) No secrets are disclosed. M5-security.yaml (read in full) contains …

B14. Hardcoded default database credentials in prod profile and compose file

  • 严重度:MEDIUM 维度:security 位置engine/src/main/resources/application-prod.yml:8
  • 缺陷:The production profile falls back to a hardcoded DB password, and docker-compose commits weak MySQL root/user passwords.
  • 证据 password: ${DB_PASS:trade123} // application-prod.yml L8; deploy/docker-compose.yml L8 MYSQL_ROOT_PASSWORD: root123, L11 MYSQL_PASSWORD: trade123
  • 影响 / 触发场景:If DB_PASS is not overridden, prod connects with the well-known committed password 'trade123'; the compose stack ships root123/trade123. Anyone with the repo (or scanning the deployment) knows the DB credentials, enabling direct database access on the exposed 3306 port.
  • 建议修复:Remove hardcoded fallbacks; require DB_PASS/root password to be injected via secrets with no default, and do not commit real credentials to the compose file.
  • 复现核验:Evidence verified exactly as quoted. engine/src/main/resources/application-prod.yml:8 reads password: ${DB_PASS:trade123}, so the production Spring profile silently falls back to the committed literal trade123 when DB_PASS is unset (CWE-798/CWE-521 silent weak-credential fallback rather than a fail-fast). deploy/docker-compose.yml — whose own header comment calls it the production topology mapping ("生产拓扑映射...MySQL/RocketMQ 为生产映射") — commits MYSQL_ROOT_PASSWORD: root123 (L8), MYSQL_USER: trade/MYSQL_PASSWORD: trade123 (L10-11), passes DB_PASS: trade123 to the engine (L39), and binds MySQL to the host via ports: ["3306:3306"] (L12). Concrete manifestation: deploying via the docum …
  • 反驳核验:Read application-prod.yml, docker-compose.yml, and application.yml. The finding does not survive refutation as a genuine reachable defect. (1) application-prod.yml:8 password: ${DB_PASS:trade123} is the canonical Spring env-var-with-fallback idiom; the file header (lines 1-2) explicitly documents 环境变量可覆盖...DB_PASS and that the prod profile is opt-in via --spring.profiles.active=prod. trade123 is a documented, overridable placeholder, not an accidentally leaked secret. (2) In docker-compose.yml the MySQL container is PROVISIONED with trade123 (L11) and the engine connects with the same trade123 (L39) — a self-contained stack must state its DB password somewhere; this is the standa …

B15. toBig() swallows parse errors and returns ZERO, letting non-numeric dueDays bypass the >=0 check

  • 严重度:LOW 维度:correctness 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:419
  • 缺陷:toBig() catches every exception and returns BigDecimal.ZERO, so a non-numeric dueDays like "abc" yields signum()==0 and silently passes the 'dueDays 不可为负' validation instead of being rejected.
  • 证据 private static BigDecimal toBig(Object o) { try { return new BigDecimal(String.valueOf(o)); } catch (Exception e) { return BigDecimal.ZERO; } } // used at line 235: if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负");
  • 影响 / 触发场景:An invalid dueDays value (e.g. "abc", "3d", "1,0") is treated as 0 and accepted by the validator, then crashes downstream at DynamicRepository.convert("int", "abc"). The swallowed exception hides malformed input and produces a wrong validation verdict rather than a clear rejection.
  • 建议修复:Treat unparseable numeric input as a validation error (return an errors entry) instead of silently mapping it to ZERO.
  • 复现核验:Evidence verified. CommandEngine.java:418-420 toBig() catches every exception and returns BigDecimal.ZERO. It is used at line 235 (if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负")), inside the branch guarded by line 232 cmdValidations.contains("dueDays"), which is satisfied because M2-command.yaml:123 contains - dueDays >= 0, depositRatio ∈ [0,1]. dueDays is declared int in M1-domain.yaml:93-94 and mapped to due_days in M3-deploy.yaml:77.

Concrete manifestation: submit SCENE_CREATE_ORDER with details.PaymentTerm[0].dueDays = "abc". In validate(), toBig("abc") swallows the NumberFormatException → returns ZERO → signum()==0 is not < 0 → no error added → validati …

  • 反驳核验:The mechanism is literally true (toBig("abc") returns BigDecimal.ZERO so signum()==0 skips the "dueDays 不可为负" error at CommandEngine.java:235), but the claimed correctness impact is neutralized on every path.

(1) The line-232 check is a narrow NEGATIVITY gate derived from M2 text "dueDays >= 0" (models/M2-command.yaml:123); its sole job is to reject negatives. "abc" is genuinely not a negative number, so not adding a negativity error is semantically consistent. This engine has no per-field numeric-type validator for any field — type enforcement is delegated to the persist layer by design.

(2) No data ever gets accepted. dueDays is M1 type int (models/M1-domain.yaml:93-94) persisted to due_ …

B16. deductStock() unguarded Long.parseLong crashes on NULL stock column (asymmetric with productStock)

  • 严重度:LOW 维度:correctness 位置engine/src/main/java/com/onto/engine/event/EventEngine.java:105
  • 缺陷:deductStock only checks row.isEmpty() before Long.parseLong(String.valueOf(row.get(stockCol))); if the stock column is NULL the value becomes the literal "null" and parseLong throws NumberFormatException inside the order transaction.
  • 证据 Map<String, Object> row = repo.findByPk(table, pkCol, productId); if (row.isEmpty()) { action.put("status", "SKIP_NOT_FOUND"); return action; } long current = Long.parseLong(String.valueOf(row.get(stockCol))); // row.get(stockCol)==null -> "null" -> NumberFormatException
  • 影响 / 触发场景:CommandEngine.productStock() guards this exact case (returns null when row.get(stockCol)==null, so precheck's runRules silently skips the low-stock ALERT), but deductStock() does not, so precheck and execute diverge: for a product row with a NULL stock value, precheck passes cleanly while OrderCreated -> StockDeduct crashes with NumberFormatException, rolling back a valid order. Same crash if stockNum is later modeled as decimal in M1 (H2 returns BigDecimal -> String.valueOf("100.0000") -> parseLong fails). Trigger: any Product whose stock_num is NULL (e.g. created without stockNum, or column added post-hoc) referenced by an OrderItem.
  • 建议修复:Null-check row.get(stockCol) (mirror productStock's guard) and parse via BigDecimal(...).longValue() to tolerate decimal storage; treat missing/NULL stock as 0 or SKIP rather than throwing.
  • 复现核验:Evidence confirmed verbatim. EventEngine.java:101 guards only if (row.isEmpty()), then line 105 does long current = Long.parseLong(String.valueOf(row.get(stockCol)));. When the stock column is NULL, String.valueOf((Object)null) yields the literal "null" and Long.parseLong("null") throws NumberFormatException. The asymmetry is real: CommandEngine.java:292 productStock() guards if (row.isEmpty() || row.get(stockCol) == null) return null; whereas deductStock does not. There is even an internal inconsistency inside deductStock: line 92 strips decimals for qty via .replaceAll("\\..*$","") but line 105 does not for stock.

Concrete reproduction (NULL path): DynamicRepository.insertDo …

  • 反驳核验:The syntactic asymmetry is real: EventEngine.deductStock() (engine/.../event/EventEngine.java:105) does long current = Long.parseLong(String.valueOf(row.get(stockCol))); guarding only row.isEmpty() (line 101), whereas CommandEngine.productStock() (engine/.../command/CommandEngine.java:292-293) additionally guards row.get(stockCol) == null. But the claimed runtime crash requires a Product row whose stock_num is NULL, and that state is unreachable in the current models. (1) No product-creation path exists: M4-scene.yaml defines only SCENE_CREATE_ORDER/SCENE_MODIFY_ORDER_ADDR/SCENE_CANCEL_ORDER (none create a product), and M8 has only customer/order/blank templates — no product template. …

B17. Required fields are visual-only; no client-side validation

  • 严重度:LOW 维度:frontend 位置web/src/engine/SceneForm.tsx:160
  • 缺陷:M8 'required' renders a red asterisk but the Form.Items have no name/rules, so submit and precheck never enforce required fields on the client.
  • 证据 {fb.required && <Text type="danger"> *</Text>}
  • 影响 / 触发场景:The is decorative: fields are tracked in manual master/singles/tables state with no antd Form binding, so doSubmit/doPrecheck fire even with empty required fields and rely entirely on the backend to reject. The asterisk implies client validation that does not exist (poorer UX, extra round-trips).
  • 建议修复:Before doPrecheck/doSubmit, validate required fields from schema against collected state and surface inline errors, or wire fields through antd Form name/rules.
  • 复现核验:Verified in web/src/engine/SceneForm.tsx. Line 160 renders the required asterisk ({fb.required && <Text type="danger"> *</Text>}) inside FieldLabel. Every Form.Item (lines 176, 197, and table columns 224-231) is declared as <Form.Item label={...}> with no name and no rules, so antd's Form store never tracks these fields and can perform no validation. Line 135's <Form layout="vertical"> has no form instance and no onFinish; submission is a plain Button onClick={doSubmit} (line 147). Field values are held in manual useState (master/singles/tables, lines 38-40) via FieldControl value/onChange, bypassing antd Form entirely. Neither doSubmit (90-107) nor doPrecheck (79-88) contains …
  • 反驳核验:The code claims are literally accurate: SceneForm.tsx:160 renders {fb.required && <Text type="danger"> *</Text>}, and the Form.Items at lines 176/197/232 have no name/rules, so antd validation is never engaged; doSubmit (90) and doPrecheck (79) build the payload from manual master/singles/tables state (38-40) and POST without a client-side required check. However this is not a defect. (1) The project's IRON RULE makes models/*.yaml the single source of truth and the backend (MetaRule + M2 + M3) the authoritative generic validator; duplicating required-field logic in React would create rule-drift, precisely what the architecture avoids — delegation is intentional design. (2) A pre-submit va …

B18. eventPayload 仅从 master 取值,ME 中落在子实体/非领域字段的 payload 解析为 null

  • 严重度:LOW 维度:model-integrity 位置engine/src/main/java/com/onto/engine/command/CommandEngine.java:380
  • 缺陷:eventPayload 对每个 ME payload 字段只从 master 读取,凡是 payload 引用子实体字段(receiverPhone)或根本不是领域字段(productIdList)的事件,其 payload 值都会是 null。
  • 证据 CommandEngine.java:378-380 遍历 def.payload,`payload.put(field, field.equals(rootIdField) ? rootId : master.get(field))`;ME-event.yaml:51 OrderDeliveryAddressModified `payload: [orderId, receiverPhone]`(receiverPhone 属 DeliveryAddressEntity 子实体,不在 master);55 OrderCancelled `payload: [orderId, productIdList]`(productIdList 非任何 M1 字段)。
  • 影响 / 触发场景:当前 CreateOrder 的 OrderCreated payload[orderId,customerId,totalAmount] 恰好都在 master 才侥幸正确;一旦启用 ModifyOrderDeliveryAddress/CancelOrder 等命令,其事件 payload 的子实体/聚合字段会全部为 null,事件内容失真。属 latent break。
  • 建议修复:eventPayload 应能从 master 与 details 子实体、乃至聚合上下文中按字段名解析 payload 值,而非仅 master.get(field)。
  • 复现核验:Evidence verified at the cited lines. CommandEngine.java:378-380 builds event payloads with payload.put(field, field.equals(rootIdField) ? rootId : master.get(field)), so every payload field except the root id is resolved solely from master. ME-event.yaml:51 declares OrderDeliveryAddressModified payload [orderId, receiverPhone] and ME-event.yaml:55 declares OrderCancelled payload [orderId, productIdList].

I constructed a concrete, reachable manifestation. SCENE_CANCEL_ORDER is a live scene (M4-scene.yaml:32-40) exposed via POST /api/scene/{sceneId}/execute (CommandController.java:31-37). Permission order_cancel allows normal_user/backend_admin (M5-security.yaml:21-23), and default princ …

  • 反驳核验:Mechanically the observation is accurate: CommandEngine.java:378-380 resolves each ME payload field via field.equals(rootIdField) ? rootId : master.get(field), so a child-entity or non-domain field yields null. But this is not a reachable defect in the system.

(1) Not wired: the events OrderDeliveryAddressModified / OrderCancelled are only emitted by commands ModifyOrderDeliveryAddress / CancelOrder, reachable only via scenes SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER. M8-front-schema.yaml binds templates ONLY to SCENE_CREATE_CUSTOMER (line 20) and SCENE_CREATE_ORDER (line 76); templateByScene (ModelRepository.java:262-268) returns emptyMap otherwise. The generic renderer refuses these: …


附录 C · 被驳回(5 条)

本组候选未通过复现验证,主要为把"文档态/演示简化"的既定设计误报为缺陷(如 M6/M7 层未接线属设计意图、动态 SQL 标识符来自可信 YAML 不可被用户注入等),或严重度被下调至可忽略。详见运行日志 journal.jsonl。