blocks before parsing in LlmJsonClient.
+
+合并自 4 条独立发现:agent-core/guards-mutually-exclusive, agent-core/intent-gate-failure-drops-antifab-guard, state-memory/fabricated-answer-stays-in-memory, agent-core/llmjson-no-tolerant-parse
+
+**33. The tool-driven proposal path stores the whole proposal sentence in the state slot's record field and echoes ERP text back into the intent gate**
+`src/main/java/com/xly/web/AgentChatController.java:548` · state-memory
+
+state.setActiveDoc(convId, "", summary, opId, "proposed") puts a full sentence where StateService documents a record name/单号, and leaves entity blank — unlike the two deterministic call sites at lines 194 and 451 which pass a real record name. StateService.digest then renders 在办单据=【将【宏达纸品】的【电话】由「1」改为「2」】 and prefixes that to every later classify/extractWrite call, whose schema requires a record — breaking the very cross-turn reference the slot exists to resolve. The same string carries ERP-controlled text (customer names, old field values) verbatim into the intent gate's input with no escaping or length cap, a prompt-injection surface the gate did not otherwise have.
+
+*修复:* Have ProposeWriteTool return the located entity type and record name in its JSON and pass those into setActiveDoc, keeping the summary in the ledger only; strip control characters and cap the length of any ERP-sourced text that reaches a prompt.
+
+合并自 4 条独立发现:state-memory/activedoc-record-set-to-summary, agent-core/tool-path-state-record-is-summary, agent-core/erp-text-into-classifier-input, state-memory/entities-cap-not-enforced
+
+**34. Documentation asserts security controls and an architecture that no longer exist**
+`docs/agent-architecture.md:7` · docs-drift
+
+The reading guide sends readers to §17/§18 as current state, but §17 lists 9 @Tool methods including QueryTool.queryData, KgQueryTool.kgSearch and SkillTool.loadSkill and a ToolScope narrowing mechanism — none of which exist (there are exactly 6 @Tool methods and AgentFactory.build takes no scope); it also claims the work is unmerged on kg-edge-flow. Worse, docs/security-findings.md:39/42 says findings 3-5 are mitigated by 「新 queryData 已按此实现」 — a SQL sandbox that was deleted, so a reviewer signing off the security report concludes ad-hoc SQL is safely sandboxed when neither the tool nor any of its listed controls (SELECT-only, forced LIMIT, tenant AST injection) exists. FormController:19 and §14 similarly claim tenant filtering that rank 4 disproves. legacy-tool-inventory.md still says the 64 legacy tools load at startup.
+
+*修复:* Point the reading guide at §19, mark §17/§18 superseded, rewrite security-findings 3-5 as 'closed by removal of the NL2SQL stack' (or as requirements for any future SQL tool), and correct the tenant-filtering claims to match the code.
+
+合并自 4 条独立发现:docs-drift/arch-reading-guide-points-at-obsolete-section, docs-drift/secfindings-queryData-gone, docs-drift/legacy-inventory-stale-progress, docs-drift/kg-views-orphaned
+
+**35. Dead no-token ErpClient overloads, an undefined logback appender, and inert config keys**
+`src/main/java/com/xly/service/ErpClient.java:109` · docs-drift
+
+Four ErpClient overloads (readForm:109, updateForm:161, deleteForm:175, newUuid:313) and two IntentService overloads have zero callers; the ErpClient ones are the shorter, more obvious signatures and resolve the token to null, i.e. they are foot-guns that silently execute as dev-admin the moment someone calls them. logback-spring.xml:17-19 declares with no DB appender defined, so Joran prints a configuration ERROR and dumps its status report on every start and every 60s rescan, masking real problems during deploy triage; it also configures loggers for org.mybatis and ai.djl, both removed from the POM. application.yml:47's block-when-exhausted binds to nothing. KgQueryTool:27 advertises '最多返回 15 条' while the SQL is LIMIT 12, with no truncation marker.
+
+*修复:* Delete the six dead overloads, lines 17-19 of logback-spring.xml plus the mybatis/djl loggers and the unused console appender, and the orphan Redis key; derive the findForms limit and its tool description from one constant and emit a truncation flag.
+
+合并自 5 条独立发现:docs-drift/erpclient-dead-overloads, docs-drift/logback-retired-stack-leftovers, docs-drift/orphan-redis-config-key, docs-drift/findforms-limit-mismatch, config-ops/logback-dead-root-block
+
+
+## 本次审计未覆盖 / 无法定论
+
+- No runtime or network access, so several impact ceilings are unproven: whether the target ERP actually accepts admin/666666 for brand 1111111111 (bounds rank 1's ERP-read half, though the JDBC-backed endpoints need no ERP login), whether nginx-saas in front of /xlyAi enforces an ERP session (this single unknown moves every unauthenticated finding between 'internet-exposed' and 'internal-only'), and whether port 3318 accepts arbitrary internet connections (rank 3).
+
+- xlyEntry's own handlers were never read — they are out of repo. Unsettled: whether getBusinessDataByFormcustomId binds query parameters into stored-procedure IN params (this decides whether the URL injection at rank 8 escalates from 'reach any ERP endpoint' to 'bUpdate=1 write via the read tool'); whether /ai/execStaging marks the row before doing work (decides the exec-staging replay); whether addUpdateDelBusinessData rejects unknown columns, re-mints sBillNo, or applies business-required validation (decides ranks 13, 19 and the required-flag outcome); and whether quoquotationmaster has a unique index on (sBrandsId, sBillNo).
+
+- No database access, so three findings could not be settled and are excluded or downgraded: (a) whether gdsmodule/gdsconfigformmaster carry sBrandsId — SHOW COLUMNS FROM gdsmodule LIKE 'sBrandsId' would decide whether the KG catalog views leak other tenants' form titles and table names via findForms (the reported kg-views-no-brand-scope finding is dropped as unproven); (b) whether any table has two distinct columns sharing a shortest Chinese label, which is what makes the label-keyed /form/submit protocol lossy — real in mechanism but with no verified instance, so it is not in the ranked list; (c) whether every sFkTable actually has sBrandsId and a resolvable name column, which decides how often the silent-empty FK picker fires.
+
+- No LLM access, so all model-behaviour-dependent claims are stated as mechanisms rather than outcomes: gate accuracy (how often a write lands on the 查询 branch), whether Ollama's /v1 shim tolerates an orphan assistant tool_calls message, whether the model self-answers its own askUser call (askUser uses the default ReturnBehavior.TO_LLM, and the obvious IMMEDIATE fix is illegal on a TokenStream-returning service), and whether the intent extractor copies a whole sentence out of the corrupted state slot. Related: LlmJsonClient's strict parse is only a live outage if the configured endpoint returns fenced or thinking-prefixed content or 400s on reasoning_effort — one POST against 112.82.245.194:41434 would settle it.
+
+- Files/flows nobody traced end to end: AuditService and ai_audit_log were only read as writers — retention, PII content and whether the audit table is itself queryable cross-tenant were not examined; TracingChatModelListener's Langfuse export was checked only for being gated and non-blocking, not for WHAT it exports (if it ships prompts containing ERP row data to a third party once keys are configured, that is an undiscovered data-egress path); SlotFillService's role tagging was read only at its FormCollectTool boundary, so mis-roled prefills were not traced from the gate output; and the build/deploy surface (the packaged WAR under target/, the nginx-saas config, the xlyEntry deploy scripts) is outside the repo and unaudited.
+
+- git history was checked only for application.yml. Other credentials, tokens or IPs committed and later removed in the two-commit history were not swept, and no secret-scanning tool was run.
+
+- There are no tests of any kind in this repo, so none of the 35 entries above is regression-guarded. Every merged fix — particularly the confirm compare-and-set, the coerce/summary alignment, and bringing doUpdate to parity with doCreate on FK/coerce/system-column handling — will need its own verification path built from scratch; there is no existing harness to extend.
diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java
index 2714677..84aa5fb 100644
--- a/src/main/java/com/xly/service/FormResolverService.java
+++ b/src/main/java/com/xly/service/FormResolverService.java
@@ -361,17 +361,29 @@ public class FormResolverService {
return out;
}
- /** 外键名称 -> id:在外键表按名称字段模糊匹配(元数据来源可信,非用户拼 SQL)。 */
- public String resolveFk(String fkTable, String name) {
- if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank()) {
+ /**
+ * 外键名称 -> id:在外键表按名称字段模糊匹配(元数据来源可信,非用户拼 SQL)。
+ *
+ * 必须按租户过滤:没有 sBrandsId 谓词时,新增/修改会把**别家租户**的客户/产品 id 绑进本租户单据,
+ * 而且能被当成跨租户的存在性探针。拿不到租户就直接返回 null(写入路径随即报「不是系统里已有的…」)。
+ */
+ public String resolveFk(String fkTable, String brand, String name) {
+ if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank()
+ || brand == null || brand.isBlank()) {
return null;
}
String nameField = resolveNameField(fkTable);
if (nameField == null) {
return null;
}
- return queryOne("SELECT sId FROM `" + fkTable + "` WHERE `" + nameField + "` LIKE ? " +
- "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", "%" + name.trim() + "%");
+ boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId");
+ if (!tenantScoped) {
+ // 无租户列 = 全局基础表(如字典类),按名称直接解析
+ return queryOne("SELECT sId FROM `" + fkTable + "` WHERE `" + nameField + "` LIKE ? " +
+ "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", "%" + name.trim() + "%");
+ }
+ return queryOne("SELECT sId FROM `" + fkTable + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ? " +
+ "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", brand, "%" + name.trim() + "%");
}
/**
diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java
index 4f2b2cd..6700e4e 100644
--- a/src/main/java/com/xly/tool/ProposeWriteTool.java
+++ b/src/main/java/com/xly/tool/ProposeWriteTool.java
@@ -130,7 +130,7 @@ public class ProposeWriteTool {
Object stored;
String shown = newValue;
if (fkTable != null && !fkTable.isBlank() && !"null".equalsIgnoreCase(fkTable)) {
- String id = resolver.resolveFk(fkTable, newValue);
+ String id = resolver.resolveFk(fkTable, identity.brandsId(), newValue);
if (id == null) {
String ent = fieldChinese.replace("名称", "");
return err("「" + newValue + "」不是系统里已有的" + ent + ",请从已有记录里选一个。");
@@ -238,7 +238,7 @@ public class ProposeWriteTool {
}
String fk = str(fm.get("fk"));
if (fk != null && !fk.isBlank()) {
- String id = resolver.resolveFk(fk, v); // 外键:名称 -> id
+ String id = resolver.resolveFk(fk, identity.brandsId(), v); // 外键:名称 -> id
if (id == null) {
String ent = zh.replace("名称", "");
return err("「" + v + "」不是系统里已有的" + ent + "。请让用户从下拉里选真实存在的"
@@ -382,7 +382,7 @@ public class ProposeWriteTool {
}
Object value;
if (fk != null && !fk.isBlank()) {
- String id = resolver.resolveFk(fk, v);
+ String id = resolver.resolveFk(fk, identity.brandsId(), v);
if (id == null) {
String ent = zh.replace("名称", "");
return err("「" + v + "」不是系统里已有的" + ent + "。请用 collectForm 让用户从下拉里选真实存在的"