diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index e7d16e44..8fcbed3b 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -241,3 +241,21 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝 发布器记住每个 PR 已发布的最新修订,较旧的项不会覆盖较新的评论;服务在账本(全局 `findings:`,每个 PR 一条,与其涉及的仓库 范围无关)记下最新一条直至发布器确认送达,过期仍未送达的以相同内容与原修订号重新签发(与 PR 当前状态无关),因此旧发现永远 不会覆盖新的;发布器的修订记录原子写入,损坏时视为无记录。 + +## 2026-09-29 v8:来历检查与撤回 +`audit`(原每日审计)改为每个调度 tick 运行,沿 main 的第一父链检查每个触碰 `knowledge/` 的提交:可信(账本记录的合并 SHA,或只携带 +已记录 head 知识变化的双亲合并)、待回执(合并项仍在等待回执的 head,下一 tick 再看)、已处置(精确撤回已合并)、未知。 +- 未知提交:暂停其涉及的仓库(触及仓库范围之外则全部暂停),记录 `unknown:`,告警,并把其内容作为新候选事件(`unrecorded`) + 送回 intake;对受治理路径生成撤回 PR(恢复为第一父内容,由发布器构建、永不自动合并;`open_revert_pr` 属于总可执行项)。 + 路径之后又被修改(会冲突)或涉及非受治理路径时不生成撤回、转人工。无租约时记为 `revert_pending`,由下一次持有租约的检查补发。 +- 撤回 PR 由人合并:只有 PR 合并的正是发布器构建的撤回提交、且合并提交在受治理路径上的变化恰为撤回路径并恢复到前像,才记录 + `disposed:` 处置配对;不精确的撤回合并本身也是未知提交。关闭撤回 PR 则保持未处置并告警。 +- 存在未处置的未知提交时不激活任何快照(G5 例外);可信列表(合并 SHA 与处置配对)写入签名控制记录的 `provenance`,发布器合并前 + 核对自当前激活快照以来 main 上每个受治理知识提交都可信(或是其自己的合并),否则不合并(瞬时,服务随即暂停并撤回)。 +- 精确性按树条目(模式 + 内容)比较;手工撤回同样被识别:与某提交共享知识路径的未处置未知提交(传递闭包,先前的部分撤回也是其中之一) + 构成一组;若该提交只改动这组涉及的路径,且之后这组触及的每条知识路径(不限受治理路径)都恢复到最早一个提交之前的树条目 + (按祖先关系排序),则整组被处置。未知记录保存完整路径列表与全部知识路径的前像,冲突检查覆盖所有路径。已处置的未知提交只在其处置撤回属于被检查的历史时 + 才可信(main 回退到撤回之前会使其重新成为未知);只要涉及的任一仓库范围为私有上游,就不开公开撤回 PR、不生成候选事件,只转人工。结算撤回前先拉取,避免把新合并的撤回误判为未知。 +- 激活本身(`activate()`,含 `kb activate`)核对从当前激活快照到目标 SHA 的每个知识提交都可信(首次激活除外),否则 + `ActivationError`;调度器在激活前做同样的检查并只记录 `activation_blocked`。入 intake 的候选事件带上该提交的 diff。 +- 尚未实现:`kb accept-unknown`(接受未知提交内容而不撤回),目前通过合并精确撤回(服务开出的或手工的)解除。 diff --git a/doc/features/knowledge-service.md b/doc/features/knowledge-service.md index 003dfcfa..48aed1fa 100644 --- a/doc/features/knowledge-service.md +++ b/doc/features/knowledge-service.md @@ -65,7 +65,9 @@ infermatrix-copilot kb rollback --to # 回指到较早的快照(下一 `main` 与 PR head 的合并树上运行本地门禁,通过才 `gh pr merge`(每仓库同时至多 1 个)→ 合并后激活新快照。 本地门禁拒绝的变更从不合并:上下文变化则在当前 main 上重建;服务自己的变更被质量门或本地门禁拒绝时,由生成器按 理由精炼后整体复检,至多 2 轮,仍失败则留在人工队列(不合并)。他人的知识 PR 每天检查一次:结果写在该 PR 上唯一一条 -“知识质量门”评论里(原地更新),未通过则不合并,作者修改后次日复检。发版巡检在上游新 release 后运行 +“知识质量门”评论里(原地更新),未通过则不合并,作者修改后次日复检。 +有人绕过门禁直接合并/推送知识变更时,服务立即暂停该仓库、停止激活新快照、告警,并开出撤回 PR(不自动合并); +合并精确的撤回 PR 并 `kb resume` 后恢复,被撤回的内容会作为新候选重新过门禁。发版巡检在上游新 release 后运行 T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。 评审服务读取知识:将 `KNOWLEDGE_ROOT` 指向 `$KB_STATE_DIR/active`。 diff --git a/src/infermatrix_copilot/kb_service/activate.py b/src/infermatrix_copilot/kb_service/activate.py index a36d0002..545ac538 100644 --- a/src/infermatrix_copilot/kb_service/activate.py +++ b/src/infermatrix_copilot/kb_service/activate.py @@ -135,11 +135,20 @@ def switch_active(state_dir: Path, snapshot: Path) -> None: os.replace(tmp, link) -def activate(rt, sha: str, *, locked: bool = False) -> Path: - """Build, verify and switch to the snapshot of knowledge at ``sha``.""" +def activate(rt, sha: str, *, locked: bool = False, verify_provenance: bool = True) -> Path: + """Build, verify and switch to the snapshot of knowledge at ``sha``. Every + knowledge commit from the active snapshot up to ``sha`` must have passed + the gate (design v8): nothing that bypassed it is ever served, whoever + asks (the scheduler, or `kb activate`).""" if not locked: with activation_lock(rt.state_dir): - return activate(rt, sha, locked=True) + return activate(rt, sha, locked=True, verify_provenance=verify_provenance) + if verify_provenance: + from .audit import provenance_problems + + problems = provenance_problems(rt, sha) + if problems: + raise ActivationError("provenance: " + "; ".join(problems[:5])) files = rt.knowledge.knowledge_files(sha) extra = rt.knowledge.top_level_knowledge(sha) snapshot = build_snapshot(rt.state_dir, sha, files, extra=extra) diff --git a/src/infermatrix_copilot/kb_service/audit.py b/src/infermatrix_copilot/kb_service/audit.py index 0e961239..9f173900 100644 --- a/src/infermatrix_copilot/kb_service/audit.py +++ b/src/infermatrix_copilot/kb_service/audit.py @@ -1,29 +1,61 @@ -"""Daily post-merge audit: nothing reaches knowledge/ on main without a record. - -The gate constrains every merge path the service knows about; this audit -catches the ones it does not (an admin bypass, a direct push, a merge the -ledger missed). It walks main's first-parent history from the last audited -commit. Every commit whose change (against its first parent) touches -``knowledge/`` must be the recorded merge of a knowledge change set: its SHA -is a change set's merge SHA, or it merges a PR the ledger recorded as merged. -Anything else pauses auto-merge for the repositories it touched (everything, -when it touched knowledge outside a repository scope) and goes to people; -repositories not in auto_merge only get a trace record. - -Commits younger than ``GRACE`` wait for the next run: the scheduler records a -merge only when it next looks at the PR, and the audit must not race it. +"""Provenance: nothing reaches knowledge/ on main without passing the gate (design v8). + +Without GitHub-side enforcement a person can still merge (or push) a knowledge +change the gate never saw. This module catches it and undoes it. Every +scheduler tick it walks main's first-parent history from the last audited +commit. A commit whose change (against its first parent) touches +``knowledge/`` is one of: + +* trusted: it IS a recorded merge of a knowledge change set (the publisher + merged it after its local gate), or a two-parent merge of a head such a + change set recorded that carries nothing but that head's knowledge change, + or the revert that disposed of an unknown commit; +* pending: a merge of a head whose merge item still waits for its receipt; +* disposed: an unknown commit an EXACT revert was merged for; +* unknown: anything else. + +An unknown commit pauses automatic merging for the repositories it touched +(everything, when it touched knowledge outside a repository scope), alerts +people, goes back into intake with its diff as evidence, and gets a revert +PR: governed paths restored to the commit's first-parent tree entries (mode +and content), built by the publisher and NEVER merged automatically. If a +path changed again after the commit, or the commit touched non-governed +paths, people revert it by hand. + +A revert disposes of unknown commits only if it is exact: it changes exactly +the governed paths they changed, back to exactly the tree entries they had +before the first of them (a revert PR the service opened must also be the +very commit the publisher built). A hand-made revert is recognised the same +way. The pairs are recorded; both commits are then trusted. + +No snapshot is activated past a commit that is unknown or still pending: +``provenance_problems`` checks every commit from the active snapshot up to +the exact SHA ``activate()`` is asked for (so ``kb activate`` cannot bypass +it). The trusted list reaches the publisher through the signed control record. """ from __future__ import annotations +import json import re from ..knowledge_service.ops import repo_scope AUDIT_CURSOR = "audit_main_sha" -GRACE = 2 * 3600 +GRACE = 0 # v8: acted on at once; our own merges waiting for a receipt are "pending", not unknown +UNKNOWN = "unknown:" # "*" cursors: one record per unknown commit +DISPOSED = "disposed:" # "*" cursors: unknown commit -> the exact revert that disposed of it +TRUSTED = "trusted_merges" # "*" cursor: the list the control record carries to the publisher +GOVERNED = ("knowledge/repos/", "knowledge/general/") +SUFFIXES = (".md", ".yaml") _PR_REF = re.compile(r"Merge pull request #(\d+)|\(#(\d+)\)\s*$", re.M) # reporting only, never proof _RECORDED = ("merged",) +TRUSTED_KEEP = 500 +DIFF_LIMIT = 8 * 1024 + + +def _governed(path: str) -> bool: + return path.startswith(GOVERNED) and path.endswith(SUFFIXES) def _scopes(paths: list[str]) -> set[str]: @@ -48,47 +80,264 @@ def _merge_carries_only_the_head(rt, parents: list[str], sha: str, paths: list[s return rt.knowledge.blob_ids(sha, paths) == rt.knowledge.blob_ids(head, paths) -def audit_main(rt, pause) -> list[str]: - """One audit pass; ``pause(scope, reason)`` pauses a repository ("*": all). - Returns findings (empty when everything that landed was recorded).""" - main = rt.knowledge.fetch() - last = rt.ledger.get_cursor("*", AUDIT_CURSOR) - if not last: - rt.ledger.set_cursor("*", AUDIT_CURSOR, main) # first run: the baseline - return [] - # proof is structural, never a commit message: the commit IS a recorded - # merge SHA, or it is a two-parent merge of the exact head a merged change - # set recorded (what the merge queue lands) - merge_shas, merged_heads = set(), set() +def disposals(rt) -> dict[str, str]: + return {name[len(DISPOSED):]: value for name, value in rt.ledger.cursors_with_prefix("*", DISPOSED).items()} + + +def _records(rt) -> dict: + merge_shas, merged_heads, pending = set(), set(), set() for lifecycle in rt.registry.values(): for changeset in rt.ledger.changesets(lifecycle.repo, _RECORDED): + if changeset["kind"] == "revert" and not changeset["detail"].get("exact"): + continue # an inexact revert merge is itself unknown if changeset.get("merge_sha"): merge_shas.add(changeset["merge_sha"]) if changeset.get("head_sha"): merged_heads.add(changeset["head_sha"]) + for changeset in rt.ledger.changesets(lifecycle.repo, ("merge_requested",)): + if changeset.get("head_sha"): + pending.add(changeset["head_sha"]) + disposed = disposals(rt) + return {"merges": merge_shas, "heads": merged_heads, "pending": pending, + "disposed": disposed, "disposing": set(disposed.values())} + + +def _classify(rt, sha: str, parents: list[str], paths: list[str], recs: dict, target: str) -> str: + """``target``: the commit the history is checked up to. A disposed commit + is trusted only when the revert that disposed of it is part of that + history (main rewound to before the revert brings it back).""" + if not paths: + return "none" + if sha in recs["merges"] or sha in recs["disposing"]: + return "trusted" + if sha in recs["disposed"]: + return "trusted" if rt.knowledge.is_ancestor(recs["disposed"][sha], target) else "unknown" + if len(parents) == 2 and parents[1] in recs["heads"] and _merge_carries_only_the_head(rt, parents, sha, paths): + return "trusted" + if len(parents) == 2 and parents[1] in recs["pending"]: + return "pending" + return "unknown" + + +def _knowledge_paths(rt, sha: str, parents: list[str]) -> list[str]: + return [p for p in rt.knowledge.changed_names(parents[0] if parents else "", sha) if p.startswith("knowledge/")] + + +def open_unknown(rt) -> list[str]: + """Unknown commits not disposed of yet.""" + disposed = disposals(rt) + return sorted(name[len(UNKNOWN):] for name in rt.ledger.cursors_with_prefix("*", UNKNOWN) + if name[len(UNKNOWN):] not in disposed) + + +def provenance_problems(rt, sha: str) -> list[str]: + """Why ``sha`` may not be activated (empty: every knowledge commit from the + active snapshot up to it is trusted). The very first activation has no + trusted baseline and is allowed.""" + active = rt.ledger.active_snapshot() + if not active or active == sha: + return [] + if not rt.knowledge.is_ancestor(active, sha): + return [f"{sha[:12]} does not descend from the active snapshot {active[:12]}"] + recs = _records(rt) + problems = [] + for commit, parents, _at, _message in rt.knowledge.first_parent_commits(active, sha): + kind = _classify(rt, commit, parents, _knowledge_paths(rt, commit, parents), recs, sha) + if kind in ("unknown", "pending"): + problems.append(f"{commit[:12]} is {kind}") + return problems + + +def publish_trusted(rt) -> None: + """What the publisher may treat as trusted on main (control record).""" + recs = _records(rt) + trusted = sorted(recs["merges"] | set(recs["disposed"]) | recs["disposing"])[-TRUSTED_KEEP:] + rt.ledger.set_cursor("*", TRUSTED, json.dumps(trusted)) + + +def audit_main(rt, pause) -> list[str]: + """One pass; ``pause(scope, reason)`` pauses a repository ("*": all). + Returns findings (empty when everything that landed was trusted).""" + main = rt.knowledge.fetch() + last = rt.ledger.get_cursor("*", AUDIT_CURSOR) + if not last: + rt.ledger.set_cursor("*", AUDIT_CURSOR, main) # first run: the baseline + publish_trusted(rt) + return [] findings: list[str] = [] audited = last for sha, parents, committed_at, message in rt.knowledge.first_parent_commits(last, main): if rt.clock() - committed_at < GRACE: - break # too recent to judge: the next run starts here + break + paths = _knowledge_paths(rt, sha, parents) + kind = _classify(rt, sha, parents, paths, _records(rt), main) + if kind == "pending": + break # our own merge; its receipt records it next tick: look again then audited = sha - paths = [p for p in rt.knowledge.changed_names(parents[0] if parents else "", sha) - if p.startswith("knowledge/")] - if not paths: - continue - refs = [int(a or b) for a, b in _PR_REF.findall(message)] - if sha in merge_shas or (len(parents) == 2 and parents[1] in merged_heads - and _merge_carries_only_the_head(rt, parents, sha, paths)): + if kind in ("none", "trusted") or rt.ledger.get_cursor("*", UNKNOWN + sha): continue - scopes = _scopes(paths) - reason = (f"unrecorded knowledge change {sha[:12]}" - f"{' (PR #' + str(refs[0]) + ')' if refs else ' (no PR)'} touching {paths[:3]}") - auto = [s for s in sorted(scopes) if s == "*" or getattr(rt.registry.get(s), "auto_merge", False)] - for scope in auto: - pause(scope, f"daily audit: {reason}") - rt.trace("outcome", context={"playbook": "kb-audit", "repo": ",".join(sorted(scopes))}, - result={"outcome": "unrecorded_merge", "sha": sha, "prs": refs, "paths": paths[:20], - "paused": auto}) - findings.append(reason) + if _dispose_by_revert(rt, sha, parents, paths): + continue # an exact revert (by hand, or of a PR merged around the service) + findings.append(_unknown(rt, pause, sha, parents, message, paths, main)) rt.ledger.set_cursor("*", AUDIT_CURSOR, audited) + _issue_pending_reverts(rt, main) + publish_trusted(rt) return findings + + +def _dispose_by_revert(rt, sha: str, parents: list[str], paths: list[str]) -> list[str]: + """Whether ``sha`` completes the revert of open unknown commits. The set + is every open unknown commit sharing a knowledge path with ``sha``, and, + transitively, with those (a partial revert made earlier is itself one of + them). It is disposed of when ``sha`` changes only paths of the set and, + after it, EVERY path the set touched (governed or not) holds exactly the + tree entry it had before the earliest commit of the set: together they + changed nothing. Returns the commits disposed of.""" + import functools + + if not paths or not parents: + return [] + records = {u: json.loads(rt.ledger.get_cursor("*", UNKNOWN + u) or "{}") for u in open_unknown(rt)} + # history order (ancestors first): the earliest commit's pre-image wins + in_order = sorted(records, key=functools.cmp_to_key( + lambda a, b: -1 if rt.knowledge.is_ancestor(a, b) else (1 if rt.knowledge.is_ancestor(b, a) else 0))) + targets, touched = [], set(paths) + grew = True + while grew: + grew = False + for unknown in in_order: + if unknown not in targets and set(records[unknown].get("pre") or {}) & touched: + targets.append(unknown) + touched |= set(records[unknown].get("pre") or {}) + grew = True + if not targets: + return [] + covered: dict[str, str] = {} + for unknown in in_order: + if unknown in targets: + for path, entry in (records[unknown].get("pre") or {}).items(): + covered.setdefault(path, entry) + if not set(paths) <= set(covered) or rt.knowledge.blob_ids(sha, list(covered)) != covered: + return [] + for unknown in targets: + rt.ledger.set_cursor("*", DISPOSED + unknown, sha) + return targets + + +def _unknown(rt, pause, sha: str, parents: list[str], message: str, paths: list[str], main: str) -> str: + refs = [int(a or b) for a, b in _PR_REF.findall(message)] + scopes = _scopes(paths) + reason = (f"unrecorded knowledge change {sha[:12]}" + f"{' (PR #' + str(refs[0]) + ')' if refs else ' (no PR)'} touching {paths[:3]}") + auto = [s for s in sorted(scopes) if s == "*" or getattr(rt.registry.get(s), "auto_merge", False)] + for scope in auto: + pause(scope, f"provenance: {reason}; nothing is activated until it is reverted") + # the COMPLETE path list and every path's entry before the commit: a + # revert is exact only if it restores all of them (reports stay short) + record = {"scopes": sorted(scopes), "paths": paths, "prs": refs, "paused": auto, "state": "open", + "parents": parents, "reason": reason, "at": rt.clock(), + "pre": rt.knowledge.blob_ids(parents[0], paths) if parents else {}} + revert_repo = next((s for s in auto if s != "*"), None) + lifecycle = rt.registry.get(revert_repo) if revert_repo else None + # a revert PR and a candidate both publish the commit's paths and content: + # only when EVERY repository scope it touches may publish + public = all(s == "*" or (rt.registry.get(s) is not None and rt.registry[s].publishes) for s in scopes) + if lifecycle is not None and lifecycle.publishes and public: + record.update(repo=lifecycle.repo, state="revert_pending") + # its content goes back through the gate as a candidate of its own + rt.ledger.record_event(lifecycle.repo, "unrecorded", sha, { + "source_reference": f"commit {sha}", "title": message.splitlines()[0] if message else sha, + "body": message[:3000], "changed_files": paths[:50], + "diff_excerpt": rt.knowledge.diff_text(parents[0] if parents else "", sha, paths)[:DIFF_LIMIT]}) + else: + record["state"] = "people" + rt.ledger.enqueue_human(revert_repo or next(iter(rt.registry)), f"{reason}: revert it by hand") + rt.ledger.set_cursor("*", UNKNOWN + sha, json.dumps(record)) + rt.trace("outcome", context={"playbook": "kb-audit", "repo": ",".join(sorted(scopes))}, + result={"outcome": "unrecorded_merge", "sha": sha, "prs": refs, "paths": paths[:20], "paused": auto}) + return reason + + +def _issue_pending_reverts(rt, main: str) -> None: + """Revert PRs for unknown commits that do not have one yet (staging needs + the scheduler's lease; without it they wait for the next pass).""" + if not getattr(rt, "lease_owner", None): + return + disposed = disposals(rt) + for name, raw in sorted(rt.ledger.cursors_with_prefix("*", UNKNOWN).items(), + key=lambda item: json.loads(item[1]).get("at", 0)): + record = json.loads(raw) + sha = name[len(UNKNOWN):] + lifecycle = rt.registry.get(record.get("repo") or "") + if record.get("state") != "revert_pending" or lifecycle is None or sha in disposed: + continue + record.update(_issue_revert(rt, lifecycle, sha, record, main)) + rt.ledger.set_cursor("*", name, json.dumps(record)) + + +def _issue_revert(rt, lifecycle, sha: str, record: dict, main: str) -> dict: + """A revert PR restoring every governed path to its tree entry before the + commit. Refused (people) when a path is not governed or changed again.""" + from .merge import issue_once + + reason, paths, parents = record["reason"], record["paths"], record.get("parents") or [] + first = parents[0] if parents else "" + other = [p for p in paths if not _governed(p)] + if other or not first: + rt.ledger.enqueue_human(lifecycle.repo, f"{reason}: it touches {other[:3] or 'a root commit'}, " + "which the service does not revert; revert it by hand") + return {"state": "people"} + landed, now = rt.knowledge.blob_ids(sha, paths), rt.knowledge.blob_ids(main, paths) + moved = [p for p in paths if landed[p] != now[p]] + if moved: + rt.ledger.enqueue_human(lifecycle.repo, f"{reason}: a revert would conflict ({moved[:3]} changed again " + "since); revert it by hand (an exact revert unblocks it)") + return {"state": "conflict", "moved": moved} + pre = record["pre"] + if any(entry and not entry.startswith("100644 blob ") for entry in pre.values()): + rt.ledger.enqueue_human(lifecycle.repo, f"{reason}: a path had a non-regular mode before it; revert it by hand") + return {"state": "people"} + files = {p: rt.knowledge.show(first, p) for p, entry in pre.items() if entry} + deleted = [p for p, entry in pre.items() if not entry] + changeset_id = rt.ledger.new_changeset_id(lifecycle.repo, "revert") + rel = lambda p: p[len("knowledge/"):] # noqa: E731 + rt.save_changeset_files(changeset_id, {"base_sha": main, "files": {rel(p): t for p, t in files.items()}, + "deleted": [rel(p) for p in deleted], + "evidence": [{"source_reference": f"commit {sha}", "title": reason}]}) + rt.ledger.stage_intake(rt.lease_owner, lifecycle.repo, changeset_id, kind="revert", status="pr_requested", + verdicts=[], human_reason=f"{reason}: review and merge revert PR (changeset {changeset_id})", + drafted_events=[], detail={"reverts": sha, "expected": pre, "operations": []}) + issue_once(rt, lifecycle.repo, rt.ledger.changeset(changeset_id), "open_revert_pr", { + "changeset_id": changeset_id, "base_sha": main, "branch": f"kb/revert/{sha[:12]}", + "files": files, "deleted": deleted, + "title": f"knowledge: revert {sha[:12]} (merged without passing the knowledge gate)", + "body": (f"{reason}.\n\nThis revert restores the governed knowledge pages to their content before " + f"`{sha}`. It is never merged automatically: merge it to dispose of the unrecorded change " + "(auto-merge then resumes after `kb resume`), or close it and decide otherwise. The reverted " + "content has been queued as a new knowledge candidate and goes through the gate.")}) + return {"state": "reverting", "revert_changeset": changeset_id} + + +def settle_revert(rt, changeset: dict, pr: dict) -> bool: + """A revert PR was merged: record the disposal only if it is exact (the + very commit the publisher built, merged with nothing else on governed + paths, every path back to its tree entry, mode included).""" + rt.knowledge.fetch() # the merge commit may be newer than our clone + merge_sha = str(pr.get("merge_commit_sha") or "") + detail = changeset["detail"] + expected: dict[str, str] = detail.get("expected") or {} + exact = False + if merge_sha and str((pr.get("head") or {}).get("sha") or "") == changeset.get("head_sha"): + parents = rt.knowledge.parents(merge_sha) + if parents: + changed = {p for p in rt.knowledge.changed_names(parents[0], merge_sha) if p.startswith("knowledge/")} + exact = changed == set(expected) and rt.knowledge.blob_ids(merge_sha, list(expected)) == expected + rt.ledger.update_changeset(changeset["id"], status="merged", merge_sha=merge_sha or None, + detail={**detail, "exact": exact}) + if exact: + rt.ledger.set_cursor("*", DISPOSED + detail["reverts"], merge_sha) + else: + rt.ledger.enqueue_human(changeset["repo"], ( + f"revert PR #{changeset['pr_number']} for {detail['reverts'][:12]} was merged but is not the exact " + "revert (modified or with other knowledge changes): the change stays undisposed"), changeset["id"]) + return exact diff --git a/src/infermatrix_copilot/kb_service/merge.py b/src/infermatrix_copilot/kb_service/merge.py index cfba6080..f41cf5bf 100644 --- a/src/infermatrix_copilot/kb_service/merge.py +++ b/src/infermatrix_copilot/kb_service/merge.py @@ -69,6 +69,7 @@ def knowledge_repository() -> str: "post_verdict": ("pr_open", "verdict_posted"), "enqueue": ("verdict_posted", "queued"), "merge": ("merge_requested", "merged"), + "open_revert_pr": ("pr_requested", "revert_open"), } @@ -164,7 +165,7 @@ def apply_acks(rt, acks: list[dict]) -> None: changeset_id) continue fields = {"status": after, "pending_item": None} - if kind in ("open_pr", "open_companion_pr"): + if kind in ("open_pr", "open_companion_pr", "open_revert_pr"): fields.update(pr_number=int(ack["pr"]), head_sha=str(ack["head_sha"]), branch=str(ack.get("branch", ""))) elif kind == "enqueue": @@ -463,7 +464,7 @@ def advance(rt, lifecycle) -> list[str]: paused = is_paused(rt.ledger, lifecycle.repo) merging = rt.ledger.changesets(lifecycle.repo, ("merge_requested",)) for changeset in rt.ledger.changesets(lifecycle.repo, (*IN_FLIGHT, "superseding", "paused", - "companion_open")): + "companion_open", "revert_open")): number = changeset["pr_number"] if number is None: continue @@ -511,6 +512,24 @@ def advance(rt, lifecycle) -> list[str]: knowledge["id"]) events.append(f"companion_closed {changeset['id']}") continue + if changeset["status"] == "revert_open": + # people merge reverts; an exact one disposes of the unknown change + from .audit import settle_revert + + if pr.get("merged"): + try: + exact = settle_revert(rt, changeset, pr) + except Exception as exc: # noqa: BLE001 - e.g. the clone cannot fetch yet: next pass + events.append(f"revert_settle_retry {changeset['id']}: {exc}") + continue + events.append(f"revert_merged {changeset['id']} exact={exact}") + elif pr.get("state") == "closed": + rt.ledger.update_changeset(changeset["id"], status="closed") + rt.ledger.enqueue_human(lifecycle.repo, ( + f"revert PR #{number} was closed: {changeset['detail'].get('reverts', '')[:12]} stays " + "undisposed and activation stays blocked until it is reverted"), changeset["id"]) + events.append(f"revert_closed {changeset['id']}") + continue if changeset["status"] == "superseding": # the replaced PR stays tracked until GitHub shows it closed; the # close item is re-issued whenever the previous one expired diff --git a/src/infermatrix_copilot/kb_service/outbox.py b/src/infermatrix_copilot/kb_service/outbox.py index cabc42cf..adfc9e6a 100644 --- a/src/infermatrix_copilot/kb_service/outbox.py +++ b/src/infermatrix_copilot/kb_service/outbox.py @@ -37,12 +37,15 @@ ITEM_KINDS = ( "open_pr", "open_companion_pr", "post_verdict", "enqueue", - "update_branch", "pause", "close", "open_issue", "merge", "post_findings", + "update_branch", "pause", "close", "open_issue", "merge", "post_findings", "open_revert_pr", ) -ALWAYS_EXECUTABLE = frozenset({"pause", "close"}) +# they only ever stop or undo: executable while a repository is paused (a +# revert is issued BECAUSE the repository was paused for an unknown change) +ALWAYS_EXECUTABLE = frozenset({"pause", "close", "open_revert_pr"}) ITEM_TTL = { "open_pr": 24 * 3600, "open_companion_pr": 24 * 3600, "post_verdict": 30 * 60, "enqueue": 30 * 60, "merge": 30 * 60, "post_findings": 24 * 3600, + "open_revert_pr": 24 * 3600, "update_branch": 24 * 3600, "pause": 24 * 3600, "close": 24 * 3600, "open_issue": 7 * 24 * 3600, } @@ -182,7 +185,11 @@ def _control_payload(self) -> dict: "paused": bool(row["paused"])} for row in self._ledger.all_repo_states() } - return {"issued_at": self._clock(), "repos": repos} + # what the publisher checks main against before every merge: every + # governed knowledge commit since the active snapshot must be trusted + trusted = json.loads(self._ledger.get_cursor("*", "trusted_merges") or "[]") + return {"issued_at": self._clock(), "repos": repos, + "provenance": {"active": self._ledger.active_snapshot() or "", "trusted": trusted}} def refresh_control(self) -> dict: with self.publication_lock(): diff --git a/src/infermatrix_copilot/kb_service/publisher.py b/src/infermatrix_copilot/kb_service/publisher.py index 2c460524..2c20f29a 100644 --- a/src/infermatrix_copilot/kb_service/publisher.py +++ b/src/infermatrix_copilot/kb_service/publisher.py @@ -55,7 +55,7 @@ # so a label that failed to apply cannot make one mergeable by automation COMPANION_BRANCH = re.compile(r"kb/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+-companion-[0-9a-f]+") HOLD_LABEL = "kb:hold" -PUSHING = frozenset({"open_pr", "open_companion_pr"}) +PUSHING = frozenset({"open_pr", "open_companion_pr", "open_revert_pr"}) # retried until done, never acked as failed: stops (pause, close) and reports # (open_issue, post_findings: a transient GitHub error must not lose a sweep # report or an author's findings) @@ -384,7 +384,7 @@ def _handle(self, item_id: str, control: Any) -> str: publishes, auto_merge = self.repo_flags.get(claimed, (False, False)) item = check_item(envelope, control, self.service_public_key, now=self.clock(), repo_publishes=publishes, repo_auto_merge=auto_merge) - self._envelope, self._flags = envelope, (publishes, auto_merge) + self._envelope, self._flags, self._control = envelope, (publishes, auto_merge), control except (OutboxError, OSError, ValueError, KeyError, TypeError) as exc: # not performed and not acked: an unverifiable item is never # answered, and a stale one is superseded by the service itself @@ -464,6 +464,32 @@ def _do_open_pr(self, item: OutboxItem, *, draft: bool = False, allowed: re.Patt raise PublishError(f"the PR for {branch} could not be confirmed after creation") return {"pr": int(opened[0]["number"]), "head_sha": commit, "branch": branch} + def _do_open_revert_pr(self, item: OutboxItem) -> dict: + """A revert of an unknown change: governed pages only, never merged by us.""" + return self._do_open_pr(item) + + def _provenance_problems(self, main_sha: str) -> list[str]: + """Every governed knowledge commit on main since the active snapshot + must be trusted: listed in the signed control record, or a merge this + publisher made itself (its receipt may not have reached the service).""" + payload = (getattr(self, "_control", None) or {}).get("payload") or {} + provenance = payload.get("provenance") or {} + active = str(provenance.get("active") or "") + if not active: + return [] # nothing activated yet: no trusted baseline to compare with + trusted = set(provenance.get("trusted") or []) + done = self.state_dir / "done" + for path in (done.glob("*.json") if done.is_dir() else []): + try: + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if record.get("kind") == "merge" and record.get("merge_sha"): + trusted.add(record["merge_sha"]) + commits = self._git("rev-list", "--first-parent", f"{active}..{main_sha}", "--", + "knowledge/repos", "knowledge/general").split() + return [sha for sha in commits if sha not in trusted] + def _do_open_companion_pr(self, item: OutboxItem) -> dict: """Always a draft, labelled kb:companion, and only within the companion whitelist: people review, ready and merge it; automation never does.""" @@ -590,6 +616,10 @@ def _do_merge(self, item: OutboxItem) -> dict: if self._git("rev-parse", f"refs/kb/pr-{number}") != head: raise PublishError(f"fetched head of PR #{number} is not {head[:12]}") main_sha = self._git("rev-parse", "refs/remotes/origin/main") + unknown = self._provenance_problems(main_sha) + if unknown: + raise PublishError(f"not merging: knowledge changes on main that passed no gate " + f"({', '.join(s[:12] for s in unknown[:3])}); the service pauses and reverts them") problems = local_gate.gate(self.clone, repository=self.github.repository, repo=item.repo, pr=rest, head_sha=head, main_sha=main_sha, verdict=verdict, public_key=self.service_public_key, now=self.clock()) diff --git a/src/infermatrix_copilot/kb_service/scheduler.py b/src/infermatrix_copilot/kb_service/scheduler.py index fe27617f..91688974 100644 --- a/src/infermatrix_copilot/kb_service/scheduler.py +++ b/src/infermatrix_copilot/kb_service/scheduler.py @@ -27,7 +27,7 @@ from . import merge from .activate import ActivationError, activate, activation_lock from .archive import make_archive -from .audit import audit_main +from .audit import audit_main, open_unknown, provenance_problems from .report import flush_reports from .companion import publish_companion from .external import poll_external @@ -45,7 +45,7 @@ class Scheduler: tick_seconds: float = 60.0 intake_every: float = 900.0 release_every: float = 3600.0 - audit_every: float = 24 * 3600.0 + audit_every: float = 0.0 # v8: provenance is checked every tick archive_every: float = 7 * 24 * 3600.0 log: list[dict] = field(default_factory=list) _last: dict[str, float] = field(default_factory=dict) @@ -108,8 +108,14 @@ def tick(self) -> None: if pinned_main == sha: pass # rolled back away from this main: stay there until main moves on elif sha != rt.ledger.active_snapshot(): - activate(rt, sha, locked=True) - self._record("*", "activated", snapshot=sha) + blocked = open_unknown(rt) or provenance_problems(rt, sha) + if blocked: + # a knowledge change that passed no gate is on main: nothing + # new is served until it is reverted (design v8, G5 exception) + self._record("*", "activation_blocked", unknown=[s[:12] for s in blocked]) + else: + activate(rt, sha, locked=True) + self._record("*", "activated", snapshot=sha) except ActivationError as exc: rt.ledger.enqueue_human("*", f"snapshot activation refused: {exc}") self._record("*", "activation_refused", error=str(exc)) diff --git a/src/infermatrix_copilot/kb_service/sources.py b/src/infermatrix_copilot/kb_service/sources.py index 44e4d6f4..71cab98b 100644 --- a/src/infermatrix_copilot/kb_service/sources.py +++ b/src/infermatrix_copilot/kb_service/sources.py @@ -86,6 +86,24 @@ def fetch_pull(self, number: int) -> str: def merge_base(self, a: str, b: str) -> str: return self._git("merge-base", a, b).decode().strip() + def is_ancestor(self, older: str, newer: str) -> bool: + proc = subprocess.run(["git", "-C", str(self.path), "merge-base", "--is-ancestor", older, newer], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + return proc.returncode == 0 + + def diff_text(self, old: str, new: str, paths: list[str]) -> str: + """The textual diff of ``paths`` (evidence for a candidate).""" + if not paths: + return "" + args = ["diff", old, new, "--", *paths] if old else ["show", "--format=", new, "--", *paths] + try: + return self._git(*args).decode("utf-8", "replace") + except SourceError: + return "" + + def parents(self, sha: str) -> list[str]: + return self._git("rev-list", "--parents", "-n", "1", sha).decode().split()[1:] + def show(self, rev: str, path: str) -> str | None: try: return self._git("show", f"{rev}:{path}").decode("utf-8", "replace") diff --git a/test/test_kb_audit.py b/test/test_kb_audit.py index 1f14eded..af05229a 100644 --- a/test/test_kb_audit.py +++ b/test/test_kb_audit.py @@ -1,4 +1,4 @@ -"""The daily post-merge audit: unrecorded knowledge changes pause auto-merge.""" +"""Provenance: unrecorded knowledge changes pause auto-merge, block activation and get reverted.""" from __future__ import annotations @@ -6,7 +6,7 @@ import time from dataclasses import replace -from infermatrix_copilot.kb_service.audit import AUDIT_CURSOR, GRACE, audit_main +from infermatrix_copilot.kb_service.audit import AUDIT_CURSOR, GRACE, audit_main # noqa: F401 from infermatrix_copilot.kb_service.scheduler import Scheduler from test_kb_flow import _flow_runtime from test_kb_intake_gate import PAGE @@ -28,6 +28,7 @@ def _land(origin, rel: str, text: str, message: str) -> str: def _setup(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) rt.clock = lambda: time.time() + GRACE + 60 # every commit made in the test is old enough + rt.lease_owner = rt.ledger.acquire_lease("scheduler") scheduler = Scheduler(rt) assert audit_main(rt, scheduler._pause) == [] # the first run records the baseline assert rt.ledger.get_cursor("*", AUDIT_CURSOR) == rt.knowledge.main_sha() @@ -42,7 +43,7 @@ def test_a_knowledge_change_without_a_record_pauses_auto_merge(tmp_path): (finding,) = audit_main(rt, scheduler._pause) assert sha[:12] in finding and "(no PR)" in finding assert rt.ledger.repo_state("demo")["paused"] - assert any("daily audit" in item["reason"] for item in rt.ledger.human_queue("demo")) + assert any("provenance" in item["reason"] for item in rt.ledger.human_queue("demo")) assert rt.ledger.get_cursor("*", AUDIT_CURSOR) == sha assert audit_main(rt, scheduler._pause) == [] # audited once @@ -54,7 +55,7 @@ def test_recorded_merges_and_code_changes_pass(tmp_path): merged = _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "Merge pull request #42 from JiusiServe/kb/demo/x") changeset = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", changeset, detail={}, status="merged", + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merged", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(changeset, pr_number=42, merge_sha=merged) assert audit_main(rt, scheduler._pause) == [] @@ -62,14 +63,21 @@ def test_recorded_merges_and_code_changes_pass(tmp_path): assert rt.ledger.get_cursor("*", AUDIT_CURSOR) == merged -def test_recent_commits_wait_for_the_next_run(tmp_path): +def test_our_own_merge_waiting_for_its_receipt_is_not_unknown(tmp_path): rt, lifecycle, scheduler, origin = _setup(tmp_path) before = rt.ledger.get_cursor("*", AUDIT_CURSOR) + _git(origin, "checkout", "-q", "-b", "kb/demo/r") page = origin / "knowledge" / PAGE - _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "fresh") - rt.clock = time.time # the commit is younger than the grace period - assert audit_main(rt, scheduler._pause) == [] - assert rt.ledger.get_cursor("*", AUDIT_CURSOR) == before + head = _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "knowledge change") + _git(origin, "checkout", "-q", "main") + _git(origin, "-c", "user.name=q", "-c", "user.email=q@e", "merge", "-q", "--no-ff", "-m", "merged by us", head) + changeset = rt.ledger.new_changeset_id("demo", "intake") + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merge_requested", + verdicts=[], human_reason="", drafted_events=[]) + rt.ledger.update_changeset(changeset, pr_number=46, head_sha=head) + assert audit_main(rt, scheduler._pause) == [] # the receipt is on its way + assert not rt.ledger.repo_state("demo")["paused"] + assert rt.ledger.get_cursor("*", AUDIT_CURSOR) == before # looked at again next tick def test_repositories_not_in_auto_merge_are_only_traced(tmp_path): @@ -99,7 +107,7 @@ def test_a_commit_message_citing_a_recorded_pr_is_no_proof(tmp_path): merged = _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "Merge pull request #42 from JiusiServe/kb/demo/x") changeset = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", changeset, detail={}, status="merged", + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merged", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(changeset, pr_number=42, merge_sha=merged) sneaky = _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", @@ -116,7 +124,7 @@ def test_a_queue_merge_of_the_recorded_head_is_proof(tmp_path): _git(origin, "checkout", "-q", "main") _git(origin, "-c", "user.name=q", "-c", "user.email=q@e", "merge", "-q", "--no-ff", "-m", "queue merge", head) changeset = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", changeset, detail={}, status="merged", + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merged", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(changeset, pr_number=43, head_sha=head, merge_sha="") assert audit_main(rt, scheduler._pause) == [] @@ -133,7 +141,7 @@ def test_a_merge_of_a_recorded_head_with_extra_edits_is_caught(tmp_path): _git(origin, "add", "-A") _git(origin, "-c", "user.name=q", "-c", "user.email=q@e", "commit", "-q", "-m", "merge with extras") changeset = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", changeset, detail={}, status="merged", + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merged", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(changeset, pr_number=44, head_sha=head, merge_sha="") (finding,) = audit_main(rt, scheduler._pause) @@ -150,7 +158,7 @@ def test_a_merge_that_adds_an_executable_bit_is_caught(tmp_path): _git(origin, "update-index", "--chmod=+x", f"knowledge/{PAGE}") _git(origin, "-c", "user.name=q", "-c", "user.email=q@e", "commit", "-q", "-m", "merge with a mode change") changeset = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", changeset, detail={}, status="merged", + rt.ledger.stage_intake(rt.lease_owner, "demo", changeset, detail={}, status="merged", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(changeset, pr_number=45, head_sha=head, merge_sha="") assert len(audit_main(rt, scheduler._pause)) == 1 diff --git a/test/test_kb_flow.py b/test/test_kb_flow.py index c50a6d21..1dfdc6d5 100644 --- a/test/test_kb_flow.py +++ b/test/test_kb_flow.py @@ -247,10 +247,10 @@ def test_activation_switches_atomically_and_rolls_back(tmp_path, monkeypatch): rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) first = rt.knowledge.fetch() - activate(rt, first) + activate(rt, first, verify_provenance=False) assert Path(active_link(rt.state_dir)).resolve().name == first second = _commit_change(rt, "repos/demo/guide.md", "# guide\n") - activate(rt, second) + activate(rt, second, verify_provenance=False) monkeypatch.setenv("KNOWLEDGE_ROOT", str(active_link(rt.state_dir))) from infermatrix_copilot.knowledge_view import _load_view _load_view.cache_clear() @@ -265,11 +265,11 @@ def test_activation_refuses_a_broken_tree(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) good = rt.knowledge.fetch() - activate(rt, good) + activate(rt, good, verify_provenance=False) bad = _commit_change(rt, "repos/demo/_routes.yaml", "schema_version: 1\nowners:\n - owner: core\n path: repos/demo/missing.md\n") with pytest.raises(ActivationError): - activate(rt, bad) + activate(rt, bad, verify_provenance=False) assert Path(active_link(rt.state_dir)).resolve().name == good # unchanged @@ -484,9 +484,9 @@ def test_scheduler_does_not_undo_a_rollback_until_main_moves(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) first = rt.knowledge.fetch() - activate(rt, first) + activate(rt, first, verify_provenance=False) second = _commit_change(rt, "repos/demo/guide.md", "# guide\n") - activate(rt, second) + activate(rt, second, verify_provenance=False) rollback(rt, first) scheduler = Scheduler(rt) scheduler.intake_every = scheduler.release_every = 10 ** 9 @@ -494,6 +494,12 @@ def test_scheduler_does_not_undo_a_rollback_until_main_moves(tmp_path): scheduler.tick() assert Path(active_link(rt.state_dir)).resolve().name == first # rollback respected third = _commit_change(rt, "repos/demo/guide.md", "# guide v3\n") + owner = rt.ledger.acquire_lease("t") + for sha in (second, third): # both passed the gate + merged = rt.ledger.new_changeset_id("demo", "intake") + rt.ledger.stage_intake(owner, "demo", merged, detail={}, status="merged", + verdicts=[], human_reason="", drafted_events=[]) + rt.ledger.update_changeset(merged, merge_sha=sha) scheduler.tick() assert Path(active_link(rt.state_dir)).resolve().name == third # a newer main activates @@ -502,7 +508,7 @@ def test_activation_rejects_a_snapshot_whose_content_was_altered(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) sha = rt.knowledge.fetch() - snapshot = activate(rt, sha) + snapshot = activate(rt, sha, verify_provenance=False) (snapshot / "knowledge" / "AGENTS.md").write_text("# tampered\n", encoding="utf-8") with pytest.raises(ActivationError, match="does not match its manifest"): verify_snapshot(snapshot) @@ -610,9 +616,9 @@ def test_rollback_and_a_concurrent_tick_are_serialised(tmp_path, monkeypatch): rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) first = rt.knowledge.fetch() - activate(rt, first) + activate(rt, first, verify_provenance=False) second = _commit_change(rt, "repos/demo/guide.md", "# guide\n") - activate(rt, second) + activate(rt, second, verify_provenance=False) original = activate_module.verify_snapshot entered = threading.Event() @@ -850,17 +856,17 @@ def test_activation_refuses_a_knowledge_format_this_copilot_does_not_read(tmp_pa rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) good = rt.knowledge.fetch() - snapshot = activate(rt, good) + snapshot = activate(rt, good, verify_provenance=False) assert json.loads((snapshot / "MANIFEST.json").read_text())["knowledge_format"] == 2 newer = _commit_change(rt, "_format.yaml", "format_version: 3\n") with pytest.raises(ActivationError, match="knowledge format 3"): - activate(rt, newer) + activate(rt, newer, verify_provenance=False) origin = Path(rt.knowledge.path).parent / "origin" (origin / "knowledge" / "_format.yaml").unlink() _git(origin, "add", "-A") _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "undeclared") with pytest.raises(ActivationError, match="undeclared"): - activate(rt, rt.knowledge.fetch()) + activate(rt, rt.knowledge.fetch(), verify_provenance=False) assert Path(active_link(rt.state_dir)).resolve().name == good # still the good one @@ -875,8 +881,8 @@ def test_a_malformed_format_declaration_is_refused_not_coerced(tmp_path, declare rt, lifecycle = _flow_runtime(tmp_path) _add_agents(rt) good = rt.knowledge.fetch() - activate(rt, good) + activate(rt, good, verify_provenance=False) bad = _commit_change(rt, "_format.yaml", f"format_version: {declared}\n") with pytest.raises(ActivationError, match="undeclared"): - activate(rt, bad) + activate(rt, bad, verify_provenance=False) assert Path(active_link(rt.state_dir)).resolve().name == good diff --git a/test/test_kb_provenance.py b/test/test_kb_provenance.py new file mode 100644 index 00000000..789e91db --- /dev/null +++ b/test/test_kb_provenance.py @@ -0,0 +1,279 @@ +"""Unknown knowledge changes: revert PRs, exact disposal, blocked activation, the publisher's check.""" + +from __future__ import annotations + +import json + +from infermatrix_copilot.kb_service import merge +from infermatrix_copilot.kb_service.audit import ( + DISPOSED, TRUSTED, UNKNOWN, audit_main, open_unknown, +) +from test_kb_audit import _git, _land, _setup +from test_kb_flow import _items +from test_kb_intake_gate import PAGE + + +def _sneak(origin, text="\n\n"): + page = origin / "knowledge" / PAGE + before = page.read_text() + sha = _land(origin, f"knowledge/{PAGE}", before + text, "hotfix knowledge directly") + return sha, before + + +def _open_revert(rt, origin, sha, before, *, extra=None): + """What the publisher does for open_revert_pr, then its ack.""" + (item,) = _items(origin.parent, "open_revert_pr") + body = item["body"] + assert body["files"] == {f"knowledge/{PAGE}": before} and body["deleted"] == [] + _git(origin, "checkout", "-q", "-b", body["branch"]) + head = _land(origin, f"knowledge/{PAGE}", before, "revert") + if extra: + head = _land(origin, extra[0], extra[1], "and something else") + _git(origin, "checkout", "-q", "main") + merge.apply_acks(rt, [{"item_id": item["id"], "kind": "open_revert_pr", "changeset_id": body["changeset_id"], + "ok": True, "pr": 90, "head_sha": head, "branch": body["branch"]}]) + return body["changeset_id"], head + + +def _merge_revert(rt, origin, head, *, chmod=False): + """People merge the revert PR on GitHub (the service's clone has not seen it yet).""" + _git(origin, "-c", "user.name=h", "-c", "user.email=h@e", "merge", "-q", "--no-ff", "--no-commit", head) + if chmod: + _git(origin, "update-index", "--chmod=+x", f"knowledge/{PAGE}") + _git(origin, "-c", "user.name=h", "-c", "user.email=h@e", "commit", "-q", "-m", "merge revert") + merged = _git(origin, "rev-parse", "HEAD") + rt.github.prs[90] = {"state": "closed", "merged": True, "merge_commit_sha": merged, "head": {"sha": head}} + return merged + + +def test_an_unknown_change_is_paused_blocked_reverted_and_sent_back_to_intake(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + sha, before = _sneak(origin) + (finding,) = audit_main(rt, scheduler._pause) + assert sha[:12] in finding and rt.ledger.repo_state("demo")["paused"] + assert open_unknown(rt) == [sha] + record = json.loads(rt.ledger.get_cursor("*", UNKNOWN + sha)) + assert record["state"] == "reverting" + (revert,) = rt.ledger.changesets_of_kind("demo", "revert") + assert revert["status"] == "pr_requested" and revert["detail"]["reverts"] == sha + (item,) = _items(tmp_path, "open_revert_pr") + assert item["body"]["files"] == {f"knowledge/{PAGE}": before} + (event,) = rt.ledger.events("demo", "pending") + assert event["payload"]["source_reference"] == f"commit {sha}" + assert "+" in event["payload"]["diff_excerpt"] # the content, not a name + assert audit_main(rt, scheduler._pause) == [] # handled once + + +def test_an_exact_revert_disposes_of_the_change_and_both_become_trusted(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + sha, before = _sneak(origin) + audit_main(rt, scheduler._pause) + changeset_id, head = _open_revert(rt, origin, sha, before) + assert rt.ledger.changeset(changeset_id)["status"] == "revert_open" + merged = _merge_revert(rt, origin, head) + assert f"revert_merged {changeset_id} exact=True" in merge.advance(rt, lifecycle) + assert rt.ledger.get_cursor("*", DISPOSED + sha) == merged + assert open_unknown(rt) == [] + assert audit_main(rt, scheduler._pause) == [] # the revert merge is trusted too + assert {sha, merged} <= set(json.loads(rt.ledger.get_cursor("*", TRUSTED))) + + +def test_a_revert_merged_with_other_changes_disposes_of_nothing(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + sha, before = _sneak(origin) + audit_main(rt, scheduler._pause) + changeset_id, head = _open_revert(rt, origin, sha, before, + extra=("knowledge/repos/demo/core/_index.md", "# core\n\nsmuggled\n")) + merged = _merge_revert(rt, origin, head) + assert f"revert_merged {changeset_id} exact=False" in merge.advance(rt, lifecycle) + assert open_unknown(rt) == [sha] + assert any("not the exact revert" in row["reason"] for row in rt.ledger.human_queue("demo")) + (finding,) = audit_main(rt, scheduler._pause) # and the inexact merge is itself unknown + assert merged[:12] in finding + + +def test_a_revert_that_would_conflict_goes_to_people(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + page = origin / "knowledge" / PAGE + sha, _before = _sneak(origin) + _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "a second direct push") + audit_main(rt, scheduler._pause) + record = json.loads(rt.ledger.get_cursor("*", UNKNOWN + sha)) + assert record["state"] == "conflict" + assert all(sha not in item["body"]["title"] for item in _items(tmp_path, "open_revert_pr")) + assert any("would conflict" in row["reason"] for row in rt.ledger.human_queue("demo")) + + +def test_nothing_is_activated_while_an_unknown_change_is_open(tmp_path): + from infermatrix_copilot.kb_service.activate import activate + from test_kb_flow import _add_agents + + rt, lifecycle, scheduler, origin = _setup(tmp_path) + from infermatrix_copilot.kb_service.audit import AUDIT_CURSOR + + _add_agents(rt) + activate(rt, rt.knowledge.fetch()) + rt.ledger.set_cursor("*", AUDIT_CURSOR, rt.knowledge.main_sha()) # the fixture's own setup commit + active = rt.ledger.active_snapshot() + sha, before = _sneak(origin) + scheduler._last = {f"intake:{lifecycle.repo}": rt.clock(), f"release:{lifecycle.repo}": rt.clock(), + "external": rt.clock(), "archive": rt.clock()} + scheduler.tick() + assert rt.ledger.active_snapshot() == active + assert any(e["event"] == "activation_blocked" for e in scheduler.log) + changeset_id, head = _open_revert(rt, origin, sha, before) + _merge_revert(rt, origin, head) + rt.ledger.resume("demo") + scheduler.tick() + assert rt.ledger.active_snapshot() == rt.knowledge.main_sha() != active + + +def test_the_publisher_refuses_to_merge_on_top_of_an_unknown_change(tmp_path): + from infermatrix_copilot.kb_service.activate import activate + from test_kb_flow import _add_agents + from test_kb_publisher import _collect, _merge_ready + + rt, lifecycle, changeset_id, pub, gh, head = _merge_ready(tmp_path) + _add_agents(rt) + activate(rt, rt.knowledge.fetch()) + rt.outbox.refresh_control() # carries the active snapshot + trusted list + origin = tmp_path / "origin" + _git(origin, "checkout", "-q", "main") + sneaky, _ = _sneak(origin) + summary = pub.run_once() + assert summary["failed"] == 1 and gh.prs[42]["state"] == "OPEN" + _collect(rt) + assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" # transient: the service pauses next + + +def test_a_revert_merged_with_a_mode_change_is_not_exact(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + sha, before = _sneak(origin) + audit_main(rt, scheduler._pause) + changeset_id, head = _open_revert(rt, origin, sha, before) + _merge_revert(rt, origin, head, chmod=True) + assert f"revert_merged {changeset_id} exact=False" in merge.advance(rt, lifecycle) + assert open_unknown(rt) == [sha] + + +def test_a_revert_made_by_hand_disposes_of_what_it_undoes(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + page = origin / "knowledge" / PAGE + first, before = _sneak(origin) + second = _land(origin, f"knowledge/{PAGE}", page.read_text() + "\n\n", "second push") + audit_main(rt, scheduler._pause) + assert open_unknown(rt) == sorted([first, second]) + manual = _land(origin, f"knowledge/{PAGE}", before, "revert both by hand") + assert audit_main(rt, scheduler._pause) == [] # recognised as their exact revert + assert open_unknown(rt) == [] + assert rt.ledger.get_cursor("*", DISPOSED + first) == manual == rt.ledger.get_cursor("*", DISPOSED + second) + + +def test_activation_itself_refuses_a_commit_that_passed_no_gate(tmp_path): + import pytest + + from infermatrix_copilot.kb_service.activate import ActivationError, activate + from test_kb_flow import _add_agents + + rt, lifecycle, scheduler, origin = _setup(tmp_path) + _add_agents(rt) + activate(rt, rt.knowledge.fetch()) + _sneak(origin) # not even audited yet + with pytest.raises(ActivationError, match="provenance"): + activate(rt, rt.knowledge.fetch()) # what `kb activate` runs + + +def test_a_revert_must_restore_every_knowledge_path_not_just_governed_ones(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + page = origin / "knowledge" / PAGE + before = page.read_text() + page.write_text(before + "\n\n") + (origin / "knowledge" / "AGENTS.md").write_text("# agents, rewritten around the gate\n") + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "page + AGENTS") + both = _git(origin, "rev-parse", "HEAD") + audit_main(rt, scheduler._pause) + assert _items(tmp_path, "open_revert_pr") == [] # not all governed: people revert it + _land(origin, f"knowledge/{PAGE}", before, "revert only the page") + audit_main(rt, scheduler._pause) + assert both in open_unknown(rt) # AGENTS.md is still unreviewed + (origin / "knowledge" / "AGENTS.md").unlink() + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "revert AGENTS too") + audit_main(rt, scheduler._pause) + assert both not in open_unknown(rt) # the second revert completed it + + +def test_a_revert_by_hand_of_a_non_governed_change_clears_the_block(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + agents = _land(origin, "knowledge/AGENTS.md", "# agents around the gate\n", "AGENTS only") + audit_main(rt, scheduler._pause) + assert open_unknown(rt) == [agents] + (origin / "knowledge" / "AGENTS.md").unlink() + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "revert it") + assert audit_main(rt, scheduler._pause) == [] + assert open_unknown(rt) == [] + + +def test_a_later_edit_to_any_path_of_a_large_change_makes_the_revert_conflict(tmp_path): + rt, lifecycle, scheduler, origin = _setup(tmp_path) + core = origin / "knowledge" / "repos" / "demo" / "core" + for n in range(55): + (core / f"p{n:02d}.md").write_text(f"# p{n}\n") + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "55 pages around the gate") + big = _git(origin, "rev-parse", "HEAD") + later = _land(origin, "knowledge/repos/demo/core/p53.md", "# p53, edited through the gate\n", "gated edit") + merged = rt.ledger.new_changeset_id("demo", "intake") + rt.ledger.stage_intake(rt.lease_owner, "demo", merged, detail={}, status="merged", + verdicts=[], human_reason="", drafted_events=[]) + rt.ledger.update_changeset(merged, merge_sha=later) + audit_main(rt, scheduler._pause) + record = json.loads(rt.ledger.get_cursor("*", UNKNOWN + big)) + assert len(record["paths"]) == 55 + assert record["state"] == "conflict" and "knowledge/repos/demo/core/p53.md" in record["moved"] + assert _items(tmp_path, "open_revert_pr") == [] + + +def test_rewinding_main_past_a_revert_brings_the_unknown_change_back(tmp_path): + import pytest + + from infermatrix_copilot.kb_service.activate import ActivationError, activate + from infermatrix_copilot.kb_service.audit import AUDIT_CURSOR + from test_kb_flow import _add_agents + + rt, lifecycle, scheduler, origin = _setup(tmp_path) + _add_agents(rt) + activate(rt, rt.knowledge.fetch()) + rt.ledger.set_cursor("*", AUDIT_CURSOR, rt.knowledge.main_sha()) + sha, before = _sneak(origin) + audit_main(rt, scheduler._pause) + _land(origin, f"knowledge/{PAGE}", before, "revert by hand") + audit_main(rt, scheduler._pause) + assert open_unknown(rt) == [] # disposed of by the revert + _git(origin, "reset", "-q", "--hard", sha) # main rewound past the revert + with pytest.raises(ActivationError, match="provenance"): + activate(rt, rt.knowledge.fetch()) + + +def test_a_change_touching_a_private_scope_is_never_reverted_in_public(tmp_path): + from dataclasses import replace + + rt, lifecycle, scheduler, origin = _setup(tmp_path) + rt.registry["secret"] = replace(lifecycle, repo="secret", full_name="org/secret", knowledge_dir="repos/secret", + upstream_visibility="private", mode="shadow") + rt.ledger.ensure_repo("secret", "shadow") + page = origin / "knowledge" / PAGE + page.write_text(page.read_text() + "\n\n") + private = origin / "knowledge" / "repos" / "secret" / "notes.md" + private.parent.mkdir(parents=True, exist_ok=True) + private.write_text("# private upstream notes\n") + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "mixed scopes") + mixed = _git(origin, "rev-parse", "HEAD") + audit_main(rt, scheduler._pause) + assert _items(tmp_path, "open_revert_pr") == [] # nothing public about the private scope + assert rt.ledger.events("demo", "pending") == [] + assert json.loads(rt.ledger.get_cursor("*", UNKNOWN + mixed))["state"] == "people" + assert rt.ledger.repo_state("demo")["paused"] # still stopped until people revert it