# P4.2 来源只读扫描与变化入账验收 ## 范围 本切片将 PP 本地历史快照接入普通 Worker 的持久扫描链路: ```text 来源管理手工触发 → 原子创建 source_scan + source.scan.pp Job → 普通 Worker 只读 ledger/manifest/制品元数据 → 与 613 条治理 Source Item 对账 → 原子保存聚合、变化记录与连接健康 → API/Web 展示扫描历史和变化结果 ``` 本切片不上传、移动、重命名或删除 PP 文件,不调用 PP 网络写接口,不删除治理对象或 WeKnora Knowledge。`missing` 只记录为 `unavailable`,54 个 `over_limit` 项继续阻断,生产 Release Worker 保持关闭。 ## 安全配置与装配 - `PP_SCAN_ENABLED` 默认 `false`;仅显式启用且 allowed root、ledger、manifest 三个路径完整时注册 `source.scan.pp` Handler。 - allowed root 必须是绝对规范路径;ledger 和 manifest 必须位于其内。 - Scanner 拒绝越界、软链接、非普通文件、缺少 `local_path`、打开期间文件身份变化和超过字节上限的元数据文件。 - `over_limit` 记录允许其根目录内的制品不存在,因为它从未被下载;若制品存在仍必须通过普通文件和非软链接校验。 - Compose 只给普通 Worker 挂载 PP 数据目录,挂载模式为只读;API、Web、Release Worker均没有该挂载。 - 实际容器探针:ledger/manifest 可读,向 manifest 追加写入被只读文件系统拒绝。 - 本地测试 WeKnora Key 仍只挂载给普通 Worker;本切片不调用 WeKnora 上传或删除 API。 ## Migration 5 Migration 5 新增: ```text source_scans source_scan_changes idx_source_scans_one_active idx_source_scans_connection_created idx_source_scan_changes_pending ``` 关键约束: - 每个来源连接最多一个 `pending/running` Scan; - Scan 状态限定为 `pending/running/completed/failed`; - Change 类型限定为 `added/updated/unavailable`; - `snapshot_sha256` 只在内部审计,不进入 API; - down migration 先删除依赖的 changes 表,再删除 scans 表。 一次性 PostgreSQL 执行了: ```text up to 5 → down to 4 → up to 5 result: passed ``` 持久库迁移前逻辑备份: ```text tmp/backups/governance-pre-migration5-20260906T011223+0800.dump bytes=242987 mode=0600 pg_restore catalog=valid ``` 持久库应用后: ```text schema_migrations=1,2,3,4,5 source_items=618 content_objects=535 source_item_versions=618 test_publications=535 ``` 旧业务数据计数未因 Migration 5 改变。 ## API ```text POST /api/v1/source-connections/{id}/scan GET /api/v1/source-connections/{id}/scans?limit=20&offset=0 GET /api/v1/source-connections/{id}/scans/{scan_id} ``` 触发语义: - 成功返回 `202`; - 同一连接已有活动扫描返回 `409`; - 不存在或不可扫描的连接返回 `404`; - 创建 Scan 和 Job 在同一条 PostgreSQL data-modifying CTE 中完成。 安全只读投影允许: ```text Scan: id/state/聚合计数/固定错误码/创建完成时间 Change: id/source_item_id(可空)/change_type/observed_size_bytes(可空)/created_at ``` 明确省略: ```text snapshot_sha256 stable_external_id previous_revision observed_revision allowed root、ledger/manifest/local_path 来源 URL、正文、base_url、secret_ref、Token/API Key/Cookie ``` 跨连接读取 Scan 详情返回 `404`。前端再次使用 allowlist 重建对象,不透传服务端额外字段。 ## 真实 PP 只读扫描 真实输入只读基线: ```text ledger events=1093 manifest/source items=613 existing artifacts=559 historical over_limit without downloaded artifact=54 ``` 第一次真实扫描按安全规则失败: ```text state=failed error_code=pp_snapshot_invalid ``` 根因是初版 Scanner 把 54 个历史 `over_limit` 项未下载的制品也要求为已存在普通文件。修复后规则为:`over_limit` 缺失制品是合法阻断状态,但路径仍必须声明且位于 allowed root 内;其他状态缺失仍 fail-closed。 修复后的多次真实扫描均一致: ```text observed=613 unchanged=559 added=0 updated=0 over_limit=54 missing=0 planned_uploads=0 changes=0 ``` PP ledger、manifest 和来源制品未被修改;没有生成上传计划。 ## 调度、并发与恢复 ### 单活动扫描 Worker 停止时连续触发同一连接: ```text first=202 second=409 active_scans=1 ``` 恢复 Worker 后唯一活动扫描完成。 ### pending 恢复 ```text Worker stopped → POST creates pending Scan/Job → Worker started → Scan completed, Job completed ``` ### 过期 running 租约重领 真实持久队列构造“旧 Worker 已领取但完成前崩溃”的状态: ```text before: scan=running job=running attempt=1 lease_expired=true restart persistent Worker after: scan=completed job=completed attempt=2 result: observed=613 unchanged=559 over_limit=54 ``` Handler 对 `running` Scan 可恢复;若 Scan 已经 `completed` 而 Job 尚未完成,则重领为幂等 no-op,不重复扫描或写 Change。 最终状态: ```text source_scans=6 completed=5 failed_fixed_code=1 active_scans=0 active_source_scan_jobs=0 ``` ## Web 与 Firefox “来源管理”保留左侧导航和顶部标签,并新增: - 来源连接的最近扫描聚合; - “立即扫描”按钮及活动态禁用; - 最近 20 条扫描历史; - 单次扫描变化记录安全详情; - 零变化时明确显示“本次扫描未发现新增、更新或不可用变化”。 Firefox headless 1440×1200 验收: ```text history_rows=6 completed=5 failed=1 detail_selection=passed empty_changes_message=visible safe_projection_leaks=0 root_overflow=false table_overflow=false ``` 截图:`tmp/P42_SOURCE_SCAN_HISTORY_FIREFOX.png` 视觉复核确认:导航、顶部标签、5 条完成/1 条失败、观察数、上限阻断和变化面板均清晰;无横向裁切、遮挡或乱码。 ## 自动化验证 ```text PP配置安全默认和路径校验:passed Worker Handler装配测试:passed File Scanner边界与over_limit回归:passed Store扫描触发/完成/失败/恢复/安全读取:passed HTTP触发/历史/详情/404/409安全投影:passed 前端allowlist解析、请求和页面渲染:passed 一次性PostgreSQL真实扫描与变化读取:passed Migration 5 up/down/up:passed Web tests:31/31 passed TypeScript typecheck:passed Vite production build:passed Compose config:passed Compose全镜像build:passed Go全仓库test:passed Go vet:passed Go cmd build:passed gofmt差异:0 Firefox真实页面:passed ``` ## 运行边界 - 本地治理平台与本地测试 WeKnora 范围内完成; - PP 来源只读,未调用来源写/删 API; - 未删除 PostgreSQL 或 WeKnora 卷; - 未连接或修改生产环境; - `RELEASE_WORKER_ENABLED=false`; - 54 个 `over_limit` 项继续阻断; - 未 commit、未 push。