# dsh-mneme 记忆分享包

## 概览

把本机记忆打成一个可外发的 JSON 包，发给同样装着 dsh-mneme 的人。包只含活跃行，
`agent_scope` / `workspace_scope` 与本机 home 前缀一律不进包，命中密钥/PII 的行
**整条不进**（不打码）。出包与收包两张脸都在：`share` 打、`receive` 吃、
`undo-share` 按 `pack_id` 整包收回（车道 C），面板上有同一批三件事的按钮（车道 D，见下面
「面板」那一节）。两条路做的是同一批三件事——出包 / 入库 / 整包退出——吃的是同一套服务端判据：
判序、水位、入包闸门与 `dry_run` 口径一条都不在界面里复述，界面只投影服务端给的读数。

## 用法

```bash
dsh-mneme share --dry-run                  # 只看会进多少条、跳过多少条，不落盘
dsh-mneme share --out ~/Desktop/pack.json  # 写包
dsh-mneme share --alias 名字 --out …       # 在包里署名：对方看到的是这个，不是你的路径
dsh-mneme share --type decision,pitfall --out …   # 超水位时分批
dsh-mneme share --tag 存储 --dry-run       # 按标签收窄（与 --type 同为分批用的两只旗标）
```

`--out` 与 `--dry-run` 一个都不给就直接拒绝：不写文件又把整库打到 stdout，报告会被 JSON 淹掉。
`--type` 可逗号并列（CLI 拆成数组），`--tag` 原样透传单个标签；两者都只在填了时才进 body。
`--out` 里的 `~/…` 由 CLI 自己展开（`expandHome`）：Git Bash 会替未加引号的那个展开，
PowerShell 与 cmd 不会——那两侧它是字面量，`writeFileSync` 直接 `ENOENT`，现场只剩一坨堆栈。

前置：面板「设置 → 外部访问」打开，token 用 `dsh-mneme config set <url> <token>` 配一次。命令走 standalone 数据面 `POST /share`。

收侧两条命令（同一台机器上的对面那半，走的也是同一份前置与同一个 token）：

```bash
dsh-mneme receive ~/Desktop/pack.json              # 干跑：只报会进几条、已存在几条，一个字都不写
dsh-mneme receive ~/Desktop/pack.json --apply      # 确认数字之后真的吃进自己的库
dsh-mneme undo-share ~/Desktop/pack.json           # 干跑：清点这个包在我库里占了几行
dsh-mneme undo-share ~/Desktop/pack.json --apply   # 整包物删（不是归档；包文件还在，可重新 receive）
```

干跑是默认、`--apply` 是确认，与 `reclaim` 同一脾气：缺省即不动数据。包文件在**本地**读、整个当
body 送上去——服务端永远看不到路径，也就不需要任何文件系统权限，这是收侧协议选「传包不传路径」
的全部理由。`undo-share` 收的也是**包文件**而不是裸 `pack_id`：让人抄 16 位十六进制去删自己库里的
行，抄错一位就是「删了个没删成的包，还自以为收掉了」。直连 HTTP 时的 body 形状是
`/receive` → `{pack, confirm?}`、`/receive/undo` → `{packId, confirm?}`；`confirm` 只认真值 `true`——
缺省、`"true"`、`dryRun:false`、对象一律干跑。

同一个动作有两张皮：宿主内面板面是 `POST /api/dsh-mneme/share`（`policy: token`，与 `/export` 同门；
成功时多带一个 `Content-Disposition`——那是**挂在 POST 响应上的下载答复**，落盘要有一个客户端真的去发
这一枪；发这一枪的就是面板「分享记忆包…」弹层里那颗「下载包文件」，它只在报告摆上屏之后出现（见下面
「面板」那一节），文件名以这一格为准、面板不自己拼第二套日期命名），数据面是 `POST /share`（Bearer）。
解析与答复只有一份（`src/share-http.js`）。#411 那条「两张皮只许剩鉴权与路径不同」在这里要按字面补一句：
**实际允许的差异是三处**——鉴权、路径，加上宿主内侧那一个 `Content-Disposition`（两侧 `sendJson` 签名不同，
只有面板侧有 headers 第四参，所以文件名在它那里拼，共用层只回 `{status, body}`）。除这三处之外不许再长出
第四处：状态码、错误码、包体字节、`warn` 回调有没有接上，都必须一致（GET 两侧都回 **405**，这条**故意**
与邻居 `/maintenance/reclaim` 的「GET 掉到链尾 404」不同——本路由自己新写，取一致优先于照抄旧样；
这条路由的存在感来自「有人主动按分享键」，不给面板轮询用）。

收侧的两张皮同一条纪律：面板是 `POST /api/dsh-mneme/receive` 与 `.../receive/undo`
（`policy: "token-write"`，非 POST 一律 405），数据面是 `POST /receive` 与 `POST /receive/undo`
（Bearer 那道全局门，GET 同样 405）。解析与答复同样只有一份——`src/share-http.js` 的
`parseReceiveBody` / `receiveReply` / `parseUndoBody` / `undoReply`，两面各自只写鉴权与路径。
收侧**不出** `Content-Disposition`：没有浏览器要下载的东西，CLI 自己落盘、自己读文件，
这条差异只属于出包侧。

「这一层从不抛」是**层**的契约而不是每个面的运气：`warn` 自己坏也被共用层的守卫吞掉，
两面各自还留一道 `.catch`（#410 的形状——链上没人返回 promise，输不掉就一个字节都不回）。
错误码 → 状态码的映射也只有一份（`SHARE_IMPORT_ERRORS`），码集从 `validateSharePack` 的源码
静态锁出来，新增码没登记会当场转红而不是静默回 500。

### 面板（车道 D）

不想开命令行的人在记忆面板的「⋯」菜单里做同样三步：**分享记忆包…** / **导入分享包…** / 外来区筛选。
判据一律住在服务端——出包在 `src/share.js`、入包闸门在 `src/share-import.js`、协议层在
`src/share-http.js`——界面只投影读数，一条都不复述（#411 口径）。三面都在 `lib/client.js`，
锁是**静态源锁**（`test/client-share.test.js` / `test/share-foreign-rows.test.js`），
本仓库没有可跑的宿主壳，所以下面写的是代码形状 + 服务端读数，不是「浏览器里看过」。

- **分享**：填别名（可空）、可选「只发这一类」，点「生成」。**报告先于下载**——进包条数、
  同键折叠数、六个跳过原因的计数、可展开的跳过清单都摆上屏之后，才出现「下载包文件」那颗键。
  `included` 是**实际发出**的条数，够格进包的是 `included + deduped` 两个数同屏（包内折叠，
  见下面「`report` 只有四个键」那段）。超 3 MiB 水位时服务端回 413，面板显示同样的报告 +
  字节数 + 那句提示；那句提示是 `--type`/`--tag` 的 CLI 口吻（`src/share-http.js` 的
  `SHARE_ERROR_HINT` 只许有一份，面板不改写），按 tag 分批仍只能在命令行做（见文末
  「车道 D 留下的三格已知代价」第二条）。`included` 为 0 时不给下载——那种包对面吃的是
  `empty-pack`（400），屏上换成那句「没有够格外发的条目，包是空的」。
- **接收**：选包文件 → 先干跑（body 不带 `confirm`）→ 看「将新建 N 条 / 已在库中 M 条 /
  正文相同 U 条 / 撞名 K 条」→ 点「确认导入」才写库。**确认那颗键在拿到预览之前是灰的**。
  「写进去了」这句只由答复的 `dry_run` 说，不按按钮状态推；确认那一发如果仍回干跑，屏上是
  「没有写库：这一枪仍是干跑」那句自己的话，而不是把预览重画一遍当作没发生。撞名是 advisory 读数
  （只对「本轮真正会新建的那些」判过），所以清单为空不等于「没有冲突」——同一屏下面那行
  「包内 N 条」是服务端给的 `conflicts_checked`，它是**包里的条数**而不是「比过的条数」，
  上一行为什么可能沉默，读的就是这个数。
- **外来区**：工具条「外来」chip 走服务端的 `source=imported` 精确筛选，**只管浏览态**：
  有搜索词时这颗键不许被开启（已经开着的话永远许关掉，不把她锁在一个解不开的状态里），
  并且带一行看得见的说明，不只挂在 `title` 上——搜索结果里没有可用的来源读数，理由见文末
  已知代价第一条。行上是 `from-<别名>` 徽标（读数就是 `importedTagFor` 打的那条标签，库里没有
  单独的别名列；本机行手工打的 `from-x` 不算外来行，判据先认 `source`）。抽屉里显示所属
  `pack_id` 并给「收起这个包」，**两段**：先干跑清点条数（并列出 `sample` 里前 20 条样例标题，
  让人核对「要收起的到底是哪一批」；`deleted` 才是全量，两个数并发下可以差一条，谁也不是对方的
  依据），再确认**物删**（不归档的理由见下面「整包退出」那段）。清点回 `deleted = 0` 时说的是
  「这个包在本库里已经没有行了」，不给一颗「确认删除 0 条」的键；确认那一发同样只认答复的
  `dry_run`，回干跑时说「没有删除：这一枪仍是干跑」。已知不对称：搜索态的行不带 `pack_id`
  （`/search` 的 DTO 不含这一格），所以搜索态按不了撤销、徽标也不出现，绕法是清掉搜索词回浏览态。
  入库是逐条查 + 逐条写（实测 494 µs/条，单批 1,315 条真导入 734 ms；两发的往返都在 0.5–3 秒量级），
  所以每一发都挂自己的 `busy` 并各打一行在忙的话：接收弹层那两发（预览与确认）是「导入中…」
  （`memory.explorer.importing`），抽屉那两发（清点与确认）是「收起中…」（`undoBusy` →
  `memory.explorer.undoPackBusy`）。只把键按灰而屏上无一句话，看起来就像没反应——这是已经修掉的
  病灶，不是今天的屏。

## 装什么、不装什么

判序就是下表顺序（`classifyRowForShare`），一个原因只记一次：状态位 → 类型 → 空白/噪声 → 敏感。
`report.skipped` 的六个计数按这些键给，CLI 打印的是右边那列中文，两边都对着同一份账。

| 对象（`report.skipped` 的键 → CLI 显示） | 动作 | 为什么 |
|---|---|---|
| `archived` / `forgotten` → 归档 / 已遗忘 | 跳过 | 分享的是「现在还在用的我」，不是全部历史；这两面本来就不可达 |
| `readonly_type` → 只读指针行（`document`） | 跳过 | 正文文件不随包走，导过去是「看着有、点开是空」 |
| `excluded_type` → 不随包走（`summary`） | 跳过 | 对面没有上下文读它，且会抢对面自己 dream 总览的注入档位 |
| `blank_or_noise` → 空白/噪声 | 跳过 | 这一格是**两级判据**：`informationlessReason`（写入准入那份，只许有一份，标题与正文归一后**都不剩字**才命中）+ 一条分享边界自己补的「正文 trim 后为空」。补的那一级问的不是「这条写入有没有信息」，而是「对面吃不吃得下」：收侧 `content` 列 NOT NULL，车道 C 还逐条校验非空（spec §5），一条空正文不是少发一条，是把整包毒掉。它排在敏感判定之前——没有正文就没有可泄露的东西，记成 sensitive 会让跳过原因说错话。原因键仍是那六个，多开一个要同时改这张表、CLI 的档位名和计数锁 |
| `sensitive` → 密钥/PII | **整条跳过**，不进包也不打码 | 半截打码的记忆是读不通的噪声。扫描走「任意文本」口径那份 `scanSensitive`，**不跟写入侧的 `sensitiveScanEnabled` 开关**：分享边界本身就是安全边界，没开写入闸门时恰恰最需要这里照常在扫 |
| `agent_scope` / `workspace_scope` | 不进包 | 里面是本机绝对路径与 agent 模板名 |
| 正文里的 home 前缀 | 换成 `~` | 正文字符串里同样会带出用户名目录 |

**`report` 只有四个键：`included` / `deduped` / `skipped` / `skip_list`**。`deduped` 记的是**包内折叠**：
`external_id = sha256(pack_id \0 type \0 title \0 content_hash)`，键里没有 scope（包里也不带 scope），而库里
同 type、同标题、同内容锚、scope 不同的两条是合法的两行（`src/service.js:1388` 的去重键吃
`agent_scope` / `workspace_scope` / `sensitivity` 三维），于是它们在包里共用一把键。为什么在出包侧折成一条，而不是留着让
对面处理：撞键的第二条 INSERT 会撞上车道 C 那条 `share_external_id` UNIQUE 索引，抛错穿出收侧
`transaction()` 的 ROLLBACK，**整包一条都进不去**（「收侧」那张表里「中途撞 UNIQUE」那一行）
（spec §5：先查管重复导入、UNIQUE 管并发，可同一个包
内部的两条谁都查不到，因为那时一行都没落；「重跑一次就 created=0」也救不了——两次撞的是同一个死键）。而标题与内容锚都相同的两条，到了对面那台没有 scope 的库里本来就是同一条
记忆，发两份只是多两行一样的东西。读数因此变成：`included` 是**实际发出的条数**，`included + deduped` 才是
「够格进包的行数」，两个数都摆在报告里，没有哪一条是被静默吃掉的。

**跳过清单有上限，账没有**：`report.skip_list` 截到 `SHARE_SKIP_LIST_MAX` = 50（常量在 `src/share.js`），
`report.skipped` 的六个计数仍走全量。出包侧那一份样本（「代价与实测」里那张表）5,837 条跳过 → 清单里只有
遍历顺序上前 50 个被跳过的行，CLI 又只打前 20 行，打满 50 格时终端补一句「跳过清单已截断，计数仍为全量」。
所以**绝大多数跳过项的标题根本不会出现**，命中密钥/PII 的那几条也一样——它们能不能挤进这 50 格，
取决于排在第几，而这份库被跳过的大头是归档行。要逐条核对就读计数、换 `--type` / `--tag` 收窄再跑一次，
不能把清单当成全集。上限同时是隐私口径：`skip_list` 会跟着包外发，50 格是「报告不该比包长」与
「不把整份敏感标题清单打包送走」两处约束的交点。

**清单里的标题只有两种形态：遮过 home 的、或者根本没有**。第二那种不是漏写：一条**整行**命中密钥/PII
判据的记忆，哪怕它因为别的原因（归档、已遗忘、类型排除）被跳过，标题也在源头留空，只报原因。为什么要
连标题一起给不了——判据认的是「关键词 + 赋值号 + 值的形状」，而写着 `password: 那个值` 的正文常常把同一个
值也写进了标题（最终评审复现的原话就是这一类），遮 home 遮不掉它；`skip_list` 是随包外发的，连整库被水位
拒掉那次的 413 答复也带着同一份 report。留空而不是给哈希摘要也是想清楚的：摘要等于递给收侧一个可以离线
爆破的 oracle，低熵口令正是这一类行最常见的内容。CLI 遇到空标题打 `（标题不外发）`，免得读的人把它当成
「这条本来没标题」。代价是方向上的：被扣掉标题的那几条她只能按原因计数去库里自己找。

**被按下（paused）的作用域照旧进包，这不是漏，别再按泄露登记**。取行走的是 `service.all()`
（调用点在共用层 `src/share-http.js:100`——**两张皮都从这一处取行**，它不是数据面独有的第二份实现，
#411 不许它长成第二份；`all()` 是「全库含归档/已遗忘」那个口径），而巩固那九个池子各自还套了一层
`withoutPaused`（`src/dream.js:77`）——分享这一层刻意没有。理由是一条轴的归属：`paused` 管的是
**注入与巩固**（「这个作用域现在别端给我看」，由 `scope-state` 在检索侧执行），它从来不表示「这行不属于我」，
更不表示「这行不该离开这台机器」。分享是人主动按的一次导出，能收窄它的只有 `--type` / `--tag` 与那道水位。
把这条写死在这里的另一半原因是预期管理：有人以为「按下一个作用域」连带挡住了它外发，实际没有——两张皮读的
都是 `all()`。真要按作用域筛，那是先决定「按下」该有两个含义、再动 `classifyRowForShare` 取行口径的事，
不是补一个过滤器就完（补了还会和 `--type`/`--tag` 那本「筛掉的不是跳过的」账打架）。

**进包的那几行也不是整行拷贝**：每行只有白名单字段——`external_id`、`type`、`title`、`content`、
`tags`、`importance`、`epistemic_status`、`created_at`、`updated_at`、`content_hash`；
`id` / `embedding` / `content_history` / `_full_content` / `evidence` / `doc_path` /
`agent_scope` / `workspace_scope` / `sensitivity` **一律不带**。`test/share-pack.test.js`
（"pack carries the whitelist and nothing else"）锁的就是这份「不含什么」：本机 id 与 scope 到了对面
会被当成真相源，向量与历史快照则是纯死重。剩下的字节几乎全由正文长短决定——这就是水位只能量
**最终序列化字节**、不能量条数的原因（按条数判，对「条少但每条很长」的库说谎；按字符数判，
中文字符与 UTF-8 字节差三倍，等于把 413 留给对面）。

## 收侧：吃什么、不吃什么

四条命令与两张 HTTP 面最后都汇到同一个函数：`importSharePack` / `deleteSharePack`
（`src/share-import.js`，`src/service.js` 只留委托出口）。它和常规写库入口 `saveWithDedupe`
是**两条路**，差别全在「谁改谁」这一件事上：已有的行一条都不许碰。

| 对象 | 动作 | 为什么 |
|---|---|---|
| `document` / `summary` 两类 | 闸门整包拒（`invalid-type`） | 出包侧本来就不发（上面那张表的 `readonly_type` / `excluded_type` 两行），收侧再钉一次：包的兼容面比镜像宽，出包口径哪天放开某个类型，不该自动扩大接收侧的入库范围（`SHARE_ALLOWED_TYPES`） |
| 任何一条不合格 | **整包拒**，停在第一个坏条目。线上答复只有 `{error, detail?}`：闸门确实报 `index`，但 `ShareImportError` 构造时就丢掉了它（`src/share-import.js:93` 只接 code 与 detail），这一格从来不上线（`src/share-http.js:250-251`，形状被 `test/share-receive-http.test.js` 的 deepEqual 钉死——`detail` 缺席时连这格都不造） | 手改的包是被**拒**而不是被**修**：逐条跳过的话，收件人拿到的是一份「没人知道自己少了哪几条」的库，而少掉的恰恰是他手工动过的那几条 |
| 同包重跑（`external_id` 已在本库） | 跳过，`skipped_existing` +1，`created = 0` | 幂等的主路径是事务内先查 `share_external_id`；那把部分 UNIQUE 索引只兜两个宿主共用同一 `memoryDir` 的并发，是双保险不是主路 |
| 与**自己**的条目同名（同 type 同标题、无 scope、非外来） | 建行照建，报进 `title_conflicts` | 不并入——「把别人的正文并进我的条目」正是这条设计要禁的动作。读数只对本轮真正会新建的条目判，所以它是给人看一眼的 advisory，**不是「没有别的撞名」的证明**：第二次吃同一个包时这一格恒空，而撞名在库里物理存在。`conflicts_checked` 是包里的条数，不是比过的条数 |
| 中途撞 UNIQUE | 整笔回滚，一行不留（不 catch、不续跑） | 「约束报错就跳过这一条」看着宽容，实际会把「这个包还没吃过」报成「吃过了」；整包回滚后重跑一次本来就答 `created = 0` |
| 嵌入 | 事务内入队、随**最外层 COMMIT** 排空；回滚时按层截断 | 用 `store.runAtomically` 等于静默不嵌入，这批行在收件人的向量检索里永远隐形；给已回滚的行做向量则是另一类幽灵 |
| 实体 / 关系 | **不做抽取**（v1 裁定，没有开关） | 抽取要花接收方的模型额度，而且是拿他无法修订的字去建图——那是一条未经批准的边界。后果写清：外来行**有向量、没实体**，因此实体检索与图检索看不见它们；要打开得单独决策 + 单独配置键 |
| 干跑（默认） | 只算账、不写库，答复与真导入同形状 | 干跑刻意不裹 `transaction()`：一次什么都没写的预览不该重渲染整面镜像，也不该把做梦调度器的空闲计时器顶回去 |

**落库留下什么**：`source = 'imported'`（车道 B 那条契约的唯一判据）、`agent_scope` /
`workspace_scope` 恒为 NULL、tags 追加一个来源标签（`from-<别名>`，别名里任何空白游程折成单个
`-`、整串封顶 32 字符，空别名走中性值，绝不拿对面的机器身份兜底）、`share_pack_id` /
`share_external_id` 由收侧铸造且物理上不可更新。**`created_at` 是导入那一刻**，不是发件人的写入
时间——包里那两个时间戳只作给人的时间感，落库不读它们。`content_hash` 那一格是**发件人遮 home
之前**算的锚，收件人按自己收到的字节重算，两侧各遮各的目录，所以它是发件侧自证的起点、
**不是运输证明**：答复里 `unchanged` 只是读数，要不要建这条行只看 `share_external_id` 那一格。

**答复里没有「向量待补」这一格**（spec §8 原稿承诺过，按代码删掉了，理由记在那一节）。
`importSharePack` 的答复只有 `pack_id / alias / created / skipped_existing / unchanged /
title_conflicts / conflicts_checked` 七个键（`src/share-import.js:173-187`，两张皮的 deepEqual
锁钉的就是「不多不少这一格」）：它说**建了几条**，不说这几条**有没有向量**。两种安静落空的情形
写清楚——没挂 embedder 时 `scheduleEmbed` 首行就返回（`src/service.js:318`），挂了一个会抛的
embedder 时错误被 fire-and-forget 自己吞掉；两种情况下这批行都照样落库，只是没有向量，
而向量检索吃不到它们——上面「实体 / 关系」那一行说的「外来行**有向量**、没实体」，前提就是这里
真的挂了一个不抛的 embedder。这一格不在答复里补，是因为 v1 不加新线上字段（与 §11
「不加配置键」同一条纪律），而补它要同时动共用层答复、CLI 与两处跨面锁——最后一条评审裁定改
文档而不是改代码。

**怎么查、怎么补**（读数住在向量那一面，不在收侧答复里）：查 `service.embeddedCount()`
（`src/service.js:2691`）或宿主状态面 `GET /api/dsh-mneme/semantic`（`src/api.js:1085`：
`coverage.embedded / total`、`reason: "no-embedder"`、`degraded`）；补的入口是
`POST /api/dsh-mneme/vector-reindex`（`src/api.js:1053`，`policy: token`，缺向量的行重嵌；
没配 embedder 时它回 `error: "vector-unavailable"` 而不是静默 0）。这两格都在**宿主内侧**，
CLI 走的那条 Bearer 数据面上没有对应路由（`src/api-standalone.js` 无 `/semantic`、无
`/vector-reindex`，`dsh-mneme status` 也不报覆盖度）——只会用命令行的收件人要确认向量，
得从宿主那侧读，或拿 `dsh-mneme search --mode vector` 做一条行为级探针：吃不到这批行就是没向量。

**信任边界（今天仍然成立的一句）**：接收侧**不跑 `scanSensitive`**，进库内容的敏感判定只由发件人
那一次扫描决定；`writeAdmission` 的第 1 级确定性判据同样不跑——它挂在 `saveWithDedupe` 的汇流口
（`src/service.js:1487`），而这条路走的是 `store.save`。这不是漏，是这一刀的口径：出包侧的判据管
「这份东西该不该离开我」，收侧替对面重扫一遍等于替收件人决定他愿意吃进什么，而那件事面板的
「收起这个包」与 `undo-share` 都留了人类的位置。实践含义只有一句：**只吃你信任的人发来的包**，
吃进去之后唯一的整包退路是 `undo-share` 那条路（面板的「收起这个包」按下的就是同一发
`POST /api/dsh-mneme/receive/undo`，见上面「面板」那一节，不是第二条退路）。

**同一条边界上有一处残留，没人消掉它，这里如实登记：`content_hash` 是遮之前的锚**。上面那句管的是
「内容本身敏不敏感」，这一格管的是**已经遮掉的那几个字能不能被验证**。包里每行的 `content_hash` 是
发件人库里 `normalizeForHash(title) \0 normalizeForHash(content)` 的 sha256 全串
（`src/content-hash.js:34-54`；由 store 在写入时从**未脱敏**的两列算出：`src/store.js:1208`；
包里原样带过去的那一格，`src/share.js:148-155` 的注释自己就写明它是遮前锚而不是运输凭证），
而包里给出的 title/content 是各自过了 `redact` 的（`src/share.js:112`、`:136`）。于是对遮过 home 的那些行，
这个哈希留下一个**可以离线核验的弱 oracle**：拿到包的人若已知某行的其余文本，把候选用户名目录按同一口径
归一后拼上去取 sha256，比对成功即确认「遮掉的那一段就是这个名字」，猜错只得到「不匹配」——它是核验不是
提取（不泄漏没在包里的东西，也不给出任何部分匹配）。规模参照下面那格读数：整库 11,615 行里正文带过
用户名目录的是 205 行（约 1.8%，整库口径；真正进包又被遮过的只会是它的子集）。

为什么留着而不是删：**它是 `external_id` 的第四个入参**（`deriveExternalId`，`src/share.js:49`；spec §3
那条「内容锚必须在键里」）。换成遮后的字节、或整格删掉，都会给**每一个**已发出去的包换一套幂等键——
收件人重跑同一个文件时先查不到旧键，于是把同一批原文**再建一遍全量行**，而幂等是收侧最硬的契约；
`unchanged` 那一格也失去唯一可比的东西。同一族的 oracle spec §3 在 `skip_list` 的标题上**拒绝过**
（宁给空串也不给摘要），两处不对称是权衡后的结果：那一格只服务于「给没进包的行报个原因」，这一格是键的
组成。spec §3 留了交叉引用，免得下一个读者以为没看见。**发件人如果在意**：这一格只覆盖「被遮掉的那段是
可猜的低熵词，且那行其余文本别人也已知」的组合；要断掉它，v1 的手段是**别发这些行**（`--type` / `--tag`
收窄；整行命中密钥判据的本来就不进包），不是就地打码——改归一化口径要带全库存量重算
（`src/content-hash.js:18-22`），而给这把锚开一个开关等于给幂等键留一个可以关掉的口子（§11 的理由）。
收件人这一侧的行为不变：它从不拿这个值当动作依据，只作 `unchanged` 读数（见上面「落库留下什么」）。

**整包退出（`deleteSharePack` / `undo-share`）是物删不是归档**：归档行仍然占着
`share_external_id` 的 UNIQUE 位，会造成「撤了却导不回来」的死角，那个形态比多删更坏，而原始包
还在分享者手里、随时能重导。它也与 `reclaim` 的「一条记忆都不删」相反：那是自动回收面（没人点头，
删了就找不回来），这是人显式按下的一次撤销——他刚说过「这批不是我的」，留着才是替他做决定。
答复里 `deleted` 是这一笔真的带走了几行（事务内重读，两个宿主共用 `memoryDir` 时以它为准），
`sample` 只是清点时的前 20 条预览（CLI 再截到 10 条），两者可以差一条，谁也不许拿对方的数当
「漏删/多删」的依据。什么都没删时**不进事务**：一笔空删除既不该重渲染镜像也不该顶回空闲计时器。

## 代价与实测

### 出包侧（车道 A/B）

测法：把本机记忆库**复制**到临时目录后直接驱动出货代码（`createStore` → `store.all()` →
`buildSharePack`）取数，没动活库。读数来自单机单库的一次样本，不代表所有用户。
端到端的活体跑尚未发生：当前在跑的宿主实例加载的是比这三条车道更早的构建，`/share`、
`/receive`、`/receive/undo` 在它那里都还不存在（此前只会回 404），要等插件 relink 之后才可达。
下面的数字都是**进程内**驱动出货代码的读数，不是网络往返的读数。

样本：`<本机记忆库副本>`，共 11,615 行（含归档与已遗忘）。

**下表取的是车道 A/B 合完那一版的数**，final review 之后动过这本账的口径有三条，都要重跑才作数：
整行命中密钥判据的行不再给标题（**不改计数**，只改清单里的 `title`）、正文为空算空白
（`blank_or_noise` 那一格从此不会是 0，它会吃掉一部分原先按 `included` 计的行）、包内同键折叠
（新增 `deduped` 一格，`included` 从此数的是实际发出的条数）。后两条方向一致：只会让 `included` 不增。
拿这些数做分批决策的人，请先重跑一遍脚本再按新读数说话。

| 项 | 读数 |
|---|---:|
| 整库打包进包条数 | 5,778（跳过 5,837：`archived` 5,754 / `sensitive` 79 / `readonly_type` 2 / `excluded_type` 1 / `forgotten` 1 / `blank_or_noise` 0；清单只留前 50 条，六个计数是全量） |
| 整库包字节 | 4,526,523 B ≈ 4.3 MiB → **3 MiB 水位命中，这一份发不出去** |
| `buildSharePack` 全程耗时（出货路径） | 123–143 ms：定性 + 扫描 + 铸 `external_id` + 序列化。**分享一次真实要付的进程内成本看这一格**（这一格是在「扫描只跑进包候选」那一版上量的，见下一行的口径变更） |
| 整库逐条 `scanSensitive` | 98 ms / 11,615 行（title+content+tags 拼接口径）。**现在这条就在出货路径里**：跳过分支要先知道整行干不干净才敢给标题（上面「标题只有两种形态」那段），所以每条进入定性的行都扫，归档行不再免扫。这一格因此是包内扫描成本的**上界**，两次读数相加≈新口径的全程（约 170–190 ms，推算，本机未重测）；而这条路径的大头本来就是下面那 1.1 s 全表读，多出来这一刀不改量级 |
| 首次全表读 `store.all()`（冷副本，含页调入） | 约 1.1 s |
| 按 type 分批后的最大单批 | project 1,309 条 ≈ 1.09 MiB；每个单独 type 都在水位之内 |

spec §12 等的三个数，读出来都是「口径成立」：

- **条数 → 字节**：万行级库整库一份装不下，`--type` / `--tag` 分批是主路径而不是兜底；
  好在每个 type 单独都在水位之内，实践上是「两份起步」而不是逐条挑。
- **扫描成本**：判序把「报什么原因」的大头（归档）挡在敏感判定之前，但**挡不住扫描本身**了——
  跳过分支也要先知道整行干不干净才敢给标题，所以整库逐条扫描（98 ms 那格）现在就在出货路径里。
  两次读数相加就是新口径的量级（约 170–190 ms，推算）。等待的大头在数据面本来就有的全表读，
  不是分享包新加的那一刀，所以 share 侧不需要进度输出。
- **水位命中**：是，但要分清两个数各管哪一侧。`MAX_PACK_BYTES` = **3 MiB**（`src/share.js`）
  是分享侧出货的水位，量的是最终序列化字节：超了就在源头拒成 413，答复只给 `bytes` /
  `limitBytes` / `hint` 加一份 report，**不回 pack**——把刚拒掉的几 MB 再序列化一遍是第二类事故。
  **4 MiB** 是两张皮各自写死的**入站** body 上限（`api.js` 与 `api-standalone.js` 的
  `MAX_REQUEST_BODY_BYTES`，同值同义的既有防御，不属于分享包；车道 C 的接收面读的就是这一档
  ——两面同值，所以 4 MiB 之内的包能整包进到闸门，再大在 body 层就回 413，校验根本没跑过）。
  水位刻意比入站上限留 1 MiB：包要原样当 body POST 回去，贴着上限等于给自己埋
  413。这份 4.3 MiB 的整库包两个都超，而拒发发生在第一个数字上——对面从来不会拿到它。

另一个参考值（spec §12 第 3 项）：正文里的 home 前缀替换命中 205/11,615 行（约 1.8%）——
分母是整库副本的全部行，不是进包那 5,778 条，所以它是「这台机器上有多少正文带过用户名目录」的
整库口径；正文级泄露真实存在但不泛滥，没到要回头改 §4 取行口径的程度。

### 收侧（车道 C 的入库与退出）

测法与上面同源，只是把出货代码换成收侧那两条：把本机记忆库**复制**到临时目录
（`memory.db` 连 `-wal` / `-shm` 一起，副本 619,445,896 B，复制 2,042 ms），在副本上
`createStore` → `store.all()` → `buildSharePack` 造包，然后按「干跑 → 真导入 → 重跑 →
干跑清点 → 物删 → 撤后再吃」的顺序逐格计时，全程没碰活库，跑完连脚本带副本一起删。
样本：`<本机记忆库副本>`，11,672 行（活跃 5,890）。读数是**单机单库单进程**的一次样本，
代表量级不代表所有人；这一节只报条数、字节与毫秒，正文一个字都不落文档。

装配说明（决定这些毫秒包含什么）：`createService({ store, mirror: null, config: {} })`，
**没有挂 embedder**——`scheduleEmbed` 照旧入队、最外层 COMMIT 照旧排空，但排空时没有可打的
向量。所以下面每一格都是**结构落库**的耗时，每条记忆真实嵌入的往返不在这些毫秒里（顺序本身由
`test/share-import.test.js` 那组锁钉住，不是靠这段叙述）。同理 `mirror: null`：镜像重渲染的成本
也不在账上。

**还有一处测法上的自欺要当场拆掉**：造包时传的 `homeDir` 是 `/fake/home`，它遮不到这台机器的
真实前缀，于是包里正文与库里正文逐字节相同——上面那格 `unchanged = 5,805` 因此是**测法造出来的
恒等**，不是跨机读数。真实跨机上只要 home 遮过一次，两侧锚就必然对不上（那把锚由**未脱敏**文本
派生，见 `src/share.js:148-155`）。所以 `unchanged` 永远只读成「这一格不是动作依据」，不许读成
「内容在路上没变」。遮 home 之后的包体形状不在这里量，它归 `test/share-pack.test.js` 那组锁。

| 项 | 读数 |
|---|---:|
| 整库包（八类全开） | 5,805 条 / 4,551,790 B ≈ 4.34 MiB → 仍**超 3 MiB 水位**（`buildSharePack` 回 `ok:false, error:"too-large"`），`deduped` 0；跳过 5,774 归档 + 1 已遗忘 + 82 密钥/PII（三个数与进包数相加是 11,662，剩下 10 行是 `document` / `summary` 这类被 `filters.type` **筛掉**的行——筛掉的不是跳过的，那本账不含它们） |
| `buildSharePack` 全程（含那次冷副本全表读） | 855 ms |
| `receive` 干跑 | 515 ms，报 `created = 5,805`、零写入 |
| `receive --apply` | **2,870 ms / 5,805 条 ≈ 494 µs/条** |
| 同一个包再吃一遍 | 763 ms，`created = 0`、`skipped_existing = 5,805`、`unchanged = 5,805`（幂等成立；那 763 ms 几乎全是 5,805 次 `share_external_id` 索引读 + 一次全活跃表扫的撞名表构建，**不写任何一行也照样要付**） |
| `undo-share` 干跑 | 78 ms，`deleted = 5,805`、`sample` 20 条 |
| `undo-share --apply` | **242 ms / 5,805 条 ≈ 42 µs/条** |
| 撤回之后重吃同一个包 | 2,323 ms，`created = 5,805` → UNIQUE 位确实随物删释放，「撤了导不回来」的死角不存在 |
| 发得出去的那一批（单 type，project） | 1,315 条 / 1,148,442 B ≈ 1.10 MiB：干跑 276 ms → 真导入 **734 ms（558 µs/条）** → 物删 60 ms（46 µs/条） |

四条读法：

- **退出比入库便宜一个数量级**（42 µs/条 对 494 µs/条）。入库是逐条先查 + 逐条 `INSERT` +
  排队嵌入 + 一次全活跃表扫（撞名判据），退出是一条按 `share_pack_id` 的批删加一次溯源链接清理。
  所以「先干跑看看、不满意整包收回」这条路的经济性不用担心。
- **每条耗时在大包与单批之间基本持平**（494 对 558 µs/条），没看出平方项：整库规模的 receive
  落在秒级，分批不会更快，分批只解决水位。
- **幂等重跑不是免费的**：`created = 0` 那一遍仍要 763 ms。给人按的按钮旁边要有 loading，
  车道 D 的预览与确认两发都挂 `busy`，见上面「面板」那一节。
- **分批是发件侧的事，不是收件侧的**：4.34 MiB 的整库包在源头就被 3 MiB 水位拒了，
  上面那格导入耗时是把 `buildSharePack` 直接产出的包喂给 `importSharePack` 的**进程内**读数，
  它比任何一次真实网络传输都大——真实场景一次吃进来的是单 type 那一格（1,315 条 ≈ 0.7 s）。
  那条压测线索已在 C4 闭掉：`deleteBySharePackId` 按 `DELETE_ID_CHUNK` = 500 分块（`src/store.js:1406`），
  理由是 SQLite 单条语句最多绑 32,766 个参数（`SQLITE_MAX_VARIABLE_NUMBER`，实测第 32,767 个报
  "too many SQL variables"，`src/store.js:1434-1435`），而包水位按字节不按条数，「条数极多、每条极短」
  的包理论上够得到那一格——一条语句撞上限是整笔回滚，等于「这个包永远撤不掉」。分块在同一次
  `runAtomically` 里切 IN 列表、`changes` 逐块求和、generation 只在最后加一次，语义与分块前逐点对齐。
  多块路径有锁：600 行的包由**多条** DELETE 承担、每条绑数严格小于全集、各块并起来恰是 600
  （`test/share-import.test.js` 的 600 行用例），本次 5,805 行的物删实测走的正是多块（12 块）。
  上限那一格因此**结构上到不了**，不是「没测到那个量级」：一条语句最多绑 `DELETE_ID_CHUNK` = 500 个
  参数（常量在 `src/store.js:1402-1406`，一块一行一个 `?`，块与块互不相交、每块只绑自己那 ≤500 个），
  条数再多也只是多加块数。所以这一条不该被写成「要多少行才撞得着」——按代码不存在够得到 32,766 的输入，
  也就没有留给它的回归锁；被锁住的是**多块**这条真实会走的路径。

活体端到端仍未发生：当前在跑的宿主加载的是比车道 A/B/C 更早的构建，`/share` 与 `/receive`
在它那里都回 404。上面全部是进程内读数，网络往返、Bearer 与 4 MiB 入站上限那一档的成本
还没有实测数（`/receive` 读的是 `api.js` / `api-standalone.js` 各自写死的
`MAX_REQUEST_BODY_BYTES` = 4 MiB，与出包侧 3 MiB 水位留的 1 MiB 余量正好对上）。

## 外来区：本库不巩固别人的原文（车道 B）

车道 B 立的契约只有一句话：**`source='imported'` 的行不参与我方任何巩固动作**。判据只有
一份实现——`src/memory-source.js` 的 `isImportedRow`（精确匹配 `source === "imported"`，
不做大小写魔法：`source` 是机器写的列，出现变体说明写它的人另有意图，宁可判成「不是外来行」，
也不给它多一种处置权）。它挂在两个面上：

- **汇流口**（`src/service.js:1377` 同题候选集、`src/service.js:1398` `_mergeInto` 显式目标）：
  外来行因此拿不到 `appendContent` 改写、`updated_at` 位移与 `content_history` 追加。蒸馏路径
  每一次同题命中都会覆盖别人的正文，把两家记忆搅成一条混杂物；把外来行剔出候选集后，写入
  只会落回既有的「新建一行」分支。
- **九个行池**（`src/dream.js:64,493,1021,1483`；`src/dream/sleep.js:212,454,507,596`；
  `src/organize.js:164`）：触发基线（`activeStoreSize`）、叙述条（`generateNarratives` 的
  `inputs`）、巩固快照（`runDream` 的 `memories`）、记忆总览、
  冲突消解、归档降级、模式发现、关系补全、整理的比对池。堵了汇流口不等于堵了它们——
  这些池子后面接的是 `setArchived` / `demoteToSummary` 这类**不经 `saveWithDedupe`** 的
  直接改行动作。
  `src/dream.js:1523` 刻意不挂：它按 `source === "dream"` 取行，外来行本来就够不着，
  补一个永远为真的谓词只会让下一个读这段代码的人以为这里出过事。

为什么值得单独立一条契约：一条外来行的新鲜度、召回史、热度都是**对面**喂出来的。我们的
`last_accessed_at` 上没有它的召回记录，于是时间分层与遗忘曲线在它身上系统性失真——把「我方
觉得它老了」当成「它可以被压成摘要」，等于用我们自己读不到的证据给别人定罪。合并、降级、
归档这三类动作都要处置权，而处置权不在我们手上。

**一个顺带后果，拍板为「就要这样」**：`src/dream.js:64` 的 `activeStoreSize` 是**触发阈值的基线**
（`shouldTrigger` 与成功轮回写基线共用这一个函数，口径必须同源，见该函数注释），它过滤行的那份判据与
`:1021` 真正喂给巩固的那份快照同源。所以车道 C 落地之后，一次导入五千条外来行**不会**把做梦节奏顶起来——
`dreamThresholdCount`（默认 10）/ `dreamThresholdChars`（默认 5000）只看我方自己的行数与字符数。
这是有意的：做梦频率应当是我方记忆增长的函数，而不是「对面库有多大」的函数，否则收到一份
大包就等于让别人远程调走我方每轮自付的 LLM 预算。代价一并写清——外来行既不进基线也不占
触发窗口，导入这件事本身攒不出一次巩固，下一轮仍按我方自己的写入节奏来。

### 那条边界闭到哪了：只剩镜像回灌，而那是刻意的

上面那张清单覆盖的是**巩固侧**。车道 C 之前这里确实挂着一条够不着的自动改行动作——
`src/document.js` 的 evidence 吸收拿的是**调用方给的 id 列表**（判据只有「存在 + 未归档 +
**未遗忘**（`document.js:295`，事务内重读时一起判的）+ 调用方看得见」，外加两条按类型的豁免），
升格成功后把引用的原子条**整批 `setArchived`**，既不经汇流口也不在那九个池子里，
车道 B 的九池静态锁看不见它。C8 把这一圈挂上了 `isImportedRow`（`document.js:296`）：
外来行不再被这样引去、不再被这样归档。**引用本身照旧落库**（文档合法地为它背书），
只是不替别人撤下活跃面，且跳过条数走 `evidence_skipped` 报出来（`document.js:312`）——
不做静默跳过：静默等于让「我这篇文档引了 5 条、实际收走 3 条」这件事没有出处。

于是自动动作里今天只剩**镜像回灌**这一条路会写外来行的正文，而它是 spec §15 二的刻意选择，
理由与看守都写在下面那节。

另外两处值得说清，免得把边界画错。`src/document.js:266-269` 的 supersede 败者**不在**本列：它只按
`type: "document"` 取同路径 / 同标题的旧版，而 `document` 指针行根本不进包（上面「装什么、不装什么」表里
`readonly_type` 那一行，收侧还按 `SHARE_ALLOWED_TYPES` 再钉一次），照包导入的外来行够不着它。代码那句注释
写的是「永远不可能」，口径要说准一点：`saveDocument` 是**接受调用方给 `source`** 的
（`src/document.js:250`：`payload?.source ?? "tool"`），所以「document 行不可能是外来行」靠的是
**没有哪条路把 `imported` 喂进 `saveDocument`**——今天确实没有（外来身份只由收侧那一处 `store.save` 铸造），
但它是纪律加出口形状，不是结构锁，与下面那节 `source` 列「在 SET 白名单里、靠纪律」的口径同一类。
面板与数据面的 `PATCH`（`src/api.js:820-821`：`service.update` 在 `:820`、`setArchived` 在 `:821`）
则**刻意不在**本契约之内：改正文与翻归档位
都是人按下的一次动作，人在自己的库里看得见这行就动得它——契约管的是自动动作，
不是「任何人都不许碰」。

**门已经开了，规矩在门之前**：车道 B 合完那几天，这一整套是惰性的——`receive` 还没落地，
库里一条 `source='imported'` 的行都造不出来，看文档的人容易把「守卫已在」读成「门已开」，
也容易反过来以为守卫是补的。今天两者都到位了（`dsh-mneme receive` 与两张 HTTP 面都能建行），
而守卫仍然排在入库之前那一版的位置上：先把形状钉住（`test/share-imported-guard.test.js`：
汇流口两条行为锁、整理动作面一条行为锁、九处清单与 dream.js 行扫两条静态锁），
别人的行进来那天它已经在位，中间不存在「先开门、后立规矩」的空窗。

### 手改外来行：允许，但身份不许变（spec §15 二）

上面那句「人看得见就动得它」需要把另一半钉死，这一节就是那一半。接收方在
`preferences.md` 里改一条别人发来的记忆：**正文会落地**，两条回填路线都照改——
`mergeHumanEdits`（`src/service.js:1950`，启动回灌与 `/import` 走的显式合并）与
`reconcileHumanEdits`（`src/service.js:2135`，每次 `syncMirror` 都跑，专收两次业务写之间的
手改）。这里刻意**不加** `isImportedRow` 过滤：那是接收方自己磁盘上的字，把刚打进去的内容
静默改回去是比「改写」更糟的失效——他会以为编辑生效了，而下一次同步悄悄把它抹平。

不许动的是行的身份：`source` 仍为 `imported`、`agent_scope` / `workspace_scope` 仍为 NULL、
`share_pack_id` / `share_external_id` 原样。车道 B 的九个行池与汇流口全按 `source` 认外来行，
若「编辑一次正文」能顺手改归属，「改一下正文再摘掉守卫」就成了绕开整套契约的合法操作。
今天防它靠的是两件事的叠加，而只有一件是结构性的：`share_pack_id` / `share_external_id`
压根不在 `store.update` 的 UPDATE 语句里（身份两件套只由 `save` 铸造，物理上动不了）；
`source` 与两个 scope 则**在** SET 白名单里（`src/store.js:1353`：patch 显式携带该键才改写）——
两条回填路线今天只喂 title/content，靠的是纪律，而纪律的看守是
`test/share-imported-identity.test.js`：两条行为锁各咬一条路线（①`mergeHumanEdits`、
②`reconcileHumanEdits`），**身份各列**（`source` 与两个 scope）挪了位当场转红——锁判的是
「编辑真的落地之后重读出来的那一行」，所以绿不等于空转。本组为回归锁：车道 B/C
落地那天它就是绿的，它的价值在防将来有人给「人改回填」的 patch 加「按内容重归属」这类便利。

**一条如实的覆盖缺口**：`reconcileHumanEdits` 里还有一支三方合并——人确实动过镜像
（digest 不匹配）**且** store 那一侧也改过（`updated_at` 变了）时，回灌的正文要追加一段
冲突标记（`src/service.js:2168-2172`）。那支今天**没有对着外来行走过一遍**：两条身份锁喂的
都是「只有人在镜像里动了字」的形状，走的是干净的 `else` 分支。两条分支共用同一个
`store.update` 调用形状，而 `share_pack_id` / `share_external_id` 压根不在它的 SET 白名单里、
`source` 与两个 scope 只有 patch 显式携带才改写，所以结构上那支也不会动身份；但「不会动」这一句
今天靠的是推理，不是锁——补一条行为锁留给下一刀，本节先把它记成已知缺口而不是成就。

代价照 spec 写清：手改之后那条记忆**不再是发件人的原文**——这是接收方自己的决定，本设计
不拦。不变的是后半句：它仍然不会被本机的巩固、降级或整理并走。

## 已知还没做

**面板已落地（车道 D）**：分享弹层（报告先于下载）、接收弹层（干跑先于确认）、外来区筛选 + 来源徽标 +
抽屉「收起这个包」都在 `lib/client.js`，用法与形状见上面「面板」那一节；本段只记收口——三笔账怎么闭的、
这一车道留下哪两格代价。旧话里「只有下载答复、没有下载入口」那一半现在反过来了：`Content-Disposition`
仍然只长在宿主内侧那一条 POST 的响应上（`src/api.js:1507-1510`，文件名由 `src/share-http.js` 的
`shareFilename` 拼），而**发这一枪的客户端已经存在**——分享弹层那颗「下载包文件」按 `included > 0`
自门控，报告没摆上屏就没有那颗键。收与撤两面照旧不出 `Content-Disposition`：没有要浏览器落盘的东西。

**落地时要一起清的三笔账——三条都闭了，各有一把锁**（都在 `test/client-share.test.js`，是静态源锁，
不是浏览器里看过）：

1. **`included` 不等于「够格进包的条数」——闭**：`included` 是实际发出的条数，`included + deduped`
   才是够格的行数（包内同键折叠，见上面「`report` 只有四个键」那段），两个数同屏、不需要新加字段。
   锁：`the report separates what shipped from what qualified`——钉的是源码里那个
   `report.included + report.deduped` 的**算式**与 `shareDeduped` 那一行**真实的渲染点**：把加法改成
   只读 `included`、或让折叠那一行不再上屏，当场转红。**它不锁标签**——静态锁看不见文案，把「合格
   条数」这个词从 `included + deduped` 那一格挪到 `included` 上，锁不会红；那一半靠人读屏（上面
   「面板」那一节的读数口径就是给人读的，不是给锁读的）。
2. **读 `dry_run` 那一格，不要按按钮状态推——闭**：干跑与真导入的答复同形状，区别只有 `withDryRun`
   加的 `dry_run`；`confirm` 只认真值 `true`（缺省、`"true"`、`dryRun:false`、对象一律干跑）。
   接收弹层与抽屉的撤销走同一个判据：确认那一发若仍回干跑，屏上说它自己那句（「没有写库：这一枪仍是
   干跑」/「没有删除：这一枪仍是干跑」），不把预览重画一遍当作没发生。锁：
   `the receive dialog reads dry_run instead of inferring from its own button`（钉那个三元式本身，
   并反锁收侧那一发不许出现 `dryRun` 这个旋钮——`reclaim` 带它是合法形状，收侧只认 `confirm`）+
   `the foreign badge and the withdraw action gate on server truth, not on guesswork`（撤的成功判据
   只许是 `d.dry_run === false`，`confirm && d.dry_run === false` 那种「读我刚才点了哪颗键」的合取被
   反锁掉；`deleted = 0` 那一格说的是「这个包在本库里已经没有行了」，不给一颗「确认删除 0 条」的键）。
3. **`title_conflicts` 是 advisory 读数——闭**：只对本轮真正会新建的条目判，重吃同一个包时恒空，
   `conflicts_checked` 是**包内条数**而不是「比过的条数」。屏上因此三件一起给：撞名清单（非空才出声）、
   清单本身、下面那行「包内 N 条」当分母。锁：`the receive preview keeps the advisory reading honest`
   ——它扫的是**非注释行**：警告允许写在注释里，绝对断言不允许出现在屏上（「现在没有冲突 / 没有撞名 /
   无冲突」这类文案当场转红），同一条锁还钉着「确认那颗键在拿到预览之前 `disabled`」。

**车道 D 留下的三格已知代价**（都是刻意的，不是漏做）：

1. **搜索态三件都做不了（筛选、徽标、撤销）**：`pack_id` 只从 `/api/dsh-mneme/list` 的面板行下发
   （`src/api.js` 那个 items 的 map），而 `/search` 的 DTO 里 `source` 是**召回渠道**
   （`keyword`/`vector`/`bm25`，`src/service.js:939`）而不是行来源——`searchMemories` 给每条候选
   盖上渠道时把 provenance 覆盖了，所以「这条是不是外来的」在那张 DTO 上读不出来。于是：外来 chip
   有搜索词时不许被开启（已开着时永远许关掉，不把她锁在一个解不开的状态里；带一行看得见的说明，
   不只挂在 `title` 上，不假装筛过）、`from-<别名>` 徽标在搜索态不出现、抽屉里也没有「收起这个包」。
   绕法一律是**清掉搜索词回浏览态**。这两个事实——DTO 不带包号、`source` 是渠道名——由
   `test/share-foreign-rows.test.js` 的第三条用例 `search DTO carries neither the pack id nor the
   row's own source` 钉着。要真修的是召回侧那个字段的命名（它同时让搜索结果那张卡片脚上显示
   `keyword` 而不是行来源——既存症状，本车道不动），那一刀牵动 `recall_runs` 的回执形状，
   届时长出来的是第四个车道。
2. **面板只做 `type` 分批，不做 `tag` 分批**：`tag` 是自由文本，错一个字母就静默发 0 条，而 413 的
   提示语是服务端那一份（`SHARE_ERROR_HINT`），写着 `--type`/`--tag` 的 CLI 口吻。面板不改写它
   （同一份错误文案只许有一份），所以那句话在面板里读起来像在指命令行。
3. **413 那句提示只有中文一份**：`src/share-http.js` 的 `SHARE_ERROR_HINT` 是单语字面量，面板原样
   转述（`lib/client.js` 只补自己那句字节数），所以**英文界面里那一行读起来是中文**。这一格与第二条
   是同一个决定的两面：错误措辞只许有一个主人，改成就地按语言给提示，等于在面板里再养一份文案——
   日后服务端改措辞，屏上会出现两句都对或两句都不对的话。宁可一句跨语言的提示，也不要两份口径。

**CLI 人类可读答复里有两格到不了人眼前**：`bin/cli.mjs` 的 `failHttp` 只转 `hint` / `bytes` /
`limitBytes`，所以 400 的 `detail`（闸门指到哪一格）与数据面 413 的 `limit`（入站 body 上限）
在默认档看不见，**`--json` 看得见**（那一档 `failHttp` 把整个响应体原样交给 stderr）。这一条
C10 刻意不改 `failHttp`：同一份错误文案只许有一份，补字段等于给命令侧再开一处判断分支。
要看逐格就用 `dsh-mneme receive <包> --json`。

**活体端到端还没跑过**：当前在跑的宿主实例加载的是比车道 A/B/C 更早的构建，`/share`、
`/receive`、`/receive/undo` 在它那里都回 404。要等插件 relink 之后，在一次真实往返上把
`share --dry-run` → 发文件给第二台机器 → `receive` → `undo-share` 走一遍；在此之前本文所有
数字都是**进程内**读数，网络与鉴权那一档没有实测数。车道 D 的面板三件在这条账上**没有豁免**：
它们发的是同一批宿主内路由（`/api/dsh-mneme/share`、`/api/dsh-mneme/receive`、
`/api/dsh-mneme/receive/undo`），relink 之前按下去同样不通——本车道的锁全是**静态源锁**
（`test/client-share.test.js` / `test/share-foreign-rows.test.js` 读的是 `lib/client.js` 的源码形状），
没有任何一条是在浏览器里看过。

### 三条已申报并被接受的缺口（车道 C 收口时判为「不是缺陷，是边界」）

写在这里而不是只写在台账里，是因为它们都会误导下一位读代码的人：

1. **`service.transaction()` 不可重入，而且这件事没有钉子**。它无条件 `BEGIN`
   （`src/service.js:1194-1195`），嵌套实测报 `cannot start a transaction within a transaction`，
   且内层那一次 `ROLLBACK` 会把外层事务一起滚掉（C3 修复轮的探针 P1b/P1c）。收侧「中途撞
   UNIQUE → 整笔回滚、已入队的嵌入全部丢弃」那把锁**正是靠这个形状**才测得出来的：换成可组合的
   transaction，那条锁会静默失去意义。所以现在是**承重墙没有锁**。谁要把 transaction 改成可嵌套，
   必须同一刀补回滚侧的锁（`store.runAtomically` 那一侧认 `db.isTransaction` 早退、但不认识嵌入队列，
   不是替代品）。
2. **并发宿主下 `undo-share` 的 `deleted` 与 `sample` 可以分家**：`sample` 是清点快照的前 20 条，
   `deleted` 是事务内重读的实删数（上面「整包退出」那段写了口径）。两个宿主共用同一 `memoryDir`
   时别人补写/删掉的行会落进这一格而不落进那一格，这是**读数分岔**不是漏删；今天没有任何锁跑过
   「并发下 undo」这个形状（撞 UNIQUE 那两条锁测的是导入侧）。
3. **`reconcileHumanEdits` 的三方合并那一支对外来行没走过行为锁**（详见上面「一条如实的覆盖缺口」
   那段）：两条身份锁喂的都是干净的 `else` 分支，「那支也不动身份」今天靠推理 + `store.update`
   的 SET 白名单结构，不靠锁。

**一条待办必须和第 1 条同刀**：把闸门的 `index` 透传出来那一切（`ShareImportError` +
`receiveReply` + `failHttp` + 两处 deepEqual 锁 + 本文档，台账 C10 裁定「必须同一刀动，否则锁与
文档会打架」）落地的同时，要顺手给 `transaction()` 的**不可重入**补一条锁——它动的正是导入侧
那条错误传播路径与同一组回滚锁，是最容易顺手把 transaction 改成可组合的那一刻。
