来源:
notes/documents/sensitivity_字段梳理与移除方案.mdsensitivity 字段梳理:作用、出处、含义与移除方案
一句话结论:
sensitivity不是内容敏感度分级,而是一个只表达「本地留档 / 不外发」两态的目的地开关;它真正生效的地方只有 6 处;而库里这个字段在两张表里其实都是常量(事件表 298/298 是internal,笔记表 129 篇里 127 篇是private)——它拦下过的真实数据是零。删掉它是零行为变化,把它换成「每渠道一个开关」才是它本来想做的事。
一、字段是什么
取值只有三个(scripts/event_store.py:24):
SENSITIVITY_LEVELS = frozenset({"private", "internal", "public"})
代码里没有任何一处读这三个值去做「内容风险」「加密」「访问控制」的判断。它被读取的唯一形态是:
if event.sensitivity == "private":
...跳过 / 隐藏...
也就是说 public 和 internal 在代码中完全等价——唯一把两者并列取合的地方写的是 entry.sensitivity === 'internal' || entry.sensitivity === 'public'(web/scripts/lib/daily-projection.mjs:64)。整个字段的实际信息量只剩一个二值问题:「这条算不算 private」。
二、定义与默认值从哪来
| 事实 | 位置 |
|---|---|
| 枚举常量 | scripts/event_store.py:24 |
events.sensitivity |
NOT NULL + CHECK IN ('private','internal','public'),无默认值(scripts/event_store.py:183) |
event_revisions.sensitivity |
同名字段,快照列(scripts/event_store.py:212) |
notes.sensitivity |
同 check,NOT NULL 无默认(scripts/event_store.py:238) |
note_revisions.sensitivity |
NOT NULL DEFAULT 'private'(scripts/event_store.py:262) |
| 历史增量迁移(老库补列) | scripts/event_store.py:361 |
各写入路径实际写什么值:
| 写入路径 | 写入值 | 位置 |
|---|---|---|
capture_event.py(Codex / MCP 捕获) |
internal if publish_enabled else private |
scripts/capture_event.py:57 |
migrate_daily_log_to_sqlite.py(老 Markdown 迁移) |
一律 internal |
scripts/migrate_daily_log_to_sqlite.py:83、:108 |
import_feishu_daily_log.py(飞书表格回灌) |
一律 internal |
scripts/import_feishu_daily_log.py:82、:97 |
note_cli.py create |
默认 private |
scripts/note_cli.py:152、:219 |
note_cli.py sync |
不传则沿用现值(不会被重置) | scripts/note_cli.py:160、:256 |
cc-connect /note(Go worker) |
完全不传该字段 → 落到 capture_event 的推导 |
personal_assistant/plugins/personal-assistant-sync/cmd/personal-assistant-sync/main.go:219-232 |
| 从飞书 Base 反向编辑 | 读回 Base 的「敏感级别」列并校验 | scripts/note_inbound_reconciliation.py:57-61 |
设计初衷写在 .scratch/personal-assistant-event-model/spec.md:46:
As a personal assistant user, I want sensitivity levels, so that private content is not sent to an inappropriate destination.
注意原文是「不合适的目的地」,不是后来文档里被简化成的「一律不上网」——这个措辞漂移正是今天语义混乱的起点(见第五节)。
三、它长在哪里(完整清单)
按载体分,一共 7 类:
1. 本地 SQLite · 4 个列:events、event_revisions、notes、note_revisions(data/daily_log.sqlite3)。
2. Python 代码 · 11 个文件
scripts/event_store.py(定义、校验、读写)、scripts/capture_event.py、scripts/daily_log_view.py、scripts/sync_to_feishu_base.py、scripts/note_base_projection.py、scripts/note_inbound_reconciliation.py、scripts/note_cli.py、scripts/note_docs_projection.py、scripts/calendar_entries.py、scripts/migrate_daily_log_to_sqlite.py、scripts/import_feishu_daily_log.py。
3. Go worker · 1 个文件:/note 不传 sensitivity,由 capture_event 按 publish_enabled 推导(main.go:228-230 把 calendar_enabled/publish_enabled/push_repos 写死为 true)。
4. 飞书 Base · 一个列名叫「敏感级别」
两个表都有:日常记录表(scripts/sync_to_feishu_base.py:573、:656)与「内容资产 V2」笔记表(scripts/note_base_projection.py:356)。反向编辑也认这个列名(scripts/note_inbound_reconciliation.py:13)。
5. Web / Neon · 一条完整链路
web/backend/src/lib/schema.ts:75、web/drizzle/0000_init.sql:11、web/scripts/lib/local-daily-events.mjs:35,48,81,93、web/scripts/project-calendar-entries.mjs:116,127,148,191、web/scripts/lib/daily-projection.mjs:64。
6. 文档 · 24 个文件(ADR / spec / 技能参考)
重点三处:docs/adr/0006-note-registry-and-projection-ledger.md:26、docs/adr/0010-calendar-entries-shared-definition.md:10,55-57、web/docs/adr/0011-daily-events-served-from-a-database-projection.md:11,31,42,外加 .scratch/ 下 8 个 spec/issue 与 personal_assistant/skills/personal-assistant-workflow/references/note-cli.md:73。
7. 测试 · 14 个文件、53 处(tests/ 13 个 + web/tests/daily-projection.test.mjs)。
只读历史、不在改动范围:exports/flomo/、migration-artifacts/(含 base-consolidation-20260830/old-daily-log-v2.json)。
四、真正生效的过滤点:6 处
| # | 位置 | 行为 |
|---|---|---|
| 1 | scripts/daily_log_view.py:75 |
private 事件在 logs/daily_log.md 里渲染成 [敏感记录已隐藏] |
| 2 | scripts/sync_to_feishu_base.py:559 |
事件 Base 的行构建时 continue |
| 3 | scripts/sync_to_feishu_base.py:702 |
V2 投影跳过,并写一条 skipped 台账(error="private event") |
| 4 | scripts/sync_to_feishu_base.py:806 |
旧版投影同上 |
| 5 | scripts/note_base_projection.py:371 |
private 事件不进「内容资产 V2」 |
| 6 | web/scripts/lib/daily-projection.mjs:64 |
网页日线;笔记线显式不过滤(同文件 :54-57 的注释写明) |
不看这个字段的地方(比看它的多):
- 飞书日历:闸门是
calendar_enabled。取合规则只有一份,写在scripts/calendar_entries.py的ELIGIBLE_DAILY_SQL(calendar_enabled=1且有发生时间)与ELIGIBLE_NOTE_SQL(status='active'且有落点,scripts/calendar_entries.py:57-62),两句 SQL 都没有 sensitivity 条件。sync_daily_log_to_feishu_calendar.py与note_calendar_projection.py全文不含sensitivity。 - 飞书文档(笔记正文):
note_docs_projection.py只看status。 - 笔记 Base:
note_base_projection._note_rows()不过滤,只把值当一列输出(scripts/note_base_projection.py:333-357)。 - 博客:只认
publish_enabled。 - 网页笔记面:
web/scripts/generate-notes.mjs全量遍历notes/,零过滤。 - cc-connect
/note:三个渠道写死true(main.go:228-230)。
也就是说:库里那 126 篇
private笔记,一直在往飞书文档和 Base 里写。字段值本身没有挡住任何东西。
五、它的意义:设计意图 vs 现实
意图:一个「这条内容能不能离开本机」的总闸门(personal-assistant-event-model/spec.md:46)。
现实:它是一个目的地开关的半成品,且只覆盖了 9 个目的地中的 2 个。语义坏掉有三个实证:
public与internal零行为差异。三个取值实际只提供两个状态,其中一个还没有任何消费方。- 名字与实际用途不符。名字读作「敏感度」,是内容分级;实际是「目的地可见性」,是路由开关。于是它被当成内容分级来填(
note_cli.py:152默认 private、/note路径全 true),而没人知道该按什么标准填。 - 与既有开关语义重叠。
calendar_enabled才决定日历、publish_enabled才决定博客,但在文档叙事里却变成「private 一律不上网」,两者冲突。
错误认知(「private 一律不上网」)的源头只有两处,且都是网页日程这一条线的局部口径:
web/docs/adr/0011-daily-events-served-from-a-database-projection.md:11:「只投影status = active且sensitivity ∈ {internal, public}的事件;private不上网。」.scratch/web-multimodule-shell/spec.md:135:同一句话的转述。
这两处的语境都只是「网页日线的不变量」,不是全仓库契约。docs/adr/0010:55-57 里其实明确写了相反的事实(笔记线不做 sensitivity 过滤,真库里 109 篇笔记有 106 篇是 private),web/scripts/lib/daily-projection.mjs:54-57 的注释也复述了这一点。
为什么它该被删而不是被修:修的方向只有「加第四个值」或「再加一个字段」。目的地已经 9 个,一个三值枚举在结构上不可能表达「这条去 A 不去 B」;而一旦开始加值,就是承认它其实是渠道开关——那不如直接做成渠道开关,一个渠道一个布尔列,语义自解释、可独立演进。
六、库里实测(2026-09-26)
events: internal 298 | private 0 | public 0 (共 298)
notes: private 126 | internal 1 | public 1 (共 128)
推论:
- 事件线上 private 分支从未命中过。第 4 节里 #1~#4 四处过滤、
daily-projection.mjs那处,全是从未执行过的死代码。这也意味着迁移是零行为变化,可证。 - 笔记线 126/128 标了 private,但笔记线的目的地(Docs、Base、博客、网页笔记面)都不读它,所以这些标记只在 Base 里当一个展示列存在。
- 全库唯一的
sensitivity语义差异只可能出现在「将来新写一条 private 事件」时,而没有任何现存数据依赖它。补充:这个字段在两张表里都是常量
上面那组数字容易被读成「有人刻意标了 private」。真相相反:两张表里它都是常量,只是常量值不同。 「库里有没有 private」这个问法本身会骗人——得先问是哪张表。
事件表按来源拆开,没有任何一个来源产出过别的值:
| source | sensitivity | 条数 | 谁写的 | 为什么是这个值 |
|---|---|---|---|---|
legacy_markdown |
internal | 165 | migrate_daily_log_to_sqlite.py |
硬编码 internal(:83、:108) |
feishu |
internal | 91 | import_feishu_daily_log.py |
硬编码 internal(:82、:97) |
feishu_note |
internal | 38 | cc-connect /note Go worker |
publish_enabled 钉死为 true → capture_event.py:57 推导出 internal |
mcp_capture |
internal | 4 | capture_event.py |
同上,调用时传了 publish_enabled=true |
capture_event.py:57 是全仓库唯一能算出 private 的算式("internal" if publish_enabled else "private"),
但它的两个调用方(Go worker 与 CLI)在所有真实路径上都不会传 publish_enabled=false。
所以 private 事件不是「没人想写」,而是这条路径根本到不了。
笔记表则反过来:
register_note的默认值是private(scripts/event_store.py:696、:712)。- 五类调用路径里,
note_migration.py:372,382与event_store.py:900(归档回写)完全不传该参数 → 落默认private;note_docs_projection.py:102传的是note.sensitivity(原样回写);只有note_cli.py:213会写一个用户可能显式指定的值,而它的默认同样是private(note_cli.py:152)。 - 结果:129 篇里 127 篇 private。仅有的两篇例外都是手工/测试入口留下的——
note_cc_connect_feishu(public)与note_15834bd3146341e2(internal)。
一边永远是 internal、一边永远是 private:这就是「这个字段没有承载任何信息」最直接的证据,
比「0 条 private 事件」更彻底,也说明删掉它不会有任何语义损失。
顺带解释「为什么感觉哪儿都看不到 private」:这个值在任何人类会打开的界面里都不出现——
note_cli.py status 不打印它(对比 note_cli.py:105 的输出字段),
笔记文件的 front matter 不含它(assemble_markdown,scripts/note_cli.py:555-568),
生成的 logs/daily_log.md 没有这一列,网页也不展示它。
唯一能直接看到它的地方是手查 SQLite,或飞书 Base 的「敏感级别」列。
七、删掉有哪些影响
逐处对照(「现在」→「删掉后」):
| 位置 | 现在 | 删掉后 | 净变化 |
|---|---|---|---|
daily_log_view.py:75 |
private 渲染成 [敏感记录已隐藏] |
永远渲染全文 | 0 条受影响;规则少一条分支 |
sync_to_feishu_base.py:559/702/806 |
private 跳过 + skipped 台账 |
按渠道开关决定 | 0 条受影响 |
note_base_projection.py:371 |
private 事件不进 Base | 同上 | 0 条受影响 |
daily-projection.mjs:64 |
日线 internal/public 才投 |
日线按 calendar_enabled 筛 |
0 条受影响 |
| 飞书 Base「敏感级别」列 | 展示一个字符串 | 删列,或原地改成渠道列 | 表结构变更 |
note_inbound_reconciliation.py:57-61 |
从 Base 读回并校验 | 去掉这条回写 | 必须同步改,否则远端编辑会抛 invalid note sensitivity(tests/test_note_inbound_reconciliation.py:113 就是这条断言) |
Neon calendar_entries.sensitivity |
NOT NULL |
迁移删列 | 需 drizzle 迁移 + schema-drift 校验 |
代码 Event.sensitivity / Note.sensitivity |
dataclass 字段、校验、CLI 参数 | 全删 | 11 个 Python 文件 + Go worker |
| 测试 14 文件 53 处 | 断言 | 改写 | 纯机械 |
真正的代价只有一条:失去「一句话把所有外部目的地挡掉」的能力。而这正好就是要用每渠道开关替代的东西——用 feishu_base_enabled=false / calendar_enabled=false / publish_enabled=false 表达,比 private 更精确,也不会再让人误以为它管全局。
迁移风险点(都已定位):
- SQLite
DROP COLUMN本机实测(3.46.1)可以删除带 CHECK 约束的列,但如果该列被视图引用会失败。calendar_entries视图同时引用了e.sensitivity与n.sensitivity(scripts/calendar_entries.py:79,100),所以必须DROP VIEW calendar_entries→ 删列 → 重建视图。 - 删除顺序必须 expand → contract:先加新开关并回填,跑一版观察,再删旧字段。否则中间态无法对照。
- 飞书 Base 的列是远端资源,删列/改列要在代码改动前手工确认(代码里
available_fields是动态读取的,敏感级别不在config/feishu_base.json里)。 note_revisions/event_revisions是历史快照表,删列意味着放弃「回到旧快照能看出当时的可见性」;由于从未有 private 事件、且笔记线的值也从未生效,这个信息量为零。
八、怎么去掉:用渠道开关取代敏感级别
目标模型(一个渠道一个布尔,语义自解释):
| 开关 | 管辖 | 现状 |
|---|---|---|
feishu_base_enabled(新增) |
飞书 Base:事件表 + 内容资产 V2 | 目前由 sensitivity != 'private' 兼任 |
calendar_enabled(已有) |
飞书日历 + 网页日程 | 不动 |
publish_enabled(已有) |
博客 | 不动 |
| (待定) | 飞书文档 | 方案一:不加开关,跟随 status='active';方案二:加 feishu_docs_enabled |
外加一条规则:本地日视图不再有任何闸门,永远渲染全文(它是本机文件,脱敏只会让人误判自己的记录)。
四步走(每步独立可验证):
- 冻结口径(文档先行)
- 新增
.scratch/sensitivity-removal/spec.md:列全 5 个过滤点与目标规则。 - 写一篇新 ADR(「渠道开关取代敏感级别」),并 supersede 三处旧表述:
docs/adr/0006:26(把它列为快照元数据)、web/docs/adr/0011:11、.scratch/web-multimodule-shell/spec.md:135。 - 拆 ticket(按
docs/agents/issue-tracker.md,每张一个文件,Status: ready-for-agent)。
- 新增
- Expand:加新开关,旧字段并存
events/note_revisions加feishu_base_enabled INTEGER NOT NULL DEFAULT 1(scripts/event_store.py的建表 +additions迁移两处都要加)。- 回填全部为
1:事件线本来就没有 private 被挡过;笔记线 Base 从不过滤,所以 1 是等价的。 - 把第 4 节的 5 个过滤点从
sensitivity != 'private'改成feishu_base_enabled = 1(daily_log_view.py:75这条直接删掉分支)。 note_cli.py加--feishu-base/--no-feishu-base,capture_event.py的推导改为写feishu_base_enabled,Go worker 透传。- 跑
python3 -m unittest discover -s tests与cd web && npm test。
- 观察一版
- 完整跑一次
python3 scripts/sync_daily_log_outputs.py,再等一个 5 分钟 web 投影周期。 - 对照:Base 行数、网页
calendar_entries条数、本地logs/daily_log.md行数,应与改动前完全一致(可复用scripts/calendar_parity_report.py的对账口径)。 - 任一数字变化即回滚。
- 完整跑一次
- Contract:删掉 sensitivity
- SQLite:
DROP VIEW calendar_entries→ALTER TABLE events DROP COLUMN sensitivity、event_revisions、notes、note_revisions→ 重建视图(scripts/event_store.py的_ensure_*路径)。 - 代码:删
SENSITIVITY_LEVELS、_validate_content里的校验、Event/Note的 dataclass 字段、note_cli.py的--sensitivity、calendar_entries.py的ENTRY_COLUMNS列、note_docs_projection.py:109/note_inbound_reconciliation.py:57的透传。 - Web:
npm run db:generate出 0001 迁移(NeonALTER TABLE calendar_entries DROP COLUMN sensitivity)+ 改web/backend/src/lib/schema.ts:75+web/scripts/lib/local-daily-events.mjs+project-calendar-entries.mjs,npm run db:check必须过。 - 飞书 Base:删「敏感级别」列;
note_inbound_reconciliation.py去掉读回与校验,NOTE_OPERATIONAL_FIELDS(scripts/note_base_projection.py:24)同步去掉。 - 文档:
note-cli.md:73去掉--sensitivity;.scratch/旧 spec 不动(历史),只在 ADR 里记一句 supersede。 - 测试:14 个文件 53 处断言一起改;
tests/test_daily_log_view.py:118与tests/test_sqlite_projections.py:305这两个「private 被隐藏」的测试应改成「渠道开关关闭才被隐藏」。 - 只读历史(
exports/、migration-artifacts/)不动。
- SQLite:
九、三个决策(2026-09-26 已拍板)
- 飞书文档用独立开关(
feishu_docs_enabled),不再跟随status='active'。status退回纯生命周期语义,withdrawn仍对所有渠道生效。 - Base 的列照 SQLite 的形状做:不做组合值列,一个渠道一个独立布尔列。 删「敏感级别」,新增「飞书文档」与「Base 同步」;「日历同步」「博客发布」保留。
- flomo 短记三个渠道全开(
Base=true / 日历=true / 博客=true), 推翻上一版「日历与博客都 false」的建议。已在 flomo 方案里记录两个后果: 515 条带时间的短记会一次性进飞书日历;且博客当时根本没有闸门。
落成:
- 决策记录:
docs/adr/0011-channel-switches-replace-sensitivity.md(supersededocs/adr/0006:26、web/docs/adr/0011:11、.scratch/web-multimodule-shell/spec.md:135) - 实施方案:
.scratch/channel-switches/spec.md - 实施 ticket:
.scratch/channel-switches/issues/01~08
十、核对过程中新发现的一件事:博客渠道根本没有闸门
写这份文档时我又核了一遍 publish_enabled 的消费方,结果是:在两条博客管道上它都不是闸门。
scripts/publish_private_blog_data.js:204全量读logs/daily_log.md加密后写入andywu1998.github.io与astro_demo两个仓库,没有任何过滤。andywu1998.github.io/scripts/import_personal_assistant_notes.py:76用rglob("*.md")全量导入笔记, 没有任何过滤。
后果:129 篇笔记里 128 篇 publish_enabled=0、298 条事件里 246 条是 0,共 374 条,但它们全部在博客上。
所以「给博客接上闸门」这一步如果照字段现值回填,等于一次性静默撤回四百多条内容。
正确的做法是按「当前实际到达了什么」回填为 1,之后的关闭才是真实意图——这条已写成
01-schema-and-backfill.md 里的硬约束,并进了 ADR 第 6 条。