Skip to content

Commit 09303ec

Browse files
tzhouamclaude
andcommitted
Catch, block and revert knowledge changes that bypassed the gate (design v8)
Without GitHub-side enforcement, a person can still merge or push a knowledge change the gate never saw. The service now catches it on the next tick, keeps it from being served, and opens a revert. - audit (provenance) runs every scheduler tick, no longer daily. Each first-parent commit on main that touches knowledge/ is classified as: - trusted: a recorded merge SHA, or a two-parent merge of a recorded head carrying only that head's change; - pending: a merge of a head whose merge item still waits for its receipt; looked at again next tick; - disposed: an exact revert of it was merged; - unknown: anything else. An inexact revert merge is itself unknown. - For an unknown commit the service: - pauses the repositories it touched (everything outside a repository scope); - records unknown:<sha> and alerts people; - sends its content back to intake as a new candidate (event source "unrecorded"); - opens a revert PR that restores the governed paths to their first-parent content. The publisher builds it and it is NEVER merged automatically; open_revert_pr is always executable, since it is issued because the repository was paused. A path changed again since (the revert would conflict), or a non-governed path, goes to people instead. Without the lease the revert is recorded as revert_pending and issued by the next pass that holds it. - A merged revert disposes of the unknown commit only if it is exact: the PR merged the revert commit the publisher built, and on governed paths the merge changed exactly the reverted paths back to their pre-images. disposed:<sha> records the pair. A closed revert leaves it undisposed and alerts people. - Exactness compares tree entries (mode and content). A revert made by hand is recognised the same way. The set is every undisposed unknown commit sharing a knowledge path with the new commit, closed transitively (an earlier partial revert is itself one of them). The new commit disposes of the set when it changes only the set's paths and, after it, EVERY path the set touched, governed or not, holds its tree entry from before the earliest commit (ordered by ancestry): together they changed nothing. The unknown record keeps the complete path list and the entries of all its knowledge paths, uncapped, so conflict detection and exactness cover every path. A disposed commit is trusted only while its disposing revert is part of the history being checked, so main rewound past the revert brings it back. A revert PR or intake candidate is created only when EVERY repository scope the commit touches may publish; a commit touching a private upstream goes to people. A merged revert PR is settled only after fetching, so a fresh merge is never mistaken for another unknown change. - activate() itself, and so `kb activate`, refuses any SHA with an unknown or pending knowledge commit between the active snapshot and it (the first activation has no baseline). The scheduler checks the same before activating and records activation_blocked, and nothing is activated while an unknown commit is undisposed (the G5 exception in the design). - The intake event for an unknown commit carries its diff, not just its message and file names. - The signed control record now carries provenance: the active snapshot and the trusted list (merge SHAs plus disposal pairs). Before merging, the publisher checks that every governed knowledge commit on main since the active snapshot is trusted or one of its own merges; otherwise it refuses (transient: the service pauses and reverts). Not implemented yet: kb accept-unknown (accepting the unknown content instead of reverting). For now a merged exact revert, the service's or one made by hand, lifts the block. Tests: an unknown change is paused, blocks activation, is reverted and sent back to intake, and is handled once; an exact revert disposes of it and both commits become trusted; a revert merged with other changes disposes of nothing and is caught itself; a conflicting revert goes to people; nothing is activated while an unknown change is open, and activation resumes after the revert; the publisher refuses to merge on top of an unknown change; our own merge waiting for its receipt is not unknown; a revert merged with a mode change is not exact; a hand-made revert disposes of what it undoes; activate() refuses a commit that passed no gate; the intake event carries the diff; a revert is settled without the test pre-fetching; a revert must restore non-governed paths too (a partial revert keeps the block, and a second completes it); a hand revert of an AGENTS-only change clears it; a later edit to the 54th of 55 paths makes the revert conflict; rewinding main past a revert brings the unknown change back; a commit touching a private scope is never reverted in public. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cd48103 commit 09303ec

12 files changed

Lines changed: 738 additions & 87 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,21 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
241241
发布器记住每个 PR 已发布的最新修订,较旧的项不会覆盖较新的评论;服务在账本(全局 `findings:<PR>`,每个 PR 一条,与其涉及的仓库
242242
范围无关)记下最新一条直至发布器确认送达,过期仍未送达的以相同内容与原修订号重新签发(与 PR 当前状态无关),因此旧发现永远
243243
不会覆盖新的;发布器的修订记录原子写入,损坏时视为无记录。
244+
245+
## 2026-09-29 v8:来历检查与撤回
246+
`audit`(原每日审计)改为每个调度 tick 运行,沿 main 的第一父链检查每个触碰 `knowledge/` 的提交:可信(账本记录的合并 SHA,或只携带
247+
已记录 head 知识变化的双亲合并)、待回执(合并项仍在等待回执的 head,下一 tick 再看)、已处置(精确撤回已合并)、未知。
248+
- 未知提交:暂停其涉及的仓库(触及仓库范围之外则全部暂停),记录 `unknown:<sha>`,告警,并把其内容作为新候选事件(`unrecorded`)
249+
送回 intake;对受治理路径生成撤回 PR(恢复为第一父内容,由发布器构建、永不自动合并;`open_revert_pr` 属于总可执行项)。
250+
路径之后又被修改(会冲突)或涉及非受治理路径时不生成撤回、转人工。无租约时记为 `revert_pending`,由下一次持有租约的检查补发。
251+
- 撤回 PR 由人合并:只有 PR 合并的正是发布器构建的撤回提交、且合并提交在受治理路径上的变化恰为撤回路径并恢复到前像,才记录
252+
`disposed:<sha>` 处置配对;不精确的撤回合并本身也是未知提交。关闭撤回 PR 则保持未处置并告警。
253+
- 存在未处置的未知提交时不激活任何快照(G5 例外);可信列表(合并 SHA 与处置配对)写入签名控制记录的 `provenance`,发布器合并前
254+
核对自当前激活快照以来 main 上每个受治理知识提交都可信(或是其自己的合并),否则不合并(瞬时,服务随即暂停并撤回)。
255+
- 精确性按树条目(模式 + 内容)比较;手工撤回同样被识别:与某提交共享知识路径的未处置未知提交(传递闭包,先前的部分撤回也是其中之一)
256+
构成一组;若该提交只改动这组涉及的路径,且之后这组触及的每条知识路径(不限受治理路径)都恢复到最早一个提交之前的树条目
257+
(按祖先关系排序),则整组被处置。未知记录保存完整路径列表与全部知识路径的前像,冲突检查覆盖所有路径。已处置的未知提交只在其处置撤回属于被检查的历史时
258+
才可信(main 回退到撤回之前会使其重新成为未知);只要涉及的任一仓库范围为私有上游,就不开公开撤回 PR、不生成候选事件,只转人工。结算撤回前先拉取,避免把新合并的撤回误判为未知。
259+
- 激活本身(`activate()`,含 `kb activate`)核对从当前激活快照到目标 SHA 的每个知识提交都可信(首次激活除外),否则
260+
`ActivationError`;调度器在激活前做同样的检查并只记录 `activation_blocked`。入 intake 的候选事件带上该提交的 diff。
261+
- 尚未实现:`kb accept-unknown`(接受未知提交内容而不撤回),目前通过合并精确撤回(服务开出的或手工的)解除。

‎doc/features/knowledge-service.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ infermatrix-copilot kb rollback --to <sha> # 回指到较早的快照(下一
6565
`main` 与 PR head 的合并树上运行本地门禁,通过才 `gh pr merge`(每仓库同时至多 1 个)→ 合并后激活新快照。
6666
本地门禁拒绝的变更从不合并:上下文变化则在当前 main 上重建;服务自己的变更被质量门或本地门禁拒绝时,由生成器按
6767
理由精炼后整体复检,至多 2 轮,仍失败则留在人工队列(不合并)。他人的知识 PR 每天检查一次:结果写在该 PR 上唯一一条
68-
“知识质量门”评论里(原地更新),未通过则不合并,作者修改后次日复检。发版巡检在上游新 release 后运行
68+
“知识质量门”评论里(原地更新),未通过则不合并,作者修改后次日复检。
69+
有人绕过门禁直接合并/推送知识变更时,服务立即暂停该仓库、停止激活新快照、告警,并开出撤回 PR(不自动合并);
70+
合并精确的撤回 PR 并 `kb resume` 后恢复,被撤回的内容会作为新候选重新过门禁。发版巡检在上游新 release 后运行
6971
T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。
7072

7173
评审服务读取知识:将 `KNOWLEDGE_ROOT` 指向 `$KB_STATE_DIR/active`。

‎src/infermatrix_copilot/kb_service/activate.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,20 @@ def switch_active(state_dir: Path, snapshot: Path) -> None:
135135
os.replace(tmp, link)
136136

137137

138-
def activate(rt, sha: str, *, locked: bool = False) -> Path:
139-
"""Build, verify and switch to the snapshot of knowledge at ``sha``."""
138+
def activate(rt, sha: str, *, locked: bool = False, verify_provenance: bool = True) -> Path:
139+
"""Build, verify and switch to the snapshot of knowledge at ``sha``. Every
140+
knowledge commit from the active snapshot up to ``sha`` must have passed
141+
the gate (design v8): nothing that bypassed it is ever served, whoever
142+
asks (the scheduler, or `kb activate`)."""
140143
if not locked:
141144
with activation_lock(rt.state_dir):
142-
return activate(rt, sha, locked=True)
145+
return activate(rt, sha, locked=True, verify_provenance=verify_provenance)
146+
if verify_provenance:
147+
from .audit import provenance_problems
148+
149+
problems = provenance_problems(rt, sha)
150+
if problems:
151+
raise ActivationError("provenance: " + "; ".join(problems[:5]))
143152
files = rt.knowledge.knowledge_files(sha)
144153
extra = rt.knowledge.top_level_knowledge(sha)
145154
snapshot = build_snapshot(rt.state_dir, sha, files, extra=extra)

0 commit comments

Comments
 (0)