Skip to content

Commit b98ffbb

Browse files
authored
Merge pull request #232 from JiusiServe/kb/local-upstream-facts
Attest upstream facts in the gate and re-check them before merging (design v8 8.2)
2 parents a905d3e + c6bf5a9 commit b98ffbb

13 files changed

Lines changed: 729 additions & 10 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,17 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
244244
- 测试:`test_kb_local_gate_verifier.py`(取代 `test_kb_gate_verifier.py`;本地门禁上的重放/过期/伪造/非 auto 来源、清单与块表、
245245
混合路径与可执行位、跨仓库、上下文失效、落地树中的悬空引用);`test_kb_publisher.py`(暂停不触碰 PR、恢复后重签合并、
246246
draft 不合并、`close` 重试);`test_kb_external.py`(受治理页面以外的路径从不合并)。
247+
248+
## 2026-09-29 v8:上游事实证明与发布器复核
249+
- 服务:`run_gate(facts=observer)` 在 L1 之后、L2 之前对变更涉及的规则调用 `facts.attest`(观测者为
250+
`upstream_facts.MirrorObserver`:服务自己的上游裸镜像 `<state_dir>/upstream/<repo>.git`(与发版巡检共用)+ 只读
251+
GitHub reader)。必需声明不成立 → `fail`(理由 `upstream fact: …`,不调用付费评审,进入精炼);上游不可读 → `human`。
252+
事实与钉住的 `upstream` 写入 `decision`,`sign_verdict` 签入判定。没有上游(`general`)或不发布的仓库不做证明。
253+
`KbRuntime.upstream_facts(lifecycle)` 提供观测者(`from_env` 装配;测试默认不证明)。
254+
- 发布器:`merge` 在本地门禁通过后、最后一次读控制记录前调用 `facts.recheck`,数据来源独立:发布器自己的镜像
255+
`<publisher_state>/upstream/<repo>.git` 与 `gh api repos/<upstream>/pulls/<n>`;上游来自**发布器自己的**适配器配置
256+
(`upstreams`,只含启用且发布的仓库)。任何不一致、未配置公开上游或上游不可读 → 本轮不合并(`not merging: upstream facts: …`)。
257+
- 服务收到这类拒绝:回到 `pr_open` 下一轮重签,记录 `facts_failing_since`;连续 24 小时仍失败 → `gate_failed` 并转人工。
258+
其他任何回执结束这段连续失败。
259+
- 测试:`test_kb_upstream_facts.py`(声明抽取、符号定义、证明与复核的一致/不一致/不可达/伪造、真实 git 镜像、
260+
门禁在 L2 前失败、发布器一致时合并、不一致或不可达时不合并并在 24 小时后转人工、未配置上游不合并)。

‎doc/architecture/SPEC/knowledge_service.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,12 @@ merge group 逐段验证、暂停清单与 `human-approved` 审批核对已于 2
6363
## 2026-09-29 verify_change 供本地门禁调用
6464
`verify_change` 只接受调用方已验签与绑定的判定(`verdict=`),不再从 PR 评论查找判定,也不读暂停清单;
6565
暂停由签名控制记录表达。
66+
67+
## 2026-09-29 上游事实(facts)
68+
`facts` 从变更涉及的规则中抽取可解析的上游声明:`^[PR #N]` 引用(PR 已合并)、反引号中首段为上游仓库顶层条目的路径
69+
(文件或目录存在)与 `path::Symbol`(文件中以 def/class/赋值定义了每个点分量);首段不是顶层条目的片段路径、`..` 与
70+
`...` 段不算声明。`active` 规则的声明必须成立;退役规则的声明只按观测记录,但退役证据中的 PR 必须已合并。
71+
`attest(claims, observer)` 在上游当前 head 上观测,返回 `upstream={repository, sha}`、事实列表与问题;超过 200 条声明拒绝。
72+
`recheck(upstream, facts, observer)` 由发布器调用:仓库一致、SHA 为 40 位、每条事实重新观测后逐字段相等,
73+
签名了不成立的必需事实或格式错误均为问题;上游不可读(未知 SHA、网络、API 错误)抛 `FactsError`,由调用方重试。
74+
仅标准库。`gate_verifier.verify_change` 不再拒绝带事实的判定(由发布器的 `recheck` 复核)。

‎doc/features/knowledge-service.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。
8181
逐块与一致性判定、上下文未变),通过才合并,合并后再检查落地的提交。只接受 `auto` 判定,
8282
没有 `human-approved` 路径:改动受治理页面以外路径的知识 PR 不会被自动合并,作者会在发现评论里被要求拆分。
8383
绕过发布器直接合并或推送的知识变更由每轮审计发现、暂停并撤回(见上文)。
84+
规则对上游的声明(引用的 PR 已合并、写全的上游路径与 `path::Symbol` 存在)在质量门中对钉住的上游 SHA 证明并签入判定;
85+
发布器合并前用自己的上游镜像与 `gh api` 逐条复核,不一致或上游不可读则不合并,连续 24 小时失败转人工。
8486

8587
切换前的负责人步骤:`main` 必须允许发布器直接合并(不开合并队列、不开自动合并);
8688
按设计 §P3 的顺序关闭 v7 遗留的自动合并并卸载 v7 发布器。

‎src/infermatrix_copilot/kb_service/cli.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ def _publish(args) -> int:
259259
state_dir=state,
260260
author=(author.group(1), author.group(2)),
261261
repo_flags={name: (lc.publishes, lc.auto_merge) for name, lc in registry.items() if lc.enabled},
262+
upstreams={name: lc.full_name for name, lc in registry.items()
263+
if lc.enabled and lc.publishes and lc.full_name},
262264
allow_post=os.environ.get("ALLOW_POST") == "1",
263265
allow_push=os.environ.get("ALLOW_PUSH") == "1",
264266
)

‎src/infermatrix_copilot/kb_service/external.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ def _poll(rt, owner: str, *, force: bool = False) -> list[str]:
286286
external_texts=rt.knowledge.external_texts(main_sha), evidence=evidence,
287287
gateway=rt.gateway, judge=rt.judge, release=release,
288288
repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules,
289-
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files)
289+
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files,
290+
facts=rt.upstream_facts(lifecycle))
290291
detail.update(source="auto", generator="human", judge=rt.judge.label(), decision=decision.to_dict(),
291292
**_lifecycle_bookkeeping(decision.l1, head_files))
292293
status = {"pass": "pr_open", "fail": "failed", "human": "human"}[decision.status]

‎src/infermatrix_copilot/kb_service/gate.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ class GateDecision:
7878
l1: ChangesetResult
7979
blocks: list[BlockVerdict] = field(default_factory=list)
8080
consistency: list[dict] = field(default_factory=list)
81+
upstream: dict = field(default_factory=dict) # {repository, sha} the facts were observed on
82+
facts: list[dict] = field(default_factory=list)
8183

8284
def to_dict(self) -> dict:
8385
return {
@@ -86,6 +88,7 @@ def to_dict(self) -> dict:
8688
"external_refs": [list(ref) for ref in self.l1.external_refs],
8789
"blocks": [b.to_dict() for b in self.blocks],
8890
"consistency": self.consistency,
91+
"upstream": self.upstream, "facts": self.facts,
8992
}
9093

9194

@@ -210,11 +213,26 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
210213
external_texts: dict[str, str], evidence: list[dict], gateway: ModelGateway,
211214
judge: ModelRole, release: str, repo_dir: str,
212215
protected_rules: tuple[str, ...] = (),
213-
retire_ratio: float = 0.10, max_files: int = 50) -> GateDecision:
216+
retire_ratio: float = 0.10, max_files: int = 50, facts=None) -> GateDecision:
217+
"""``facts`` observes the repository's public upstream (None: the
218+
repository has no upstream to attest, or it publishes nothing)."""
214219
l1 = check_changeset(base, head, changes, external_texts=external_texts, release=release)
215220
reasons: list[str] = []
216221
if not l1.ok:
217222
return GateDecision("fail", [f"L1: {i.code} {i.path} {i.detail}" for i in l1.issues], l1)
223+
upstream, signed_facts = {}, []
224+
if facts is not None:
225+
from ..knowledge_service.facts import FactsError, attest
226+
from .upstream_facts import change_claims
227+
228+
try:
229+
claims = change_claims(head, l1.blocks, facts.top_level(facts.head()))
230+
upstream, signed_facts, problems = attest(claims, facts) if claims else ({}, [], [])
231+
except FactsError as exc:
232+
return GateDecision("human", [f"upstream facts could not be checked: {exc}"], l1)
233+
if problems:
234+
return GateDecision("fail", [f"upstream fact: {p}" for p in problems], l1,
235+
upstream=upstream, facts=signed_facts)
218236
if l1.external_refs:
219237
reasons.append("references outside knowledge/ need a companion PR: "
220238
+ ", ".join(f"{rid}@{path}" for rid, path in l1.external_refs))
@@ -234,17 +252,20 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
234252
for b in l1.blocks]
235253
if any(b.verdict == "fail" for b in blocks):
236254
return GateDecision("fail", reasons + [f"L2 rejected {b.block.rule_id or b.block.path}"
237-
for b in blocks if b.verdict == "fail"], l1, blocks)
255+
for b in blocks if b.verdict == "fail"], l1, blocks,
256+
upstream=upstream, facts=signed_facts)
238257
owner_dirs = sorted({_owner_dir(b.path) for b in l1.blocks})
239258
consistency = check_consistency(head, owner_dirs, gateway=gateway, judge=judge)
240259
if any(item["verdict"] == "conflict" for item in consistency):
241-
return GateDecision("fail", reasons + ["change set is inconsistent"], l1, blocks, consistency)
260+
return GateDecision("fail", reasons + ["change set is inconsistent"], l1, blocks, consistency,
261+
upstream, signed_facts)
242262
if any(b.verdict == "human" for b in blocks):
243263
reasons.append("L2 was not sure about " + ", ".join(
244264
b.block.rule_id or b.block.path for b in blocks if b.verdict == "human"))
245265
if any(item["verdict"] == "unsure" for item in consistency):
246266
reasons.append("consistency check was not sure")
247-
return GateDecision("human" if reasons else "pass", reasons, l1, blocks, consistency)
267+
return GateDecision("human" if reasons else "pass", reasons, l1, blocks, consistency,
268+
upstream, signed_facts)
248269

249270

250271
def _is_active(section) -> bool:

‎src/infermatrix_copilot/kb_service/merge.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
IN_FLIGHT = ("pr_requested", "pr_open", "merge_requested", "rebuild_needed")
3333
# margin for the publisher's clock when deciding an item has certainly expired
3434
CLOCK_SKEW = 5 * 60
35+
# the publisher could not confirm the signed upstream facts: signed again and
36+
# retried each round, to people once it kept failing this long (design v8 §8.2)
37+
FACTS_MERGE = "upstream facts:"
38+
FACTS_ESCALATE_AFTER = 24 * 3600
3539
# local-gate refusals the service answers by rebuilding on current main
3640
REBUILD_GATE = ("context changed since the verdict", "pages changed since the consistency judgement",
3741
"does not merge cleanly", "signed patch manifest does not match")
@@ -194,6 +198,22 @@ def _apply_merge_ack(rt, changeset: dict, ack: dict) -> None:
194198
ack.get("problems") or [])
195199
return
196200
text = " ".join([str(ack.get("error") or ""), *map(str, ack.get("problems") or [])])
201+
detail = {k: v for k, v in changeset["detail"].items() if k != "facts_failing_since"}
202+
if FACTS_MERGE in text:
203+
since = float(changeset["detail"].get("facts_failing_since") or rt.clock())
204+
if rt.clock() - since >= FACTS_ESCALATE_AFTER:
205+
rt.ledger.update_changeset(changeset_id, status="gate_failed", pending_item=None,
206+
detail={**detail, "gate_problems": [text[:400]]})
207+
rt.ledger.enqueue_human(repo, f"PR #{changeset['pr_number']}: the publisher could not confirm its "
208+
f"upstream facts for 24 hours: {text[:300]}", changeset_id)
209+
_outcome(rt, changeset, "gate_failed", problems=[text[:400]])
210+
else:
211+
rt.ledger.update_changeset(changeset_id, status="pr_open", pending_item=None,
212+
detail={**detail, "facts_failing_since": since})
213+
return
214+
if detail != changeset["detail"]: # any other answer ends a run of fact failures
215+
rt.ledger.update_changeset(changeset_id, detail=detail)
216+
changeset = rt.ledger.changeset(changeset_id)
197217
if any(m in text for m in REBUILD_GATE):
198218
rt.ledger.update_changeset(changeset_id, status="rebuild_needed", pending_item=None,
199219
detail={**changeset["detail"], "rebuild_because": text[:500]})
@@ -243,7 +263,7 @@ def sign_verdict(rt, changeset: dict) -> dict:
243263
verdict = build_verdict(
244264
repository=knowledge_repository(), pr=int(changeset["pr_number"]), head_sha=changeset["head_sha"],
245265
context_base_sha=detail["base_sha"], manifest=manifest, blocks=blocks, consistency=consistency,
246-
release=detail["release"], upstream={}, facts=[],
266+
release=detail["release"], upstream=decision.get("upstream") or {}, facts=decision.get("facts") or [],
247267
models={"generator": detail["generator"], "judge": detail["judge"]},
248268
source="auto", issued_at=rt.clock(),
249269
)

‎src/infermatrix_copilot/kb_service/publisher.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ class Publisher:
199199
git_remote: str = "" # default https://github.com/<repository>.git
200200
clock: Callable[[], float] = time.time
201201
log: list[dict] = field(default_factory=list)
202+
upstreams: Mapping[str, str] = field(default_factory=dict) # repo -> public upstream, from OUR config
203+
observer: Callable[[str, str], Any] | None = None # (repo, upstream) -> facts observer
202204

203205
# local records -------------------------------------------------------------
204206
def _done_path(self, item_id: str) -> Path:
@@ -524,6 +526,32 @@ def _recheck_control(self) -> None:
524526
except (OutboxError, OSError, ValueError, KeyError, TypeError) as exc:
525527
raise PublishError(f"not merging: {exc}") from exc
526528

529+
def _recheck_facts(self, repo: str, verdict: dict) -> None:
530+
"""Observe every signed upstream fact again from the upstream itself (our
531+
own mirror and ``gh api``, never the service's). Any difference, or an
532+
upstream we cannot read, means no merge this round (design v8 §8.2)."""
533+
from ..knowledge_service.facts import FactsError, recheck
534+
535+
facts = verdict.get("facts") or []
536+
if not facts:
537+
return
538+
upstream = self.upstreams.get(repo, "")
539+
if not upstream:
540+
raise PublishError(f"not merging: upstream facts: {repo} has no public upstream in our configuration")
541+
observer = (self.observer or self._mirror_observer)(repo, upstream)
542+
try:
543+
problems = recheck(verdict.get("upstream") or {}, facts, observer)
544+
except FactsError as exc:
545+
raise PublishError(f"not merging: upstream facts: {exc}") from exc
546+
if problems:
547+
raise PublishError("not merging: upstream facts: " + "; ".join(problems[:5]))
548+
549+
def _mirror_observer(self, repo: str, upstream: str):
550+
from .upstream_facts import MirrorObserver
551+
552+
return MirrorObserver(self.state_dir / "upstream" / f"{repo}.git", upstream,
553+
lambda number: json.loads(self.github.gh("api", f"repos/{upstream}/pulls/{number}")))
554+
527555
def _do_merge(self, item: OutboxItem) -> dict:
528556
"""The local gate on the exact merged tree, then the merge (design v8 §8.2)."""
529557
from . import local_gate
@@ -566,6 +594,7 @@ def _do_merge(self, item: OutboxItem) -> dict:
566594
break
567595
else:
568596
raise PublishError(f"main kept moving while PR #{number} was checked; retried next round")
597+
self._recheck_facts(item.repo, verdict)
569598
self._recheck_control()
570599
intent = self._intent_path(item.id)
571600
intent.parent.mkdir(parents=True, exist_ok=True)

‎src/infermatrix_copilot/kb_service/runtime.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class KbRuntime:
4646
publisher_public_key: object | None = None
4747
traces: object | None = None # trace_store.TraceStore: decisions and outcomes (model calls via the gateway)
4848
release_label: Callable[[str], str] = field(default=lambda repo: "")
49+
# the repository's upstream as the gate observes it (None: no facts attested)
50+
upstream_facts: Callable[[RepoLifecycle], object | None] = field(default=lambda lifecycle: None)
4951

5052
@classmethod
5153
def from_env(cls, settings, *, state_dir: Path | None = None) -> "KbRuntime":
@@ -74,13 +76,15 @@ def from_env(cls, settings, *, state_dir: Path | None = None) -> "KbRuntime":
7476
from ..trace_store import TraceStore
7577

7678
traces = TraceStore(state_dir / "traces")
79+
github = GitHubReader()
7780
return cls(
81+
upstream_facts=lambda lifecycle: service_observer(state_dir, lifecycle, github),
7882
publisher_public_key=publisher_key, traces=traces,
7983
state_dir=state_dir, ledger=ledger, registry=registry,
8084
gateway=ModelGateway(settings, recorder=trace_recorder(traces)),
8185
generator=generator, judge=judge,
8286
knowledge=KnowledgeRepo(Path(os.environ.get("KB_KNOWLEDGE_CLONE") or state_dir / "knowledge-repo")),
83-
github=GitHubReader(), outbox=outbox,
87+
github=github, outbox=outbox,
8488
)
8589

8690
# -- helpers -------------------------------------------------------------
@@ -296,7 +300,8 @@ def gate_and_stage(rt: KbRuntime, lifecycle: RepoLifecycle, owner: str, *, kind:
296300
base=base, head=head, changes=changes_between(base, head), external_texts=external,
297301
evidence=evidence, gateway=rt.gateway, judge=rt.judge, release=release,
298302
repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules,
299-
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files)
303+
retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files,
304+
facts=rt.upstream_facts(lifecycle))
300305
if force_human and decision.status == "pass":
301306
decision.status = "human"
302307
decision.reasons.append(force_human)
@@ -392,6 +397,19 @@ def _pr_body(changeset_id: str, detail: dict) -> str:
392397
return "\n".join(lines)
393398

394399

400+
def service_observer(state_dir: Path, lifecycle: RepoLifecycle, github: GitHubReader):
401+
"""The service's view of a public upstream: its own mirror (shared with the
402+
release sweep) and the read-only GitHub reader. A repository without an
403+
upstream, or one that publishes nothing, attests no facts."""
404+
if not lifecycle.full_name or not lifecycle.publishes:
405+
return None
406+
from .upstream_facts import MirrorObserver
407+
408+
full_name = lifecycle.full_name
409+
return MirrorObserver(state_dir / "upstream" / f"{lifecycle.repo}.git", full_name,
410+
lambda number: github.get(f"/repos/{full_name}/pulls/{number}"))
411+
412+
395413
def calibration_record(rt: KbRuntime, lifecycle: RepoLifecycle) -> dict | None:
396414
raw = rt.ledger.get_cursor(lifecycle.repo, "calibration")
397415
return json.loads(raw) if raw else None

0 commit comments

Comments
 (0)