diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 55046827..af4cf6c3 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -15,6 +15,7 @@ **测试** 3,496 → **3,499**(+3):`test/issue-692-default-on-reads.test.js` 三条——裸配置下 guarded 类型(`constraint`)的 merge 被 skip 且理由点名 `dreamMergeGuard`、显式 `dreamMergeGuard: false` 时同一条 merge 落库、装配判据三态(`{}` / `undefined` / `true` 为开,`false` 为关)。变异自证:两处各自改回 `=== true` → 2 红(默认开那两条),还原 → 3/3 绿;「改成恒真」会被那两条反向对照抓住。 - **知识库未启用时的空态文案指错了分组名(#668 尾巴)**。空态一直写「设置 → 记忆(mneme) → 功能 → 知识库」,但 v0.13 起 `group.ext` 是独立分组、标签是「扩展功能」(英文 `Extensions`),「知识库」只是组里的一项——照这句话去设置页找「知识库」分组会找不到。中英两版都改成「功能 → 扩展功能」并点名组内那一项,`test/client.test.js` 的文案锁同步跟上。 +- **无会话身份的系统写入整片没进写入准入:三类出口在 `if (!sessionKey) return verdict` 处直接把判据跳过了(#663)**。`writeAdmission` 的判据挂在 `service.saveWithDedupe` 的汇流口上,而 `evaluate` 开头有一句「无会话身份 = 系统写入,不进预算」就 `return`——预算确实不该进,**判据却连跑都没跑**。于是 autoSummarize 的蒸馏产出、dream 的总览落盘、`bootstrapFromDirectory` 的整目录导入这三条路,密钥/PII 与空白噪声一次都没判过;`writeAdmission.enforce` 打开时它们照样无条件入库。更麻烦的是三条调用点各自把返回值丢了:`dream.js` 写总览的地方根本不看 `saveWithDedupe` 的返回,`summaryStored = true` 无条件置位(拒绝也被当成「已写入」,直接反映到运行结果与常驻条);`bootstrap.js` 解构出 `memory` 就往 `items.push({ id: memory.id })` 走,被拒时 `memory` 是 `null`,解引用抛错掉进外层逐条 catch,**拒绝理由与被拒条数一起丢**,只剩一条 warn。修法是把「会话时间线」和「判据」拆开:第 1 级判据(空白/噪声、密钥/PII)与会话身份无关,对所有写入照跑;命中且 `enforce` 时 `gate` 记 `null`(没有会话就没有预算维度)、拒绝照样生效;判据之后才 `if (!sessionKey) return judged;` 挡在预算/同话题冷却/内容哈希之前——这三样都要一条真实会话时间线做基准,硬凑一个假 `session_key` 会把真实会话的分布污染成两截,读审计的人再也分不开。`record` 的落行条件同步改成「放行档必须有会话且有 gate,拒绝档一律落行」:系统写入的放行不该留计量行(读不出分布,只撑大审计表),但**拒绝是一次真实的丢弃,必须留行**,`session_key` 记为 NULL。调用点各自补上拒绝处理:dream 只在非 `denied` 时置 `summaryStored = true`;bootstrap 解构出 `reason`、`denied` 计数与 `items.push({ action, id: null, title, reason })`,返回对象多一个 `denied` 字段——不再让 `null` 掉进 catch 把理由吃掉;standalone HTTP 那条路径的注释从「拦不到」改成「只过第 1 级」,显式 422 分支保留(落到 `toApiList([null])` 会变 500,把「被拒」伪装成「服务器出错」)。`sensitiveScanEnabled` 的文档口径一起校正:它覆盖所有写入的第 1 级,无会话身份的路径同样在内。**两处刻意没接**:`share-import.js` 走 `store.save` 而不走汇流口(`docs/SHARE.md` 把收包侧定成信任边界,要接得先改那条路,不在本件范围);`saveWithDedupe` 的 `existing` 合并分支在准入之前就 `return`,合并写入整条绕过闸门——这是另一条缺口,同样单独处理。**测试** 3,496 → **3,502**(+6):`test/write-admission.test.js` 覆盖三档系统写入(默认档不判不落行、观察档落行且 `session_key` 为 NULL / `metadata.gate === null`、拦截档 `denied` 且零行),以及 bootstrap 与 dream 两条真实出口的集成(被拒条数、`id: null`、无 summary 行、审计行带命中类型)。变异自证:把 `if (!sessionKey) return judged;` 挪回判据之前 → 4 红;`record` 恢复旧 guard → 拒绝档落行断言红;dream 改回无条件 `summaryStored = true` → 集成用例红;bootstrap 不接 `denied` 分支 → `report.denied` 与 `items[0].id` 断言红。 - **跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688)**。两条 issue 报告的是同一个根病:**记忆的「类型」被当成了「对象身份」**。#687 上报者(xueyue33)给的分布很刺眼——他自己库里 `sim>=0.85` 的 81 对近重复中 **72 对跨类型**、`>=0.90` 的 7 对中 6 对跨类型;`sim>=0.88` 的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:`src/lang.js` 的 `codingSummary` 明确让模型「宁可拆成多条」而 `pitfall`(现象+根因+规避)与 `constraint`(约束+来源)本就是同一结论的两种写法;`src/service.js:1480` 的 `findSessionDuplicate` 用 `store.list({ type: memory.type, source, limit: 200 })` 锁死同类型同会话;`src/dream/clustering.js:117` 的 `findPotentialConflicts` 首行 `if (memories[i].type !== memories[j].type) continue;`;dream 巩固提示词同样写着「仅合并同类型」。**我们不开常开类型闸**:本机活跃库 9,275 条带向量的记忆里跨类型 `cosine>=0.90` 共 45 对,逐对读正文后 **36 对(80%)确为同义,9 对合并会丢信息**(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是**源头互斥 + 批内去重 + 存量一次性处置**:① `codingSummary` 提示词(中英双语)补一条互斥约束——同一结论只记一条,`pitfall` 与 `constraint` 不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做**批内**跨类型去重,只对 `pitfall↔constraint`、`history↔project` 两组生效(正是实测最集中的应用对:9 对 + 8 对),`planBatchCrossTypeDedupe(entries, { minSim })` 是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛 `BATCH_CROSS_TYPE_MIN_SIM = 0.85`(textSim,不是余弦,词面重合是这两组对的主特征)、`BATCH_CROSS_TYPE_MIN_CHARS = 24`、`BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5`;③ 处置走 `store.supersedeChain` 只写 `superseded_by` / `superseded_at`,**保留正文更全的一条、被取代者正文零改写、不归档、不 merge**——判错时旧行仍在库里、显式搜索照旧读得到,且 `supersededIds()` 的 `JOIN ... AND w.archived=0` 让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意**没有**复用 `src/dream/decisions.js` 的 `supersedeInfoFirstPair`:它走 `service.update` 会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。**默认开**(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以 `src/config.js` 给 `true`、设 `false` 是 kill switch,且关时审计行的 `batchDeduped` / `batchPairs` 两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进 `settings.js` 的 `FEATURE_FLAG_BOOLEANS` 与 `FEATURE_FLAG_NUMBER_RANGES`,面板上能改。存量 17 对**不由代码处置**:只读脚本 `scripts/cross-type-dedupe-dryrun.mjs` 出清单(输出**禁止落在仓库内**,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。 diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index af2aea66..c7a83f64 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -101,7 +101,7 @@ |---|---|---|---| | `writeAdmission.enabled` | `false` | 跑第 1 级确定性判据:空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入) | 关 = 不判定、不拒绝,`evaluate` 返回与只计量阶段逐字段一致。命中 → 审计行带 `metadata.deny` | | `writeAdmission.enforce` | `false` | 命中时真的拒绝(`store.save` 之前返回) | 关 = 仅告警:判据照跑、审计照留、写入不拦(读审计时「`deny` 非空且 `decision=allow`」即这一档的指纹)。被拒写入不落库、不通知、不排嵌入,`memory_save` 返回 `action:"denied"` + `reason` | -| `sensitiveScanEnabled` | `false` | A2 密钥 / PII 判据的来源(`sensitive-scan.js`)。开 = 判定结果可被写入准入消费;只覆盖带 sessionKey 的写入路径 | 关 = 扫描器不参与,`writeAdmission` 第 1 级只剩空白/噪声判据。**经 MCP / 8790 / `/import` 的写入不带 sessionKey,不进这道闸**(`write-admission.js` 的文件头注释点名了这条边界) | +| `sensitiveScanEnabled` | `false` | A2 密钥 / PII 判据的来源(`sensitive-scan.js`)。开 = 判定结果可被写入准入消费;覆盖**所有**写入的第 1 级判据,含 autoSummarize / dream 总览 / bootstrap / share 导入这些没有 sessionKey 的路径 | 关 = 扫描器不参与,`writeAdmission` 第 1 级只剩空白/噪声判据。**无 sessionKey 的写入只过第 1 级**:预算、同话题冷却、内容哈希仍只覆盖带 sessionKey 的写入(#663) | 第 1 级的判据面刻意收窄:**去重键命中不进第 1 级**(归 write-update 放行——同步近重复门跑在异步矛盾检测之前会把本该被裁决的矛盾直接拒掉),G1/G2 阈值也只是计量。两个键都走 feature_flags 白名单(面板可启停)。 diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js index 84a4b152..d0c9e6da 100644 --- a/dsh-mneme/lib/api-standalone.js +++ b/dsh-mneme/lib/api-standalone.js @@ -627,10 +627,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti ...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {}) }); const { action } = result; - // #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey - // 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到 - // `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而 - // 这两件事对调用方的处置完全不同(改内容 vs 重试)。 + // #254 写入准入:这条 HTTP 路径不带会话身份,只过第 1 级判据(#663)—— + // 密钥/PII 与空白噪声仍会被拒,预算 / 冷却 / 内容哈希不跑。显式分支是 + // 必需的:落到 `toApiList([null])` 会变成 500,把「被拒」伪装成「服务器 + // 出错」,而这两件事对调用方的处置完全不同(改内容 vs 重试)。 if (action === "denied") { sendJson(res, 422, { error: "write-rejected", reason: result.reason }); return; diff --git a/dsh-mneme/lib/bootstrap.js b/dsh-mneme/lib/bootstrap.js index d7faab6f..19799a44 100644 --- a/dsh-mneme/lib/bootstrap.js +++ b/dsh-mneme/lib/bootstrap.js @@ -194,15 +194,25 @@ export async function bootstrapFromDirectory({ service, dir, logger } = {}) { const items = []; let created = 0; let merged = 0; + let denied = 0; for (const item of candidates) { try { - const { action, memory } = service.saveWithDedupe({ + const { action, memory, reason } = service.saveWithDedupe({ ...item, importance: 3, tags: ["bootstrap"], source: "bootstrap", _overwrite: true }); + // #663: bootstrap has no session, so only the level-1 admission judgment applies + // here. Report the denial instead of letting a null `memory` fall into the outer + // catch — that shape lost both the reason and the count. + if (action === "denied") { + denied++; + items.push({ action, id: null, title: item.title, reason: reason ?? null }); + logger?.warn?.(`[dsh-mneme] bootstrap item "${item.title}" denied by admission (${reason ?? "unknown"})`); + continue; + } if (action === "created") created++; else merged++; items.push({ action, id: memory.id, title: memory.title }); @@ -211,5 +221,5 @@ export async function bootstrapFromDirectory({ service, dir, logger } = {}) { logger?.warn?.(`[dsh-mneme] bootstrap item "${item.title}" failed: ${String(error)}`); } } - return { dir: root, created, merged, items }; + return { dir: root, created, merged, denied, items }; } diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 8dd7b722..a396a9f7 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -1810,8 +1810,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar // summary unboundedly). `_overwrite` still archives the old overview into // content_history before replacing it. Content carries the snapshot-scope // footer computed above — the resident bar states its own 口径. - service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: `${summaryText.trim()}${summaryScope}`, importance: 5, source: "dream", _overwrite: true }); - summaryStored = true; + // #663: the dream overview passes the same admission gate as any other write. + // It has no session, so only the level-1 judgment applies. A denial must not be + // reported as a stored summary — the run outcome and the resident bar read this. + const summaryOutcome = service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: `${summaryText.trim()}${summaryScope}`, importance: 5, source: "dream", _overwrite: true }); + if (summaryOutcome?.action === "denied") { + logger?.warn?.(`dsh-mneme dream: summary write denied by admission (${summaryOutcome.reason ?? "unknown"})`); + } else { + summaryStored = true; + } // Re-embed the fresh summary so the index stays in sync with the store. if (semantic?.embedder && semantic?.vectorIndex) { try { diff --git a/dsh-mneme/lib/write-admission.js b/dsh-mneme/lib/write-admission.js index 2ef2670e..670ac586 100644 --- a/dsh-mneme/lib/write-admission.js +++ b/dsh-mneme/lib/write-admission.js @@ -36,8 +36,12 @@ // 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与 // pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。 // -// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话 -// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。 +// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行)。无会话 +// 身份的系统写入(dream 总览 / autoSummarize / bootstrap / standalone HTTP 保存)自 +// #663 起只走第 1 级判据:命中即拒的那档落一行(session_key 为 NULL,无 g1/g2/dup 测量 +// 点),放行档不落行——预算、话题冷却、内容哈希都要一条会话时间线做基准。 +// 唯一没进来的是 share 收包那条路:它走 store.save(docs/SHARE.md 的信任边界), +// 判据挂在 saveWithDedupe 的汇流口,要接得先把那条路改到汇流口。 // // 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的 // deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做 @@ -281,26 +285,34 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se exempt: null, deny: null }; - // 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。 - // 第 1 级判定同样在这里之外:#164 A2 定的判据面含 autoSummarize / dream 输出, - // 那三处的接线是 A2 的落地范围(本模块跑的是会话内的 memory_save 路径)。 - if (!sessionKey) return verdict; // 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。 + // 判据也一并跳过:拒绝面是「可解释」的承诺,审计关掉之后拒绝就只能静默发生, + // 而静默丢弃比不拦更坏——调用方会以为记忆已经写进去了。 if (config?.llmAudit?.enabled === false) return verdict; + // #663:第 1 级判据与会话身份无关。autoSummarize / dream 输出 / bootstrap / + // share 导入这些写入没有 sessionKey,但它们落库的同样是**文本**,密钥/PII 与 + // 空白噪声的判据面照样适用;此前这里提前 return,等于三处系统出口整片没判。 const hit = config?.writeAdmission?.enabled === true ? firstLevelHit(memory) : null; const judged = hit ? { ...verdict, deny: hit } : verdict; // 命中且 enforce:决策已定,不再发候选集查询、不再比较话题(纯白花成本)。 + // gate 跟着会话身份走:无会话身份不记预算行(没有可比的时间线),但拒绝照样 + // 生效,审计行由 record 落(session_key 为 NULL)。 if (hit && config?.writeAdmission?.enforce === true) { return { ...judged, decision: ADMISSION_DECISION_DENY, - gate: ADMISSION_GATE_SESSION_BUDGET, + gate: sessionKey ? ADMISSION_GATE_SESSION_BUDGET : null, topics: extractTopicKeys(memory) }; } + // 无会话身份 = 系统写入(dream / summarize / import / bootstrap):只过第 1 级, + // 不进预算、不查内容哈希、不算话题冷却。三者都要一条会话时间线做基准;硬凑一个 + // 假 session_key 会把真实会话的分布污染成两截,读审计的人再也分不开。 + if (!sessionKey) return judged; + const topics = extractTopicKeys(memory); const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? "")); if (pinned) return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" }; @@ -335,7 +347,11 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se * @returns {object[]} 落下的审计行(无测量点或失败时为空数组) */ function record({ sessionKey, verdict, memoryId } = {}) { - if (!sessionKey || !verdict || verdict.gate == null) return []; + if (!verdict) return []; + // #663:系统写入没有 gate(不进预算),但**被拒**是一次真实的丢弃,必须留行—— + // 否则三条系统出口的拒绝就是静默的,正与验收第 2 条相反。放行档不落行:没有 + // 会话基准的计量行读不出任何分布,只会把审计表撑大(同 llmAudit 保留期纪律)。 + if (!verdict.deny && (!sessionKey || verdict.gate == null)) return []; const at = now(); const topics = Array.isArray(verdict.topics) ? verdict.topics : []; const enforced = verdict.decision === ADMISSION_DECISION_DENY; @@ -393,7 +409,8 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se // 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。被拦下的写入 // 同样不推进——它没进库,成为下一次 g2 的基准就会造出一条不存在的时间线。 // 仅告警档(deny 非空但 decision=allow)**要**推进:那条行确实落库了。 - if (rows.length > 0 && !verdict.exempt && !enforced) { + // 系统写入(无 sessionKey)没有话题表可用,跳过这一步即可。 + if (sessionKey && rows.length > 0 && !verdict.exempt && !enforced) { const table = topicTable(sessionKey); for (const topic of topics) table.set(topic, at); } diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js index 84a4b152..d0c9e6da 100644 --- a/dsh-mneme/src/api-standalone.js +++ b/dsh-mneme/src/api-standalone.js @@ -627,10 +627,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti ...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {}) }); const { action } = result; - // #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey - // 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到 - // `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而 - // 这两件事对调用方的处置完全不同(改内容 vs 重试)。 + // #254 写入准入:这条 HTTP 路径不带会话身份,只过第 1 级判据(#663)—— + // 密钥/PII 与空白噪声仍会被拒,预算 / 冷却 / 内容哈希不跑。显式分支是 + // 必需的:落到 `toApiList([null])` 会变成 500,把「被拒」伪装成「服务器 + // 出错」,而这两件事对调用方的处置完全不同(改内容 vs 重试)。 if (action === "denied") { sendJson(res, 422, { error: "write-rejected", reason: result.reason }); return; diff --git a/dsh-mneme/src/bootstrap.js b/dsh-mneme/src/bootstrap.js index d7faab6f..19799a44 100644 --- a/dsh-mneme/src/bootstrap.js +++ b/dsh-mneme/src/bootstrap.js @@ -194,15 +194,25 @@ export async function bootstrapFromDirectory({ service, dir, logger } = {}) { const items = []; let created = 0; let merged = 0; + let denied = 0; for (const item of candidates) { try { - const { action, memory } = service.saveWithDedupe({ + const { action, memory, reason } = service.saveWithDedupe({ ...item, importance: 3, tags: ["bootstrap"], source: "bootstrap", _overwrite: true }); + // #663: bootstrap has no session, so only the level-1 admission judgment applies + // here. Report the denial instead of letting a null `memory` fall into the outer + // catch — that shape lost both the reason and the count. + if (action === "denied") { + denied++; + items.push({ action, id: null, title: item.title, reason: reason ?? null }); + logger?.warn?.(`[dsh-mneme] bootstrap item "${item.title}" denied by admission (${reason ?? "unknown"})`); + continue; + } if (action === "created") created++; else merged++; items.push({ action, id: memory.id, title: memory.title }); @@ -211,5 +221,5 @@ export async function bootstrapFromDirectory({ service, dir, logger } = {}) { logger?.warn?.(`[dsh-mneme] bootstrap item "${item.title}" failed: ${String(error)}`); } } - return { dir: root, created, merged, items }; + return { dir: root, created, merged, denied, items }; } diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 8dd7b722..a396a9f7 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -1810,8 +1810,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar // summary unboundedly). `_overwrite` still archives the old overview into // content_history before replacing it. Content carries the snapshot-scope // footer computed above — the resident bar states its own 口径. - service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: `${summaryText.trim()}${summaryScope}`, importance: 5, source: "dream", _overwrite: true }); - summaryStored = true; + // #663: the dream overview passes the same admission gate as any other write. + // It has no session, so only the level-1 judgment applies. A denial must not be + // reported as a stored summary — the run outcome and the resident bar read this. + const summaryOutcome = service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: `${summaryText.trim()}${summaryScope}`, importance: 5, source: "dream", _overwrite: true }); + if (summaryOutcome?.action === "denied") { + logger?.warn?.(`dsh-mneme dream: summary write denied by admission (${summaryOutcome.reason ?? "unknown"})`); + } else { + summaryStored = true; + } // Re-embed the fresh summary so the index stays in sync with the store. if (semantic?.embedder && semantic?.vectorIndex) { try { diff --git a/dsh-mneme/src/write-admission.js b/dsh-mneme/src/write-admission.js index 2ef2670e..670ac586 100644 --- a/dsh-mneme/src/write-admission.js +++ b/dsh-mneme/src/write-admission.js @@ -36,8 +36,12 @@ // 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与 // pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。 // -// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话 -// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。 +// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行)。无会话 +// 身份的系统写入(dream 总览 / autoSummarize / bootstrap / standalone HTTP 保存)自 +// #663 起只走第 1 级判据:命中即拒的那档落一行(session_key 为 NULL,无 g1/g2/dup 测量 +// 点),放行档不落行——预算、话题冷却、内容哈希都要一条会话时间线做基准。 +// 唯一没进来的是 share 收包那条路:它走 store.save(docs/SHARE.md 的信任边界), +// 判据挂在 saveWithDedupe 的汇流口,要接得先把那条路改到汇流口。 // // 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的 // deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做 @@ -281,26 +285,34 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se exempt: null, deny: null }; - // 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。 - // 第 1 级判定同样在这里之外:#164 A2 定的判据面含 autoSummarize / dream 输出, - // 那三处的接线是 A2 的落地范围(本模块跑的是会话内的 memory_save 路径)。 - if (!sessionKey) return verdict; // 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。 + // 判据也一并跳过:拒绝面是「可解释」的承诺,审计关掉之后拒绝就只能静默发生, + // 而静默丢弃比不拦更坏——调用方会以为记忆已经写进去了。 if (config?.llmAudit?.enabled === false) return verdict; + // #663:第 1 级判据与会话身份无关。autoSummarize / dream 输出 / bootstrap / + // share 导入这些写入没有 sessionKey,但它们落库的同样是**文本**,密钥/PII 与 + // 空白噪声的判据面照样适用;此前这里提前 return,等于三处系统出口整片没判。 const hit = config?.writeAdmission?.enabled === true ? firstLevelHit(memory) : null; const judged = hit ? { ...verdict, deny: hit } : verdict; // 命中且 enforce:决策已定,不再发候选集查询、不再比较话题(纯白花成本)。 + // gate 跟着会话身份走:无会话身份不记预算行(没有可比的时间线),但拒绝照样 + // 生效,审计行由 record 落(session_key 为 NULL)。 if (hit && config?.writeAdmission?.enforce === true) { return { ...judged, decision: ADMISSION_DECISION_DENY, - gate: ADMISSION_GATE_SESSION_BUDGET, + gate: sessionKey ? ADMISSION_GATE_SESSION_BUDGET : null, topics: extractTopicKeys(memory) }; } + // 无会话身份 = 系统写入(dream / summarize / import / bootstrap):只过第 1 级, + // 不进预算、不查内容哈希、不算话题冷却。三者都要一条会话时间线做基准;硬凑一个 + // 假 session_key 会把真实会话的分布污染成两截,读审计的人再也分不开。 + if (!sessionKey) return judged; + const topics = extractTopicKeys(memory); const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? "")); if (pinned) return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" }; @@ -335,7 +347,11 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se * @returns {object[]} 落下的审计行(无测量点或失败时为空数组) */ function record({ sessionKey, verdict, memoryId } = {}) { - if (!sessionKey || !verdict || verdict.gate == null) return []; + if (!verdict) return []; + // #663:系统写入没有 gate(不进预算),但**被拒**是一次真实的丢弃,必须留行—— + // 否则三条系统出口的拒绝就是静默的,正与验收第 2 条相反。放行档不落行:没有 + // 会话基准的计量行读不出任何分布,只会把审计表撑大(同 llmAudit 保留期纪律)。 + if (!verdict.deny && (!sessionKey || verdict.gate == null)) return []; const at = now(); const topics = Array.isArray(verdict.topics) ? verdict.topics : []; const enforced = verdict.decision === ADMISSION_DECISION_DENY; @@ -393,7 +409,8 @@ export function createWriteAdmission({ store, config, logger, now = Date.now, se // 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。被拦下的写入 // 同样不推进——它没进库,成为下一次 g2 的基准就会造出一条不存在的时间线。 // 仅告警档(deny 非空但 decision=allow)**要**推进:那条行确实落库了。 - if (rows.length > 0 && !verdict.exempt && !enforced) { + // 系统写入(无 sessionKey)没有话题表可用,跳过这一步即可。 + if (sessionKey && rows.length > 0 && !verdict.exempt && !enforced) { const table = topicTable(sessionKey); for (const topic of topics) table.set(topic, at); } diff --git a/dsh-mneme/test/write-admission.test.js b/dsh-mneme/test/write-admission.test.js index 7ced9a77..949a2c24 100644 --- a/dsh-mneme/test/write-admission.test.js +++ b/dsh-mneme/test/write-admission.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { DatabaseSync } from "node:sqlite"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createStore } from "../src/store.js"; @@ -23,6 +23,9 @@ import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from // 写错时照样过,真 config 才会红。 import { Config } from "../src/config.js"; import { createSensitiveScan } from "../src/sensitive-scan.js"; +import { bootstrapFromDirectory } from "../src/bootstrap.js"; +import { createDreamScheduler } from "../src/dream.js"; +import { mockCtx } from "./helpers/dream-mock.js"; import { testConfig } from "./helpers/test-config.js"; // #254 写入准入(第一阶段:只计量,不拦截)。 @@ -667,3 +670,106 @@ test("A2 负样本在真装配下一条都不误杀(含敏感词但没有值 } }); +// --- #663 A2 覆盖面:系统写入(无 sessionKey)也过第 1 级判据 --------------------- +// 第 1 级判据管的是「这条文本能不能落库」,与会话身份无关。autoSummarize / +// dream 总览 / bootstrap 冷启动 / share 导入都没有会话,但落库的是同样的文本; +// #663 之前 evaluate 在判据之前就 return,等于这几条出口整片没判。 +// 预算/话题冷却/内容哈希仍不进:它们要一条会话时间线做基准,没有会话就没有基准。 + +/** 无会话身份(session_key IS NULL)的准入行。 */ +function systemAdmissionRows(store) { + return store.listLlmAudits({ limit: 200 }) + .filter((r) => r.operation_type === ADMISSION_OPERATION_TYPE && r.session_key == null); +} + +test("#663 系统写入默认档:判据关时不判、不落行,写入与 #332 逐字节一致", () => { + const { store, service } = a2Store(); + const result = service.saveWithDedupe({ ...SECRET_ROW, source: "dream" }); + assert.equal(result.action, "created"); + assert.equal(rowCount(store), 1); + assert.deepEqual(systemAdmissionRows(store), [], "判据关时系统写入一行审计都不该多"); +}); + +test("#663 系统写入观察档:判据跑、拒绝面留行(session_key 为 NULL)、写入照常", () => { + const { store, service } = a2Store({ sensitiveScanEnabled: true, writeAdmission: { enabled: true } }); + const result = service.saveWithDedupe({ ...SECRET_ROW, source: "dream" }); + assert.equal(result.action, "created", "仅告警档不拦"); + const rows = systemAdmissionRows(store); + assert.equal(rows.length, 1, "系统写入的告警不能是静默的"); + const row = rows[0]; + assert.equal(row.session_key, undefined, "落库是 NULL,读回是 undefined——不编一个假 key"); + assert.equal(row.metadata.gate, null, "不进预算就没有 g1 行"); + assert.equal(row.metadata.decision, "allow"); + assert.equal(row.metadata.deny.reason, "sensitive"); + assert.equal(row.metadata.deny.kind, "github_token"); + assert.equal(row.metadata.deny.enforced, false); +}); + +test("#663 系统写入拦截档:拒了、没落库、审计行标 enforced", () => { + const { store, service } = a2Store({ + sensitiveScanEnabled: true, + writeAdmission: { enabled: true, enforce: true } + }); + const result = service.saveWithDedupe({ ...SECRET_ROW, source: "dream" }); + assert.equal(result.action, "denied"); + assert.equal(result.reason, "sensitive"); + assert.equal(result.memory, null); + assert.equal(rowCount(store), 0, "被拒的系统写入一条都不该落库"); + const rows = systemAdmissionRows(store); + assert.equal(rows.length, 1); + assert.equal(rows[0].session_key, undefined, "落库是 NULL"); + assert.equal(rows[0].metadata.deny.enforced, true); +}); + +test("#663 系统写入同样吃空白/噪声判据(不只是密钥)", () => { + const { store, service } = a2Store({ writeAdmission: { enabled: true, enforce: true } }); + const blank = service.saveWithDedupe({ type: "project", title: "", content: "", source: "bootstrap" }); + assert.equal(blank.action, "denied"); + assert.equal(blank.reason, "blank"); + assert.equal(rowCount(store), 0); + assert.equal(systemAdmissionRows(store).length, 1); +}); + +test("#663 bootstrap 冷启动:被拒条目报 denied 而不是混进 merged,也不解引用 null", async () => { + const { store, service } = a2Store({ + sensitiveScanEnabled: true, + writeAdmission: { enabled: true, enforce: true } + }); + const root = mkdtempSync(join(tmpdir(), "mneme-663-bootstrap-")); + try { + writeFileSync(join(root, "README.md"), `# Demo\n\n临时贴了 ${SAMPLE_VALUES.githubPat},回头删掉。`); + const report = await bootstrapFromDirectory({ service, dir: root, logger: null }); + assert.equal(report.created, 0); + assert.equal(report.merged, 0, "被拒不该记成并入"); + assert.equal(report.denied, 1); + assert.equal(report.items[0].action, "denied"); + assert.equal(report.items[0].id, null); + assert.equal(report.items[0].reason, "sensitive"); + assert.equal(rowCount(store), 0); + assert.equal(systemAdmissionRows(store).length, 1, "系统出口的拒绝也要留审计"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("#663 dream 总览落盘:被拒时 outcome.summary 为 false,库里没有 summary 行", async () => { + const { store, service } = a2Store({ + sensitiveScanEnabled: true, + writeAdmission: { enabled: true, enforce: true } + }); + const ctx = mockCtx({ summaryText: `记忆库总览:临时密钥 ${SAMPLE_VALUES.githubPat} 待清理。` }); + // 空库时 dream 直接 skip(skipped: true),走不到 summary 阶段——先种两行让它真跑。 + // 正文要够长:质量闸会把过短的行直接归档,归档行不进 dream 快照。 + service.saveWithDedupe({ type: "project", title: "插件", content: "第一条正文,长度足够的一段内容,用于 dream 快照。".repeat(3), importance: 3 }); + service.saveWithDedupe({ type: "project", title: "插件2", content: "第二条正文,另一段足够长的内容,用于区分两条记录。".repeat(3), importance: 4 }); + const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) }); + const outcome = await dream.runDream(ctx, service, {}); + assert.notEqual(outcome.skipped, true, "库里有候选时不该再 skip"); + assert.equal(outcome.summary, false, "被拒的总览不能报成已落盘"); + assert.equal(store.list({ type: "summary", limit: 10 }).length, 0); + assert.ok( + systemAdmissionRows(store).some((r) => r.metadata.deny?.kind === "github_token"), + "dream 出口的拒绝要留一行可查" + ); +}); +