From 8d362c4a05cc6da9e999d33d8b5db41e4738ea71 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:45:59 +0200 Subject: [PATCH 01/12] Plan OCR 1.11.1 evidence trust release --- PLANS.md | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 1 deletion(-) diff --git a/PLANS.md b/PLANS.md index 6d4abc7..e737a67 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,4 +6,237 @@ before handoff or commit. Completed stable plans are indexed in ## Active Work -No active or blocked repository work. +### Toolkit 0.8.7 — OCR 1.11.1, evidence integrity, and efficient built-in MCP + +- **Status:** active +- **Plan Origin:** plan_mode_approved +- **Release classification:** release-required; stable delivery is release-deferred +- **Target stable version:** 0.8.7 +- **Branch:** `codex/v0.8.7-ocr-1.11.1-evidence-trust` +- **Delivery boundary:** a fully implemented, green Draft pull request. Merge, release PR, + tags, TestPyPI/PyPI publication, stable release, issue closure, and milestone closure are + outside this cycle until an external configured environment qualifies the exact Draft head. + +#### Goal + +Qualify and adopt exact OCR 1.11.1, repair evidence comparison integrity, prevent OCR +multi-round wording from becoming toolkit validation, make admitted evidence efficiently +searchable with an explicit completeness check, admit protected same-revision GitLab CI +outcomes as provider-neutral context, and complete the related documentation work without +weakening DLP, privacy, approval, or immutable-ref boundaries. + +#### Requested Scope + +- Restore OCR 1.11.1 qualification through #163 and complete dependency tracker #158. +- Fix #159 so bounded base/head admission cannot synthesize dependency deltas. +- Fix #162 so repeated OCR findings remain unverified reports unless current evidence proves + them; preserve the default `medium` effort and private reasoning/session boundary. +- Implement #160 as two dedicated built-in MCP tools: bounded search and authoritative + coverage evaluation, with a model-facing routing contract. +- Implement #161 using protected-target context-policy v3 and provider-neutral same-revision + CI evidence. +- Complete #157 with stable PyPI, supported-Python, and license badges. +- Reconcile backlog, public contracts, test-evidence ownership, changelog, Draft PR, issue + checklists, and milestone state. + +#### Requirement Traceability + +| Requirement | Outcome | Work queue | Acceptance evidence | +| --- | --- | --- | --- | +| REQ-001 | OCR 1.11.1 qualification separates budget, threshold-crossing, and below-threshold grouping behavior | WQ-02, WQ-03 | hosted compatibility artifact, strict fixture tests | +| REQ-002 | Runtime accepts only exact OCR 1.11.1 and records verified hashes plus semantic audit | WQ-05 | manifest/evidence validation, preflight/docs tests, isolated local binary checks | +| REQ-003 | Base/head truncation cannot create synthetic add/remove deltas | WQ-04 | oversized unchanged Go and second lock fixture, add/delete/change and hostile-readback tests | +| REQ-004 | Multi-round survivor wording cannot become toolkit confirmation | WQ-05 | two-round controlled peer, public unverified status, approval/privacy tests | +| REQ-005 | OCR can efficiently discover admitted facts without arbitrary repository search | WQ-06 | `ocr_toolkit_evidence_search` schema, routing and installed-artifact tests | +| REQ-006 | OCR can distinguish authoritative absence from unknown scope | WQ-06 | `ocr_toolkit_evidence_coverage` joins, no-match and partial-coverage tests | +| REQ-007 | OCR receives deterministic, efficient instructions for summary/list/get/search/coverage/context tools | WQ-06 | tool descriptions, bootstrap decision table, controlled call-sequence qualification | +| REQ-008 | New tools have exact count-only receipt and GitLab-summary attribution without content leakage | WQ-06 | receipt v6, action receipt v2, reconciliation/DLP/approval tests | +| REQ-009 | Protected same-revision GitLab CI outcomes become bounded provider-neutral evidence only | WQ-07 | policy v3, stable API snapshot, store/MCP and negative provider tests | +| REQ-010 | Public docs, badges, backlog, roadmap, strategy, and changelog describe added, changed, removed, and unchanged contracts | WQ-08 | documentation contracts and Towncrier draft | +| REQ-011 | Final Draft is clean and green but remains deferred for exact-head external LLM qualification | WQ-09 | final local gate, hosted CI, Draft/remote/worktree readback | + +#### Explicit Non-Goals + +- No support or runtime fallback for OCR 1.11.0 or older releases. +- No real LLM/provider calls in this local environment. +- No automatic cap, tool choice, model, or protocol inference from provider metadata. +- No arbitrary repository grep, model-loop network, second evidence/context store, second OCR + pass, or new DDL/testcontainer parser for evidence search. +- No CI log, artifact, job URL, runner, user, raw identifier, provider payload, reasoning, + prompt, tool arguments, or tool results in public output or receipts. +- No approval, suppression, lifecycle, or severity authority from remediation text, search + results, coverage hints, CI outcomes, OCR survivor wording, or tool-call counts. +- No merge, Ready transition, issue/milestone closure, release branch, tag, or publication. + +#### Constraints + +- Preserve Python 3.12–3.14 and standard-library-only runtime code. +- Fixtures and public material remain private-safe. All untrusted values are bounded before + normalization, persistence, search indexing, MCP response, and publication. +- Provider-specific acquisition remains at the GitLab edge; evidence/store/MCP/receipt and + approval layers remain provider-neutral. +- Existing context policy v1/v2 remain valid without CI outcomes. This policy compatibility is + separate from exact OCR runtime compatibility. +- The only mandatory model-time evidence call remains one successful summary. Search and + coverage are conditional and must not create needless calls for small reviews. +- Every Python slice runs formatter before self-review and passes repository-wide format check. +- New runtime functions/classes and new tests receive purpose-focused docstrings. + +#### Inputs And Sources + +- Approved conversation plan and user decisions: green Draft only; protected policy v3; + include #157; add dedicated search and coverage MCP tools; preserve efficient OCR routing. +- GitHub issues #157–#163 and OCR dependency tracker #158. +- OCR v1.11.1 release/source diff and compatibility workflow run `33391721404`. +- OCR v1.11.1 pins: Linux amd64 + `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`, Darwin arm64 + `5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`, checksum file + `8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b`. +- Canonical owners: `docs/engineering/project_principles.md`, `docs/development.md`, + `docs/release.md`, `docs/configuration.md`, `docs/gitlab.md`, `docs/operations.md`, + `docs/review-context.md`, `docs/security.md`, and `docs/codex/TASKS_BACKLOG.md`. + +#### User Decisions And Answers + +- Delivery ends at a green Draft and external exact-head qualification blocker. +- CI outcome authority is owned only by protected-target policy v3. +- Documentation issue #157 is part of v0.8.7. +- Built-in MCP gains two separate tools rather than another union action. +- OCR must receive explicit routing guidance and qualification proving correct and efficient + use; optional tools are not forced on every review. +- Current GitLab technical-summary format stays stable and emits only non-zero tool names. + +#### Completed Baseline State + +- `main` is released v0.8.6 and `.next-version` selects 0.8.7. +- Existing evidence store v4 exposes one built-in MCP with summary/list/get; context uses fixed + context_list/context_get; receipt v5 reconciles count-only evidence usage. +- OCR 1.11.0 is the exact current runtime and local `/opt/homebrew/bin/ocr` version. +- Workflow audit with engineering-workflow 0.9.0 found canonical files and navigation indexes; + no target workflow migration is required. +- OCR v1.11.1 assets verify, while current qualification fails because its probes assume + pre-1.11.1 small-change grouping behavior. + +#### Current Work Queue + +| Queue | Status | Work | +| --- | --- | --- | +| WQ-01 | done | Materialize plan, create milestone/issue relationships, planning commit, and fidelity check | +| WQ-02 | in_progress | Implement #163 version-gated qualification harness, commit, initial push, and Draft PR | +| WQ-03 | pending | Rerun hosted OCR 1.11.1 qualification and accept only exact structural evidence | +| WQ-04 | pending | Implement #159 pair-aware comparison and impossible-state rejection | +| WQ-05 | pending | Implement #162 multi-round contract and adopt OCR 1.11.1 pins/security/rules/local binary | +| WQ-06 | pending | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | +| WQ-07 | pending | Implement #161 protected policy v3 and same-revision GitLab CI evidence | +| WQ-08 | pending | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | +| WQ-09 | pending | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | + +#### Locked Decisions + +- Search tool: NFKC/casefold query of 1–128 characters and at most eight tokens; no regex, + wildcard, operators, controls, bidi/format characters, or broad empty search. Search only + DLP-admitted source paths, identities, and per-kind allowlisted scalar values. +- Coverage tool: exact kind/ref plus optional exact component/path. Missing mappings and any + incomplete scope yield unknown; `absence_authoritative` requires complete scope, zero + matches, and no truncation. +- Routing: summary once, list for known kinds/deltas, search for unknown location/identity, get + selected records, coverage only before a negative claim, context_list before context_get, + and stop when evidence is sufficient. No forced `tool_choice`. +- Receipt v6 replaces v5 for current results. Private action receipt v2 counts exactly + summary/list/get/search/coverage; exact OCR by-tool/capability/server reconciliation is + required. Only closed numeric counts may reach Technical details. +- CI policy v3 uses `required` default false, `max_age_seconds` default 86400 with range + 60–604800, unique exact check names, and normalized protected path prefixes. +- OCR survivor state is an unverified report. Toolkit guidance mitigates but does not claim to + rewrite upstream prompt semantics. + +#### Verification + +- Focused grouping, compatibility, evidence comparison/store/readback, MCP/routing, receipt, + posting/approval/DLP, context-policy/provider, installed-artifact, documentation, and + changelog tests as mapped to each queue item. +- Local no-LLM OCR 1.11.1 version/help, rules, confinement, and grouping/background previews in + an isolated temporary HOME after checksum verification. +- Before each commit: `scripts/quality.sh format` for Python changes, + `uv run --frozen ruff format --check .`, focused tests, full slice diff/trust review, and + `git diff --check`. +- Once on final local head: `scripts/quality.sh check`, scoped coverage floors, + `PYTHONPATH=src python scripts/ocr_compat.py validate`, lock/manifest checks, Towncrier draft, + `scripts/gitleaks.sh`, and `git diff --check`. +- Hosted PR workflows own OS/Python matrix, packages, dependencies, Security, and CodeQL. +- External configured qualification owns real OCR 1.11.1 LLM behavior, multi-round correction, + search/get and no-match/coverage routing, same-revision CI evidence, and leakage audit. + +#### Latest Validation Results + +- 2026-08-31: clean branch created from remote `main` at + `d98763fc1cb3c14079a4b79911be57c63c7f767b`. +- 2026-08-31: engineering-workflow 0.9.0 audit found all canonical workflow files and required + navigation indexes; ignored `.quality-logs` were audit noise only. +- 2026-08-31: milestone `v0.8.7` created; #157–#163 assigned to `xeonvs`; dependency and + production-slice boundaries recorded on #158, #160, and #161. +- No implementation validation has run yet. + +#### Risks And Recovery + +- If hosted OCR qualification still fails, do not promote OCR pins; update #158 with the closed + failed subprobe/counts and keep WQ-03 blocked. +- If pair-aware comparison cannot prove source completeness, degrade to unknown rather than + emit a one-sided delta. +- If search cannot map a value safely, omit it from the index. If coverage applicability is + ambiguous, return unknown and prohibit an absence claim. +- If MCP counts do not reconcile, receipt v6 is invalid and auto-approval remains blocked; + never coerce a new tool into a legacy action count. +- If GitLab CI snapshot mutates, paginates beyond bounds, or has ambiguous retries, admit no + trusted outcome and apply required/optional degradation policy. +- If local OCR replacement validation fails, restore the verified previous binary and keep the + runtime pin work blocked; never modify OCR config, credentials, or user HOME. +- Evidence-backed CI fixes use a new logical commit after the same focused tests and review. + +#### Resume Point + +Begin WQ-02 after committing this planning checkpoint: inspect the current compatibility +harness owners, add strict v1.11.0 baseline and v1.11.1 grouping probes, and run focused tests. + +#### Plan Fidelity Check + +- [x] Every approved outcome has a stable requirement ID and queue owner. +- [x] Release/Draft boundary, exact OCR target, checksums, and external qualification are explicit. +- [x] Data-flow, DLP, provider-neutral ownership, approval, and failure semantics are explicit. +- [x] Rejected alternatives and non-goals prevent compatibility fallbacks and extra infrastructure. +- [x] Validation maps production owners, installed boundaries, hosted ownership, and local limits. +- [x] Resume point names the first safe unfinished action. + +#### Reconciliation Check + +- [x] Live `main`, released v0.8.6, `.next-version`, open issues, local OCR, and workflow owners + were inspected before implementation. +- [x] No pre-existing user changes are present in the worktree. +- [x] Milestone and issue relationships agree with this plan. +- [ ] Draft PR and compatibility artifact agree with this plan. +- [ ] Final implementation, docs, changelog, backlog, tests, and remote state agree. + +#### Closure Gate + +- [ ] All REQ and WQ items are done or explicitly out of scope. +- [ ] Final local and hosted checks are current for the exact Draft head. +- [ ] Draft remains open, mergeable, and without unresolved review threads. +- [ ] External qualification remains a truthful post-close/deferred delivery item. +- [ ] `scripts/plan_lifecycle.py check` accepts the final state before closure or archival. + +#### Post-Close Delivery + +An external agent must start from the exact green Draft head, checksum-verified OCR 1.11.1, +an isolated HOME, and a configured LLM endpoint. It runs real `ocr review`, verifies small and +threshold-crossing grouping, multi-round contradiction handling, search/get and no-match/coverage +routing, same-revision CI evidence, cleanup, and absence of raw prompts/reasoning/provider/MCP +content in public artifacts. Only after that evidence may the owner move the Draft toward the +ordinary protected merge and stable v0.8.7 release process. + +#### Handoff Notes + +- Do not repeat local development or qualify OCR 1.11.0 as a supported runtime. +- OCR v1.11.0 may appear only as historical comparison evidence. +- Do not publish external qualification prompts, reasoning, tool arguments/results, provider + bodies, session files, credentials, or private fixture content. +- Keep #157–#163 and milestone `v0.8.7` open in the Draft-only state. From ffe1233242eb1c102b1cf34306d0c106bad60806 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:05:20 +0200 Subject: [PATCH 02/12] Qualify OCR 1.11.1 grouping behavior --- PLANS.md | 13 ++- scripts/ocr_compat.py | 204 ++++++++++++++++++++++++++++++++++++--- tests/test_ocr_compat.py | 78 +++++++++++++-- 3 files changed, 265 insertions(+), 30 deletions(-) diff --git a/PLANS.md b/PLANS.md index e737a67..f8d5f15 100644 --- a/PLANS.md +++ b/PLANS.md @@ -122,7 +122,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | Queue | Status | Work | | --- | --- | --- | | WQ-01 | done | Materialize plan, create milestone/issue relationships, planning commit, and fidelity check | -| WQ-02 | in_progress | Implement #163 version-gated qualification harness, commit, initial push, and Draft PR | +| WQ-02 | in_progress | #163 harness is locally complete; commit, initial push, and Draft PR remain | | WQ-03 | pending | Rerun hosted OCR 1.11.1 qualification and accept only exact structural evidence | | WQ-04 | pending | Implement #159 pair-aware comparison and impossible-state rejection | | WQ-05 | pending | Implement #162 multi-round contract and adopt OCR 1.11.1 pins/security/rules/local binary | @@ -175,7 +175,12 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. navigation indexes; ignored `.quality-logs` were audit noise only. - 2026-08-31: milestone `v0.8.7` created; #157–#163 assigned to `xeonvs`; dependency and production-slice boundaries recorded on #158, #160, and #161. -- No implementation validation has run yet. +- 2026-08-31: `tests/test_ocr_compat.py` passed with 88 tests after repository formatting; + `git diff --check` passed. +- 2026-08-31: checksum-verified temporary OCR 1.11.1 passed the full local no-LLM contract + probe. Evidence separates local single-file, bundle-all, and high-churn per-file grouping + from the four-file semantic grouping request; it also records two medium review rounds, + filter-survivor wording, partial budget coverage 2/3, completion cap, and max-tools behavior. #### Risks And Recovery @@ -195,8 +200,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Begin WQ-02 after committing this planning checkpoint: inspect the current compatibility -harness owners, add strict v1.11.0 baseline and v1.11.1 grouping probes, and run focused tests. +Commit the reviewed WQ-02 qualification harness, push the two initial commits, open the Draft +PR, and dispatch the exact-branch OCR 1.11.1 compatibility workflow for WQ-03. #### Plan Fidelity Check diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index ac819da..f1cd1b6 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -370,14 +370,19 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: ) if not isinstance(required_flags, list) or "--max-tokens-budget" not in required_flags: _fail(f"evidence does not qualify the review budget flag for {version}") - if budget_probe != { + expected_budget_probe: dict[str, object] = { "budget": 30_000, "completed": 2, "failed_budget": 1, "partial_findings_preserved": True, "result": "passed", "selected": 3, - }: + } + if _version(version) >= (1, 11, 1): + expected_budget_probe.update( + {"grouping_requests": 0, "grouping_strategy": "per_file"} + ) + if budget_probe != expected_budget_probe: _fail(f"evidence does not qualify partial review budget behavior for {version}") if _version(version) >= (1, 10, 0): contracts = evidence.get("contracts") @@ -404,8 +409,24 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: } if _version(version) >= (1, 10, 2): expected_grouping_probe["grouping_completion_cap"] = 16_384 + if _version(version) >= (1, 11, 1): + expected_grouping_probe.update( + { + "files": 4, + "prior_finding_semantics": "filter_survivors_as_confirmed", + } + ) if contracts.get("semantic_grouping_probe") != expected_grouping_probe: _fail(f"evidence does not qualify semantic grouping behavior for {version}") + if _version(version) >= (1, 11, 1) and contracts.get("small_change_grouping_probe") != { + "grouping_requests": 0, + "high_churn": "per_file", + "low_churn": "bundle_all", + "result": "passed", + "single_file": "per_file", + "threshold_files": 4, + }: + _fail(f"evidence does not qualify small-change grouping behavior for {version}") if contracts.get("completion_cap_probe") != { "explicit": 4_096, "inherited": 16_384, @@ -781,6 +802,7 @@ class _StubHandler(http.server.BaseHTTPRequestHandler): main_mode = "findings" completion_caps: list[object] = [] request_stages: list[str] = [] + prior_finding_semantics: set[str] = set() @staticmethod def _message_contents(messages: list[Any]) -> list[str]: @@ -802,6 +824,16 @@ def _grouping_files(cls, messages: list[Any]) -> list[str]: cls.grouping_inventories.append(entries) return [entry.path for entry in entries] + @classmethod + def _is_grouping_request(cls, messages: list[Any]) -> bool: + """Distinguish the tool-free grouping task from OCR's tool-free plan task.""" + + return any( + content.startswith("Group the following changed files:\n\n") + and "\n\nRespond with a JSON array:\n" in content + for content in cls._message_contents(messages) + ) + @classmethod def _review_path(cls, messages: list[Any]) -> str | None: """Extract the first path from OCR's public review-files XML block.""" @@ -841,7 +873,7 @@ def do_POST(self) -> None: if isinstance(function, dict) and isinstance(function.get("name"), str): tool_names.add(function["name"]) message: dict[str, Any] - if not tool_names: + if not tool_names and type(self)._is_grouping_request(messages): paths = type(self)._grouping_files(messages) if not paths: self.send_error(400) @@ -854,6 +886,13 @@ def do_POST(self) -> None: message = {"role": "assistant", "content": json.dumps(groups)} finish_reason = "stop" stage = "grouping" + elif not tool_names: + message = { + "role": "assistant", + "content": "Summary: Review the changed code.\n\nIssues\n(none)", + } + finish_reason = "stop" + stage = "plan" elif "approve_all_comments" in tool_names: message = { "role": "assistant", @@ -896,6 +935,8 @@ def do_POST(self) -> None: for message in messages ) later_round = any("### Previously Confirmed Findings" in item for item in contents) + if later_round: + type(self).prior_finding_semantics.add("filter_survivors_as_confirmed") if not prior_comment and not later_round: path = type(self)._review_path(messages) if path is None: @@ -990,6 +1031,7 @@ def _stub_gateway( _StubHandler.main_mode = main_mode _StubHandler.completion_caps = [] _StubHandler.request_stages = [] + _StubHandler.prior_finding_semantics = set() server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -1148,13 +1190,20 @@ def _budget_result_probe(binary: Path, version: str, directory: Path) -> dict[st _run(["git", "init", "--initial-branch=main"], cwd=repo, env=git_env) _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=git_env) _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=git_env) - for name in ("first.py", "second.py", "third.py"): - (repo / name).write_text("def value():\n return 1\n", encoding="utf-8") + paths = ("first.py", "second.py", "third.py") + for name in paths: + (repo / name).write_text( + "def value():\n return 1\n" + "# baseline\n" * 34, + encoding="utf-8", + ) _run(["git", "add", "first.py", "second.py", "third.py"], cwd=repo, env=git_env) _run(["git", "commit", "-m", "budget baseline"], cwd=repo, env=git_env) base = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() - for name in ("first.py", "second.py", "third.py"): - (repo / name).write_text("def value():\n return 2\n", encoding="utf-8") + for name in paths: + (repo / name).write_text( + "def value():\n return 2\n" + "# changed\n" * 34, + encoding="utf-8", + ) _run(["git", "commit", "-am", "budget changes"], cwd=repo, env=git_env) head = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() @@ -1193,6 +1242,8 @@ def _budget_result_probe(binary: Path, version: str, directory: Path) -> dict[st cwd=repo, env=env, ) + grouping_inventories = list(_StubHandler.grouping_inventories) + request_stages = list(_StubHandler.request_stages) try: sample = json.loads(output) except json.JSONDecodeError as exc: @@ -1230,7 +1281,17 @@ def _budget_result_probe(binary: Path, version: str, directory: Path) -> dict[st comments = sample.get("comments") if not isinstance(comments, list) or len(comments) != 2: _fail("budget-limited review did not preserve its completed finding") - return { + _validate_file_groups(sample.get("groups"), set(paths)) + groups = sample["groups"] + if any(len(group.get("files", [])) != 1 for group in groups): + _fail("budget-limited review did not preserve per-file group boundaries") + grouping_requests = request_stages.count("grouping") + expected_grouping_requests = 0 if _version(version) >= (1, 11, 1) else 1 + if grouping_requests != expected_grouping_requests: + _fail("budget-limited review emitted an unexpected grouping request count") + if expected_grouping_requests == 0 and grouping_inventories: + _fail("budget-limited small-change review unexpectedly emitted a grouping inventory") + result: dict[str, object] = { "budget": 30_000, "completed": 2, "failed_budget": 1, @@ -1238,6 +1299,9 @@ def _budget_result_probe(binary: Path, version: str, directory: Path) -> dict[st "result": "passed", "selected": 3, } + if _version(version) >= (1, 11, 1): + result.update({"grouping_requests": 0, "grouping_strategy": "per_file"}) + return result def _run_numeric_preview_case( @@ -1537,7 +1601,7 @@ def _numeric_cli_probe( def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dict[str, object]: - """Drive one real two-file group through grouping and medium review rounds.""" + """Drive a threshold-crossing group through grouping and medium review rounds.""" root = directory / "semantic-grouping-probe" root.mkdir() @@ -1547,7 +1611,11 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic _run(["git", "init", "--initial-branch=main"], cwd=repo, env=git_env) _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=git_env) _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=git_env) - paths = ("first.py", "second.py") + paths = ( + ("01-first.py", "02-second.py", "03-third.py", "04-fourth.py") + if _version(version) >= (1, 11, 1) + else ("first.py", "second.py") + ) for path in paths: (repo / path).write_text("def value():\n return 1\n", encoding="utf-8") _run(["git", "add", *paths], cwd=repo, env=git_env) @@ -1593,6 +1661,7 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic stages = list(_StubHandler.request_stages) completion_caps = list(_StubHandler.completion_caps) grouping_inventories = list(_StubHandler.grouping_inventories) + prior_finding_semantics = set(_StubHandler.prior_finding_semantics) try: sample = json.loads(output) except json.JSONDecodeError as exc: @@ -1629,9 +1698,108 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic } if _version(version) >= (1, 10, 2): result["grouping_completion_cap"] = expected_grouping_cap + if _version(version) >= (1, 11, 1): + if prior_finding_semantics != {"filter_survivors_as_confirmed"}: + _fail("multi-round review did not expose the qualified prior-finding semantics") + result["prior_finding_semantics"] = "filter_survivors_as_confirmed" + result["files"] = len(paths) return result +def _small_change_grouping_probe(binary: Path, version: str, directory: Path) -> dict[str, object]: + """Prove OCR keeps below-threshold grouping local and deterministic.""" + + if _version(version) < (1, 11, 1): + _fail("small-change grouping probe requires OCR 1.11.1 behavior") + scenarios = ( + ("single_file", ("single.py",), 1, "per_file"), + ("low_churn", ("first.py", "second.py"), 1, "bundle_all"), + ("high_churn", ("first.py", "second.py"), 50, "per_file"), + ) + observed: dict[str, str] = {} + for name, paths, lines, expected_strategy in scenarios: + root = directory / f"small-change-{name}" + root.mkdir() + git_env = _isolated_probe_environment(root / "git-home") + repo = root / "review" + repo.mkdir() + _run(["git", "init", "--initial-branch=main"], cwd=repo, env=git_env) + _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=git_env) + _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=git_env) + for path in paths: + (repo / path).write_text( + "def value():\n return 1\n" + "# baseline\n" * lines, + encoding="utf-8", + ) + _run(["git", "add", *paths], cwd=repo, env=git_env) + _run(["git", "commit", "-m", "small-change baseline"], cwd=repo, env=git_env) + base = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() + for path in paths: + (repo / path).write_text( + "def value():\n return 2\n" + "# changed\n" * lines, + encoding="utf-8", + ) + _run(["git", "commit", "-am", "small-change update"], cwd=repo, env=git_env) + head = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() + env = _isolated_probe_environment(root / "review-home") + with _stub_gateway(grouping_inventory_version=version) as gateway_url: + env.update( + { + "OCR_LLM_URL": gateway_url, + "OCR_LLM_TOKEN": "synthetic-token", + "OCR_LLM_MODEL": "synthetic-model", + "OCR_LLM_PROTOCOL": "openai", + "OCR_TELEMETRY_ENABLED": "false", + } + ) + output = _run( + [ + str(binary), + "review", + "--from", + base, + "--to", + head, + "--format", + "json", + "--audience", + "agent", + "--effort", + "low", + "--concurrency", + "1", + ], + cwd=repo, + env=env, + ) + request_stages = list(_StubHandler.request_stages) + grouping_inventories = list(_StubHandler.grouping_inventories) + try: + sample = json.loads(output) + except json.JSONDecodeError as exc: + raise CompatibilityError("small-change grouping review did not emit JSON") from exc + if not isinstance(sample, dict): + _fail("small-change grouping review emitted an unsupported result object") + _validate_file_groups(sample.get("groups"), set(paths)) + groups = sample["groups"] + actual_strategy = "bundle_all" if len(groups) == 1 and len(paths) > 1 else "per_file" + if ( + actual_strategy != expected_strategy + or "grouping" in request_stages + or grouping_inventories + ): + _fail(f"small-change grouping scenario {name} violated its local strategy") + observed[name] = actual_strategy + return { + "grouping_requests": 0, + "high_churn": observed["high_churn"], + "low_churn": observed["low_churn"], + "result": "passed", + "single_file": observed["single_file"], + "threshold_files": 4, + } + + def _completion_cap_probe(binary: Path, version: str, directory: Path) -> dict[str, object]: """Observe the real OCR chat-completions output cap with and without an override.""" @@ -1955,6 +2123,10 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] } if _version(version) >= (1, 10, 0): contracts["semantic_grouping_probe"] = _semantic_grouping_probe(binary, version, directory) + if _version(version) >= (1, 11, 1): + contracts["small_change_grouping_probe"] = _small_change_grouping_probe( + binary, version, directory + ) if _version(version) >= (1, 9, 10): contracts["completion_cap_probe"] = _completion_cap_probe(binary, version, directory) if thinking_probe is not None: @@ -2164,7 +2336,7 @@ def artifacts() -> dict[str, Path]: contracts_passed=True, ) evidence = { - "schema_version": 2, + "schema_version": 3 if _version(version) >= (1, 11, 1) else 2, "upstream_repository": UPSTREAM_REPOSITORY, "version": version, "tag": tag, @@ -2218,7 +2390,7 @@ def assess_automatic_chain( if candidate[:2] != previous[:2] or candidate[2] != previous[2] + 1: contiguous = False if ( - item.get("schema_version") != 2 + item.get("schema_version") not in {2, 3} or item.get("tested_baseline_version") != tested_baseline or item.get("comparison_version") != comparison or item.get("result") != "compatible" @@ -2273,18 +2445,18 @@ def prepare_update( transition = _release_transition(comparison, candidate) if transition is None: _fail("candidate evidence chain is not a contiguous release sequence") - if transition != "patch" and item.get("schema_version") != 2: - _fail("minor and major promotions require chain-aware evidence schema 2") + if transition != "patch" and item.get("schema_version") not in {2, 3}: + _fail("minor and major promotions require chain-aware evidence schema 2 or 3") if item.get("result") != "compatible": _fail(f"candidate evidence does not qualify {version} as compatible") schema_version = item.get("schema_version") - if schema_version == 2: + if schema_version in {2, 3}: if item.get("tested_baseline_version") != old_version: _fail(f"candidate evidence {version} has a stale tested baseline") if item.get("comparison_version") != expected_comparison: _fail(f"candidate evidence {version} has a non-adjacent comparison version") elif schema_version != 1 or len(evidences) != 1: - _fail("multi-release promotion requires chain-aware evidence schema 2") + _fail("multi-release promotion requires chain-aware evidence schema 2 or 3") classification = item.get("classification") conclusion = conclusions.get(version) if conclusion is not None and ( diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index b704504..04d3057 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -1079,8 +1079,37 @@ def test_compatibility_gateway_rejects_malformed_messages_over_real_http() -> No assert error.value.code == 400 +def test_compatibility_gateway_distinguishes_tool_free_plan_requests() -> None: + """PLAN traffic must not be parsed as the versioned grouping inventory.""" + + module = load_script() + payload = { + "messages": [ + {"role": "system", "content": "Produce a structured review plan."}, + {"role": "user", "content": "Please analyze the code changes."}, + ] + } + + with module._stub_gateway(grouping_inventory_version="1.11.1") as gateway: + request = module.urllib.request.Request( + f"{gateway}/chat/completions", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + with module.urllib.request.urlopen( + request, timeout=module.HTTP_TIMEOUT_SECONDS + ) as response: + result = json.loads(response.read()) + + assert module._StubHandler.request_stages == ["plan"] + assert module._StubHandler.grouping_inventories == [] + + content = result["choices"][0]["message"]["content"] + assert content == "Summary: Review the changed code.\n\nIssues\n(none)" + + def test_grouping_inventory_strictly_parses_old_and_new_release_shapes() -> None: - """Qualification pins the 1.10 line and 1.11.0 to different exact wire shapes.""" + """Qualification pins the 1.10 line and 1.11 releases to exact wire shapes.""" module = load_script() @@ -1108,20 +1137,24 @@ def messages(inventory: str) -> list[dict[str, str]]: module.parse_grouping_inventory(old_inventory, version) for version in ("1.10.0", "1.10.1", "1.10.2") ] - new = module.parse_grouping_inventory( - messages( - "ADDED src/space (unicode) λ.py (+10/-0)\n" - "DELETED win\\deleted.hbs (+0/-5)\n" - "RENAMED renamed.mustache (+0/-0)" - ), - "1.11.0", - ) + new_results = [ + module.parse_grouping_inventory( + messages( + "ADDED src/space (unicode) λ.py (+10/-0)\n" + "DELETED win\\deleted.hbs (+0/-5)\n" + "RENAMED renamed.mustache (+0/-0)" + ), + version, + ) + for version in ("1.11.0", "1.11.1") + ] assert ( old_results[0] == old_results[1] == old_results[2] - == new + == new_results[0] + == new_results[1] == [ module.GroupingInventoryEntry("ADDED", "src/space (unicode) λ.py", 10, 0), module.GroupingInventoryEntry("DELETED", "win\\deleted.hbs", 0, 5), @@ -1136,6 +1169,31 @@ def messages(inventory: str) -> list[dict[str, str]]: module.parse_grouping_inventory(messages("path.py (ADDED, +1/-0)"), "1.9.10") +def test_schema_three_candidate_remains_chain_aware() -> None: + """Current behavioral evidence participates in the adjacent release chain.""" + + module = load_script() + manifest = module.load_json(MANIFEST) + evidence = { + "schema_version": 3, + "version": "1.11.1", + "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.0", + "result": "compatible", + "classification": "automatic-safe", + } + + result = module.assess_automatic_chain(manifest, [evidence]) + + assert result == { + "automatic_blockers": [], + "classification": "automatic-safe", + "target_version": "1.11.1", + "tested_baseline_version": "1.11.0", + "versions": ["1.11.1"], + } + + @pytest.mark.parametrize( "inventory", [ From cc06f20043cd9b0eb43440f5bb7a2ed92f67a715 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:16:46 +0200 Subject: [PATCH 03/12] Prevent synthetic truncated evidence deltas --- PLANS.md | 17 ++- changelog.d/159.bugfix.md | 1 + src/ocr_toolkit/evidence/collect.py | 11 +- .../evidence/collectors/projections.py | 20 +++- tests/test_evidence_collectors.py | 104 ++++++++++++++++++ tests/test_evidence_repository.py | 4 +- 6 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 changelog.d/159.bugfix.md diff --git a/PLANS.md b/PLANS.md index f8d5f15..2531f20 100644 --- a/PLANS.md +++ b/PLANS.md @@ -122,9 +122,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | Queue | Status | Work | | --- | --- | --- | | WQ-01 | done | Materialize plan, create milestone/issue relationships, planning commit, and fidelity check | -| WQ-02 | in_progress | #163 harness is locally complete; commit, initial push, and Draft PR remain | -| WQ-03 | pending | Rerun hosted OCR 1.11.1 qualification and accept only exact structural evidence | -| WQ-04 | pending | Implement #159 pair-aware comparison and impossible-state rejection | +| WQ-02 | done | #163 harness committed; initial push and Draft PR #164 created | +| WQ-03 | done | Hosted run 33400677367 accepted exact OCR 1.11.1 structural evidence | +| WQ-04 | in_progress | #159 incomplete-kind delta suppression is locally complete; commit remains | | WQ-05 | pending | Implement #162 multi-round contract and adopt OCR 1.11.1 pins/security/rules/local binary | | WQ-06 | pending | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | pending | Implement #161 protected policy v3 and same-revision GitLab CI evidence | @@ -181,6 +181,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. probe. Evidence separates local single-file, bundle-all, and high-churn per-file grouping from the four-file semantic grouping request; it also records two medium review rounds, filter-survivor wording, partial budget coverage 2/3, completion cap, and max-tools behavior. +- 2026-08-31: Draft PR #164 opened from the two reviewed initial commits. Hosted compatibility + run `33400677367` passed on exact commit `ffe1233242eb1c102b1cf34306d0c106bad60806`; + candidate #158 now records schema-v3 compatible evidence and a human-review-required semantic + audit classification. +- 2026-08-31: #159 focused evidence/store/MCP suite passed with 197 tests. Generated Go and + Composer lock fixtures prove byte-identical over-limit inputs publish no semantic delta; + bounded one-sided and ordinary version changes remain available. #### Risks And Recovery @@ -200,8 +207,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Commit the reviewed WQ-02 qualification harness, push the two initial commits, open the Draft -PR, and dispatch the exact-branch OCR 1.11.1 compatibility workflow for WQ-03. +Commit the reviewed WQ-04 evidence-integrity slice, then begin WQ-05 with the OCR 1.11.1 +semantic source audit, multi-round mitigation, exact pins, rules, and isolated local update. #### Plan Fidelity Check diff --git a/changelog.d/159.bugfix.md b/changelog.d/159.bugfix.md new file mode 100644 index 0000000..33e6c38 --- /dev/null +++ b/changelog.d/159.bugfix.md @@ -0,0 +1 @@ +Prevent incomplete base/head evidence admission from manufacturing dependency add/remove deltas. Singular same-source pairs remain comparable, while unsafe one-sided or ambiguous changes are omitted with an explicit diagnostic. diff --git a/src/ocr_toolkit/evidence/collect.py b/src/ocr_toolkit/evidence/collect.py index 5a21a3b..1fd23b9 100644 --- a/src/ocr_toolkit/evidence/collect.py +++ b/src/ocr_toolkit/evidence/collect.py @@ -144,10 +144,12 @@ def collect_repository_evidence( ), ) exhausted_kinds: set[str] = set() - for record in ordered_typed_facts: + incomplete_delta_kinds: set[str] = set() + for index, record in enumerate(ordered_typed_facts): if record.kind in exhausted_kinds: continue if not store.add(record): + incomplete_delta_kinds.add(record.kind) if record.component == "ansible" and record.kind.startswith("ansible."): raise EvidenceStoreError( "Ansible topology facts exceed the atomic evidence store limits" @@ -155,18 +157,23 @@ def collect_repository_evidence( limit_state = store.record_limit_state(record.kind) if limit_state == "global": store.add_diagnostic("typed evidence was truncated by the global store limit") + incomplete_delta_kinds.update( + candidate.kind for candidate in ordered_typed_facts[index:] + ) break if limit_state == "kind": # A per-kind omission must not suppress later independent domains. store.add_diagnostic(f"typed {record.kind} evidence was truncated by store limits") exhausted_kinds.add(record.kind) + for kind in sorted(incomplete_delta_kinds): + store.add_diagnostic(f"typed {kind} comparison incomplete; unsafe semantic deltas omitted") # Deltas are projections of canonical accepted store records, never raw facts # or references to values that redaction, deduplication, or a budget omitted. store.deltas = tuple( sorted( ( *snapshot_deltas, - *fact_deltas(store.records), + *fact_deltas(store.records, incomplete_kinds=incomplete_delta_kinds), *coverage_deltas(all_coverage), ), key=lambda item: (item.kind, item.component, item.identity), diff --git a/src/ocr_toolkit/evidence/collectors/projections.py b/src/ocr_toolkit/evidence/collectors/projections.py index 153b846..68fdfee 100644 --- a/src/ocr_toolkit/evidence/collectors/projections.py +++ b/src/ocr_toolkit/evidence/collectors/projections.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Collection, Iterable, Mapping from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage from ocr_toolkit.evidence.frameworks import PluginCoverage, PluginFact @@ -62,8 +62,10 @@ def plugin_coverage( ] -def fact_deltas(records: Iterable[EvidenceRecord]) -> tuple[EvidenceDelta, ...]: - """Build reproducible typed deltas keyed by kind/component/identity.""" +def fact_deltas( + records: Iterable[EvidenceRecord], *, incomplete_kinds: Collection[str] = () +) -> tuple[EvidenceDelta, ...]: + """Build deltas only where base/head admission leaves the identity comparable.""" base: dict[tuple[str, str, str], list[EvidenceRecord]] = {} head: dict[tuple[str, str, str], list[EvidenceRecord]] = {} @@ -108,6 +110,18 @@ def projected_values( for key in sorted(set(base) | set(head)): before = base.get(key) after = head.get(key) + if key[0] in incomplete_kinds and ( + before is None + or after is None + or len(before) != 1 + or len(after) != 1 + or before[0].source_path != after[0].source_path + ): + # Once admission is incomplete, a one-sided or ambiguous identity may + # be an omitted peer rather than a real add/remove/move. A singular + # identity admitted from the same source on both refs remains directly + # comparable and can still expose a real value change. + continue before_value = projected_values(before, after) after_value = projected_values(after, before) change = "removed" if after is None else "added" if before is None else "changed" diff --git a/tests/test_evidence_collectors.py b/tests/test_evidence_collectors.py index 9645fed..c7a2a92 100644 --- a/tests/test_evidence_collectors.py +++ b/tests/test_evidence_collectors.py @@ -872,6 +872,110 @@ def test_collects_both_refs_and_derives_dependency_and_image_deltas(tmp_path: Pa } +@pytest.mark.parametrize("lock_name", ["go.sum", "composer.lock"]) +def test_oversized_identical_locks_cannot_create_semantic_deltas( + tmp_path: Path, lock_name: str +) -> None: + """Suppress deltas when a shared kind budget makes base/head facts incomparable.""" + + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.email", "agent@example.invalid") + _git(tmp_path, "config", "user.name", "Synthetic Agent") + if lock_name == "go.sum": + lock_text = "".join( + f"example.invalid/package-{index:04d} v1.0.0 h1:sum-{index}\n" + for index in range(MAX_MANIFEST_ITEMS + 1) + ) + else: + lock_text = json.dumps( + { + "packages": [ + { + "name": f"synthetic/package-{index:04d}", + "version": "1.0.0", + } + for index in range(MAX_MANIFEST_ITEMS + 1) + ] + } + ) + (tmp_path / lock_name).write_text(lock_text, encoding="utf-8") + (tmp_path / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + _git(tmp_path, "add", lock_name, "app.py") + _git(tmp_path, "commit", "-qm", "bounded lock baseline") + base = _git(tmp_path, "rev-parse", "HEAD") + (tmp_path / "app.py").write_text("VALUE = 2\n", encoding="utf-8") + _git(tmp_path, "commit", "-qam", "unrelated source change") + head = _git(tmp_path, "rev-parse", "HEAD") + + assert _git(tmp_path, "rev-parse", f"{base}:{lock_name}") == _git( + tmp_path, "rev-parse", f"{head}:{lock_name}" + ) + store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head) + response = handle_request( + store, + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "ocr_toolkit_evidence", + "arguments": { + "action": "list", + "kind": "repository.evidence_delta", + "delta_kind": "dependency.locked", + }, + }, + }, + ) + payload = json.loads(response["result"]["content"][0]["text"]) + + assert not any(delta.kind == "dependency.locked" for delta in store.deltas) + assert ( + "typed dependency.locked comparison incomplete; unsafe semantic deltas omitted" + in store.diagnostics + ) + assert payload["records"] == [] + assert payload["returned"] == 0 + + +def test_fact_deltas_preserve_one_sided_ref_semantics() -> None: + """Keep bounded additions and removals while incomplete kinds alone are suppressed.""" + + def fact(ref: RefRole, version: str = "1.0.0") -> EvidenceRecord: + """Build one generated locked dependency at an immutable ref.""" + + return EvidenceRecord( + kind="dependency.locked", + value={"identity": "lock:synthetic", "fact": {"version": version}}, + source_path="synthetic.lock", + ref=ref, + commit_sha="a" * 40 if ref is RefRole.BASE else "b" * 40, + component="synthetic", + ) + + added = fact_deltas((fact(RefRole.HEAD),)) + removed = fact_deltas((fact(RefRole.BASE),)) + suppressed_add = fact_deltas( + (fact(RefRole.HEAD),), + incomplete_kinds={"dependency.locked"}, + ) + comparable_change = fact_deltas( + (fact(RefRole.BASE), fact(RefRole.HEAD, "2.0.0")), + incomplete_kinds={"dependency.locked"}, + ) + + assert [(delta.change, delta.before, delta.after) for delta in added] == [ + ("added", None, {"version": "1.0.0"}) + ] + assert [(delta.change, delta.before, delta.after) for delta in removed] == [ + ("removed", {"version": "1.0.0"}, None) + ] + assert suppressed_add == () + assert [(delta.change, delta.before, delta.after) for delta in comparable_change] == [ + ("changed", {"version": "1.0.0"}, {"version": "2.0.0"}) + ] + + def test_image_facts_accept_yaml_sequence_items(tmp_path: Path) -> None: """Collect common CircleCI and Kubernetes list-item image declarations.""" diff --git a/tests/test_evidence_repository.py b/tests/test_evidence_repository.py index a3f3d3d..1f9770f 100644 --- a/tests/test_evidence_repository.py +++ b/tests/test_evidence_repository.py @@ -298,7 +298,9 @@ def reject_dependency(store: EvidenceStore, item: EvidenceRecord) -> bool: assert not any(record.kind == "dependency.declared" for record in store.records) assert not any(delta.kind == "dependency.declared" for delta in store.deltas) - assert store.diagnostics == [] + assert store.diagnostics == [ + "typed dependency.declared comparison incomplete; unsafe semantic deltas omitted" + ] def test_collection_continues_after_a_real_per_kind_store_limit( From e634d633038767b4d8fb546e3f2cd1dbc697c832 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:42:49 +0200 Subject: [PATCH 04/12] Adopt OCR 1.11.1 and recheck findings --- PLANS.md | 15 +- README.md | 10 +- changelog.d/158.maintenance.md | 7 + changelog.d/158.rules.md | 1 + changelog.d/162.bugfix.md | 1 + compatibility/evidence/ocr-1.11.1.json | 217 +++++++++++++++++++++++ compatibility/ocr-support.json | 57 +++++- docs/compatibility.md | 12 ++ docs/configuration.md | 16 +- docs/operations.md | 6 +- examples/gitlab/ocr-review.gitlab-ci.yml | 6 +- scripts/ocr_compat.py | 106 +++++++++++ src/ocr_toolkit/evidence/project.py | 9 +- src/ocr_toolkit/preflight.py | 2 +- tests/test_environment_contract.py | 6 +- tests/test_evidence_repository.py | 14 +- tests/test_ocr_compat.py | 63 +++---- tests/test_operations_docs.py | 9 +- tests/test_runtime_helpers.py | 2 +- 19 files changed, 484 insertions(+), 75 deletions(-) create mode 100644 changelog.d/158.maintenance.md create mode 100644 changelog.d/158.rules.md create mode 100644 changelog.d/162.bugfix.md create mode 100644 compatibility/evidence/ocr-1.11.1.json diff --git a/PLANS.md b/PLANS.md index 2531f20..fc7dcfe 100644 --- a/PLANS.md +++ b/PLANS.md @@ -111,7 +111,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - `main` is released v0.8.6 and `.next-version` selects 0.8.7. - Existing evidence store v4 exposes one built-in MCP with summary/list/get; context uses fixed context_list/context_get; receipt v5 reconciles count-only evidence usage. -- OCR 1.11.0 is the exact current runtime and local `/opt/homebrew/bin/ocr` version. +- OCR 1.11.1 is the exact current runtime and local `/opt/homebrew/bin/ocr` version. - Workflow audit with engineering-workflow 0.9.0 found canonical files and navigation indexes; no target workflow migration is required. - OCR v1.11.1 assets verify, while current qualification fails because its probes assume @@ -124,8 +124,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-01 | done | Materialize plan, create milestone/issue relationships, planning commit, and fidelity check | | WQ-02 | done | #163 harness committed; initial push and Draft PR #164 created | | WQ-03 | done | Hosted run 33400677367 accepted exact OCR 1.11.1 structural evidence | -| WQ-04 | in_progress | #159 incomplete-kind delta suppression is locally complete; commit remains | -| WQ-05 | pending | Implement #162 multi-round contract and adopt OCR 1.11.1 pins/security/rules/local binary | +| WQ-04 | done | #159 suppresses unsafe deltas when either side was not admitted while preserving comparable pairs | +| WQ-05 | done | #162 guidance, exact OCR 1.11.1 pins/rules/evidence, and local binary are complete | | WQ-06 | pending | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | pending | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | pending | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | @@ -188,6 +188,11 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - 2026-08-31: #159 focused evidence/store/MCP suite passed with 197 tests. Generated Go and Composer lock fixtures prove byte-identical over-limit inputs publish no semantic delta; bounded one-sided and ordinary version changes remain available. +- 2026-08-31: exact local OCR 1.11.1 passed the extended no-LLM contract. Small-change and + semantic grouping remain distinct; the mandatory re-check marker reached all three main + requests; Pug, `.v`, `.vh`, `.sv`, `.vhd`, and `.vhdl` were selected with system rules while + unqualified `.svh` remained excluded. The focused compatibility/runtime/docs suite passed + with 291 tests and 104 subtests. #### Risks And Recovery @@ -207,8 +212,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Commit the reviewed WQ-04 evidence-integrity slice, then begin WQ-05 with the OCR 1.11.1 -semantic source audit, multi-round mitigation, exact pins, rules, and isolated local update. +Begin WQ-06 with the provider-neutral search/coverage query contracts, then connect the two +fixed tools to the existing built-in MCP, routing guidance, action receipt v2, and receipt v6. #### Plan Fidelity Check diff --git a/README.md b/README.md index 564dec3..81e6b41 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ python -m pip install open-code-review-toolkit ``` The toolkit does not bundle OCR. Download the platform asset for exact -[Open Code Review 1.11.0](https://github.com/alibaba/open-code-review/releases/tag/v1.11.0), +[Open Code Review 1.11.1](https://github.com/alibaba/open-code-review/releases/tag/v1.11.1), verify it before installation against the [compatibility manifest](compatibility/ocr-support.json), and place the verified binary on `PATH`. The current Linux amd64 digest is -`13f68cc2eca1a36d42140e9d37797b68fea5cbbf4b6345ec01ec1b06910fab60`; Darwin arm64 is -`ac8bf5a0fcd176bb9dcc15b169e90f4b52bf32787adef17a850489dbed97fb78`. The manifest owns +`1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`; Darwin arm64 is +`5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`. The manifest owns the other platform hashes. Verify the installation without contacting an LLM provider: @@ -42,14 +42,14 @@ ocr --version ocr-ci --help ``` -`ocr --version` must report `open-code-review v1.11.0`; `ocr-ci --help` must exit +`ocr --version` must report `open-code-review v1.11.1`; `ocr-ci --help` must exit successfully. `ocr-ci preflight` is an operational configuration check, not the installation smoke test. The exact recommended OCR release and its verified asset checksums live in the [versioned compatibility manifest](compatibility/ocr-support.json). CI should pin that release and checksum before execution. The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases. Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`. -The current OCR 1.11.0 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. +The current OCR 1.11.1 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. Stable distributions are published to [PyPI](https://pypi.org/project/open-code-review-toolkit/) and mirrored as checksum-listed, provenance-attested assets in the corresponding [GitHub Release](https://github.com/xeonvs/open-code-review-toolkit/releases). Development snapshots are published only to TestPyPI. diff --git a/changelog.d/158.maintenance.md b/changelog.d/158.maintenance.md new file mode 100644 index 0000000..2714bcb --- /dev/null +++ b/changelog.d/158.maintenance.md @@ -0,0 +1,7 @@ +Target checksum-verified Open Code Review 1.11.1 as the sole supported runtime for toolkit 0.8.7. + +Changed: small change sets use OCR-owned local grouping below four files, while threshold-crossing changes retain semantic grouping; project `rule.json` file references are confined to the canonical repository root; selected session files follow manifest coverage. + +Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit toolkit cap override `4096`, default `medium` effort, effective max-tools, private reasoning/session data, DLP, receipts, telemetry ownership, and approval authority. + +Deployment: install OCR 1.11.1 directly and verify the platform checksum from the compatibility manifest; OCR 1.11.0 is historical comparison evidence, not a runtime fallback. diff --git a/changelog.d/158.rules.md b/changelog.d/158.rules.md new file mode 100644 index 0000000..80b722b --- /dev/null +++ b/changelog.d/158.rules.md @@ -0,0 +1 @@ +Review Pug (`.pug`), Verilog/SystemVerilog (`.v`, `.vh`, `.sv`), and VHDL (`.vhd`, `.vhdl`) through the built-in OCR 1.11.1 allowlist and language-specific Rules. The upstream allowlist does not include `.svh`. diff --git a/changelog.d/162.bugfix.md b/changelog.d/162.bugfix.md new file mode 100644 index 0000000..42f2845 --- /dev/null +++ b/changelog.d/162.bugfix.md @@ -0,0 +1 @@ +Keep OCR review-filter survivors unverified across multiple review rounds. The mandatory evidence guidance now requires every prior claim to be checked again against current code, tests, and trusted evidence without changing finding, DLP, receipt, or approval schemas. diff --git a/compatibility/evidence/ocr-1.11.1.json b/compatibility/evidence/ocr-1.11.1.json new file mode 100644 index 0000000..3a92884 --- /dev/null +++ b/compatibility/evidence/ocr-1.11.1.json @@ -0,0 +1,217 @@ +{ + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "53d50290903851948113910503f82f05e129087eb2c664c3b5d419b880de287b", + "size": 57736624 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c", + "size": 54777442 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f", + "size": 55992482 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "a9ac65ebc4aabbc296f1ddae729ca6e553cca5040a565c002edf7d6fed597b00", + "size": 52691106 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "967ba90f835964051c8b89718ba0725a11725416f799401d74f0726c426e1c30", + "size": 57453056 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "abfec0c6c2c043f925a912a07551db51434047c0d0f074695d4903314d4dc35b", + "size": 53424128 + }, + { + "name": "sha256sum.txt", + "sha256": "8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b", + "size": 572 + } + ], + "classification": "human-review-required", + "classification_reasons": [ + "release notes contain a material or ambiguous compatibility signal" + ], + "comparison_version": "1.11.0", + "contracts": { + "comment_thinking_probe": { + "additive_field_preserved": true, + "posting_exposes_thinking": false, + "result": "passed" + }, + "completion_cap_probe": { + "explicit": 4096, + "inherited": 16384, + "result": "passed", + "wire_field": "max_completion_tokens" + }, + "numeric_cli_probe": { + "max_tokens_budget": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 1, + "input": 1, + "outcome": "accepted" + }, + "omitted": { + "effective": "unlimited", + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 30000, + "input": 30000, + "outcome": "accepted" + }, + "sentinel": { + "effective": "unlimited", + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-cli" + }, + "max_tools": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 100, + "input": 50, + "outcome": "accepted" + }, + "minimum_minus_one": { + "effective": 100, + "input": 49, + "outcome": "normalized", + "reported_normalization": 50 + }, + "omitted": { + "effective": 100, + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 101, + "input": 101, + "outcome": "accepted" + }, + "sentinel": { + "effective": 100, + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-template-or-higher-cli", + "reported_minimum": 50 + }, + "result": "passed" + }, + "optional_capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "preview_probe": { + "format": "json", + "path": "example.py", + "result": "passed", + "session_store_created": false + }, + "required_review_flags": [ + "--audience", + "--background-file", + "--effort", + "--format", + "--from", + "--max-tokens-budget", + "--max-tools", + "--preview", + "--rule", + "--to" + ], + "result_contract_probe": { + "additive_fields_allowed": true, + "comment_fields": [ + "category", + "content", + "end_line", + "existing_code", + "path", + "severity", + "start_line", + "thinking" + ], + "manifest_schema": "ocr.run-manifest/v1", + "normalized_outcome": "clean", + "result": "passed" + }, + "review_budget_probe": { + "budget": 30000, + "completed": 2, + "failed_budget": 1, + "grouping_requests": 0, + "grouping_strategy": "per_file", + "partial_findings_preserved": true, + "result": "passed", + "selected": 3 + }, + "semantic_grouping_probe": { + "default_effort": "medium", + "files": 4, + "filter_requests": 1, + "grouping_completion_cap": 16384, + "grouping_requests": 1, + "main_requests": 3, + "prior_finding_semantics": "filter_survivors_as_confirmed", + "result": "passed", + "review_rounds": 2 + }, + "small_change_grouping_probe": { + "grouping_requests": 0, + "high_churn": "per_file", + "low_churn": "bundle_all", + "result": "passed", + "single_file": "per_file", + "threshold_files": 4 + }, + "target_rule_selection_probe": { + "format": "json", + "from_to_unchanged": true, + "path": "synthetic-template.ocrfixture", + "result": "passed", + "source_exclusion": "unsupported_ext", + "target_selected": true + }, + "version_probe": "passed" + }, + "published_at": "2026-08-31T10:06:30Z", + "release_changes": "## 🚀 Features\n\n- feat(allowlist): add Pug template support (#1114)\n- feat(grouping): skip the grouping LLM call for small change sets (#1103)\n- feat(allowlist): add Verilog, SystemVerilog, and VHDL support (#1050)\n\n## 🐛 Bug Fixes\n\n- fix(security): prevent rule.json from reading arbitrary files on the review host (#1100)\n- fix(viewer): report selected files from run manifest (#1096)\n\n## 📖 Documentation\n\n- docs(site): replace concurrency feature card with effort (#1116)\n- docs(skill): correct timeout description to reflect effort-scaled group timeout (#1110)\n\n## Other Changes\n\n- chore(skill): remove Prerequisites section from delegate SKILL.md (#1098)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.11.0...v1.11.1", + "release_notes_sha256": "ff00b801f0ce4cca6b2a0a0df941a65fed3cfe89738a84aff8532e3a9f39bd20", + "result": "compatible", + "schema_version": 3, + "tag": "v1.11.1", + "tested_baseline_version": "1.11.0", + "upstream_repository": "alibaba/open-code-review", + "version": "1.11.1" +} diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index e3ce86c..3ecdf54 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1,6 +1,6 @@ { - "monitoring_floor": "1.11.0", - "recommended_version": "1.11.0", + "monitoring_floor": "1.11.1", + "recommended_version": "1.11.1", "releases": [ { "assets": [ @@ -1346,6 +1346,59 @@ "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.0", "status": "tested", "version": "1.11.0" + }, + { + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "53d50290903851948113910503f82f05e129087eb2c664c3b5d419b880de287b", + "size": 57736624 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c", + "size": 54777442 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f", + "size": 55992482 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "a9ac65ebc4aabbc296f1ddae729ca6e553cca5040a565c002edf7d6fed597b00", + "size": 52691106 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "967ba90f835964051c8b89718ba0725a11725416f799401d74f0726c426e1c30", + "size": 57453056 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "abfec0c6c2c043f925a912a07551db51434047c0d0f074695d4903314d4dc35b", + "size": 53424128 + }, + { + "name": "sha256sum.txt", + "sha256": "8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b", + "size": 572 + } + ], + "capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "evidence": "compatibility/evidence/ocr-1.11.1.json", + "evidence_sha256": "516d78050006e216118adcc4c1fb73d1be5318e3b7b152968061bf0a34b4aecf", + "human_conclusion": "Compatible after source and behavioral review. Toolkit consumes bounded small-change grouping, project-rule confinement, selected-file session correction, and Pug/Verilog/SystemVerilog/VHDL review coverage; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit 4096 override, medium effort, effective max-tools, private reasoning/session boundaries, receipts, DLP, and approval authority remain unchanged. Filter survivors are still labeled confirmed upstream, so toolkit treats them as unverified and requires current code, test, and trusted evidence re-checking.", + "published_at": "2026-08-31T10:06:30Z", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.1", + "status": "tested", + "version": "1.11.1" } ], "schema_version": 1, diff --git a/docs/compatibility.md b/docs/compatibility.md index 6c135ab..6a01ce4 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -87,6 +87,18 @@ Upstream GitHub Action, OpenCode plugin, npm launcher, Korean documentation, bui Deploy toolkit 0.8.6 directly with OCR 1.11.0; do not install 1.10.2 as an intermediate version. The public Linux amd64 example verifies SHA-256 `13f68cc2eca1a36d42140e9d37797b68fea5cbbf4b6345ec01ec1b06910fab60`; the independently verified Darwin arm64 artifact uses `ac8bf5a0fcd176bb9dcc15b169e90f4b52bf32787adef17a850489dbed97fb78`, and the upstream `sha256sum.txt` verifies `9dff050ec859882bef26037415b8bd9e5db70c5a7d960e5eb3989385372311ee`. +### OCR 1.11.1 — toolkit 0.8.7 target + +OCR 1.11.1 is the only runtime version accepted by toolkit 0.8.7 preflight and pinned by its GitLab example. OCR 1.11.0 is an adjacent historical comparison, not a supported fallback. Hosted run `33400677367`, issues #158/#163, strict schema-v3 evidence, source review, and an independent checksum-verified Darwin no-LLM probe preserve the OCR result and manifest, inherited completion cap `16384`, explicit toolkit override `4096`, default `medium` effort, effective max-tools, private reasoning/session boundary, DLP, receipts, summary, and approval authority. + +Small change sets now skip the grouping LLM. A single file stays per-file; two below-threshold low-churn files use one local `small change set` group; two files above the 200-line aggregate threshold stay per-file. Four files cross the default file threshold and retain the status-first grouping inventory and semantic grouping request. Qualification proves these paths separately so a local bundle cannot silently weaken the three-item partial-budget test. The local grouping decision is OCR telemetry only; path-derived group keys and model labels do not become toolkit telemetry or public data. + +OCR still labels review-filter survivors as `Previously Confirmed Findings` in a later round even though survival is not independent validation. Toolkit 0.8.7 therefore states in its mandatory, unclippable background prefix that prior/filter-surviving findings remain unverified and must be re-checked against current code, tests, and trusted evidence. Exact-binary qualification proves that instruction is present in every main request across the two default rounds. The toolkit does not parse finding prose, invent a validation status, or let survival change severity, suppression, lifecycle, or approval; external configured qualification remains responsible for demonstrating model correction when trusted evidence contradicts an earlier report. + +The project `rule.json` layer now confines the file itself and referenced rule files to the canonical repository root after symlink resolution. Operator-supplied global or explicit rule files keep their existing trusted boundary. Built-in review coverage adds Pug (`.pug`), Verilog/SystemVerilog (`.v`, `.vh`, `.sv`), and VHDL (`.vhd`, `.vhdl`) allowlist and Rules behavior. OCR 1.11.1 does not select `.svh`; qualification records that boundary explicitly. Selected session files now come from immutable manifest coverage rather than internal pseudo-sessions. Viewer, upstream Action/skills/site documentation, and presentation-only telemetry documentation changes do not create toolkit runtime behavior. + +Deploy toolkit 0.8.7 directly with OCR 1.11.1. The public Linux amd64 example verifies SHA-256 `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`; Darwin arm64 verifies `5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`; and upstream `sha256sum.txt` verifies `8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b`. + The contiguous 1.9.7 through 1.9.8 promotion combines the repository-owned hosted Linux qualification with adjacent upstream source review. It also repeats the required 1.9.8 contract probes through the checksum-verified Darwin arm64 artifact. Multi-tool MCP initialization, fixed tool discovery and dispatch, and owner-only session persistence remain compatible with the M5 design: the toolkit may expose several fixed tools from its one built-in process, while an isolated `HOME` contains OCR's `.opencodereview/sessions` state for deterministic cleanup. Gemini and Bedrock are optional upstream provider capabilities that the toolkit neither selects nor configures; mirror-aware installation is outside the toolkit-owned download path; native severity retains the structured result fields already consumed; and human-audience progress routing does not affect the toolkit's agent-audience invocation. Therefore this promotion requires no toolkit CI behavior adaptation beyond version and checksum pins. These source observations establish capability only; the M5 production-path tests remain responsible for proving the toolkit's composition and containment. An automatic-safe result is not an automatic stable release. It must still pass a normal protected compatibility PR and a separate signed stable-release PR. If a dedicated OCR update bot credential is not configured, the workflow publishes the exact patch as an artifact and records the resume action in the issue; the default `GITHUB_TOKEN` is intentionally not used to create a PR that would fail to trigger the full protected workflow set. diff --git a/docs/configuration.md b/docs/configuration.md index a46082e..4f6c251 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,7 +22,7 @@ These are the complete supported toolkit-owned runtime inputs. `Required` is sco | `OCR_LLM_VALIDATE_MODEL` | Operator / `ocr-ci preflight` | No | `false` | `true` validates through `/models`; `auto` may use the offline allowlist; false values skip validation. | | `OCR_LLM_MODELS_URL` | Operator / `ocr-ci preflight` | No | Derived from `OCR_LLM_URL` | Explicit absolute credential-free HTTPS metadata URL when validation is enabled or inference query parameters make derivation ambiguous. | | `OCR_LLM_ALLOWED_MODELS` | Operator / `ocr-ci preflight` | No | Empty list | Comma-separated exact model identifiers for offline or `auto` validation. | -| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.0 spans may include path-derived group keys and model-produced labels. | +| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.1 spans may include path-derived group keys, model-produced labels, and local grouping decisions. | | `OCR_TELEMETRY_CONTENT_LOGGING` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR content logging; keep disabled for private review data. | | `OCR_TELEMETRY_EXPORTER` | Operator / `ocr-ci configure` | No | Empty string | Exporter name written only when telemetry is enabled. | | `OCR_TELEMETRY_OTLP_ENDPOINT` | Operator / `ocr-ci configure` | No | Unset | OTLP endpoint written only when telemetry is enabled and the value is non-empty. | @@ -60,11 +60,13 @@ If `OCR_LLM_EXTRA_BODY` already owns that field, an exactly equal JSON integer i The toolkit does not derive this value from `/models.max_completion_tokens`. That metadata is a model capability boundary, not an account spending limit or proof of how a gateway reserves request cost. -The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.0 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. +The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.1 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. ### Review effort -`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.0 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. +`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.1 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. + +OCR 1.11.1 may present filter-surviving comments to a later round as previously confirmed, but the toolkit does not accept that wording as validation. Its mandatory background prefix travels with every main request and requires prior/filter-surviving findings to remain unverified until current code, tests, or trusted evidence support them. Survival cannot change severity, suppress or resolve a finding, authorize approval, or enter a receipt as independent validation. Effort controls review depth, not the prompt/context ceiling, per-call completion cap, aggregate token budget, or per-round tool limit. Semantic grouping and filtering can add requests even at `low`; higher effort can add further rounds until OCR stops early, reaches a coverage/budget boundary, or completes the configured depth. @@ -92,13 +94,13 @@ These names belong to `examples/gitlab/ocr-review.gitlab-ci.yml`; they are shell | Variable | Source / owner | Required | Exact default | Behavior | | --- | --- | --- | --- | --- | -| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.0` | Checksum-pinned recommended OCR binary release for toolkit 0.8.6. | -| **`OCR_SHA256`** | Example pipeline | Yes | `13f68cc2eca1a36d42140e9d37797b68fea5cbbf4b6345ec01ec1b06910fab60` | Expected Linux AMD64 OCR binary digest. | +| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.1` | Checksum-pinned recommended OCR binary release for toolkit 0.8.7. | +| **`OCR_SHA256`** | Example pipeline | Yes | `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f` | Expected Linux AMD64 OCR binary digest. | | **`OCR_TOOLKIT_VERSION`** | Example pipeline | Yes | `0.8.6` | Exact toolkit wheel release installed by the current published example. | | **`OCR_TOOLKIT_CHECKSUMS_URL`** | Example pipeline | Yes | Release URL derived from `OCR_TOOLKIT_VERSION` | Toolkit `SHA256SUMS` URL. | | `OCR_TOOLKIT_WHEEL` | Example shell | Computed | `open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl` | Exact wheel filename selected from the release. | | `OCR_TOOLKIT_WHEEL_SHA256` | Example shell | Computed | Matching value from `SHA256SUMS` | Digest checked before installing the toolkit wheel. | -| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.0 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | +| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.1 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | | `OCR_MAX_TOKENS_BUDGET` | Example pipeline / OCR CLI | No | `0` | Non-negative aggregate OCR token ceiling; `0` is unlimited. | ## Dynamic adapter and MCP inputs @@ -166,7 +168,7 @@ finish and OCR accounts the provider-reported input plus output tokens. `OCR_MAX_TOOLS` is independent of all three token controls. Leave the example default at `0` so OCR uses its embedded template limit of `100` per subtask. -OCR 1.11.0 reports values `1-49` as normalized to the minimum `50`, but both +OCR 1.11.1 reports values `1-49` as normalized to the minimum `50`, but both that target and explicit `50` remain below the template default and therefore remain effectively `100`. Use `101` or greater only when deliberately raising the loop cap. OCR 1.11.0 corrected stale help text for this already-qualified diff --git a/docs/operations.md b/docs/operations.md index b45e0ce..6ea386d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -34,9 +34,9 @@ suppressed. `OCR_MAX_TOKENS_BUDGET` can set an aggregate input-plus-output token ceiling for the OCR diff review. The default `0` is unlimited. A positive ceiling is approximate rather than a hard billing cutoff because already-running work may complete; when it stops further dispatch, completed findings remain publishable and unreviewed files stay explicit as budget-attributed failed coverage. Such a run is partial and cannot automatically approve. -This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.0, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. +This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.1, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. -`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.0 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior rather than changing the runtime loop. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. +`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.1 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.1 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.1 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` counts appear only after exact reconciliation. Zero-valued token counters and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. @@ -130,7 +130,7 @@ Suppression checks both the recorded inline position and compatible fingerprints Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v5 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v5 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. -OCR 1.11.0 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v5, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v5, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. +OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v5, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v5, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. When OCR exits nonzero with a valid bounded `ocr.llm-retry-report/v1`, the toolkit reads only its closed error class, failure phase, terminal outcome, HTTP status, and schema counters. It maps those facts to `authentication`, `authorization`, `rate-or-spending-limit`, `overloaded`, `timeout`, `network`, `endpoint-or-model-not-found`, `request-rejected`, `provider-unavailable`, `invalid-response`, `cancelled`, `mixed`, or `unknown`, then writes a completely toolkit-authored note. A runtime `404` remains `endpoint-or-model-not-found` because safely distinguishing the endpoint from the model would require trusting the raw response body. diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index 6bf1179..6aaf92c 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -6,10 +6,10 @@ default: image: python:3.12-slim variables: - OCR_VERSION: "v1.11.0" + OCR_VERSION: "v1.11.1" OCR_TOOLKIT_VERSION: "0.8.6" OCR_TOOLKIT_CHECKSUMS_URL: "https://github.com/xeonvs/open-code-review-toolkit/releases/download/v${OCR_TOOLKIT_VERSION}/SHA256SUMS" - OCR_SHA256: "13f68cc2eca1a36d42140e9d37797b68fea5cbbf4b6345ec01ec1b06910fab60" + OCR_SHA256: "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" # Default-on receipt-bound approval; external MCP always remains comment-only. @@ -44,7 +44,7 @@ open_code_review: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: on_success - when: never - # OCR 1.11.0 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). + # OCR 1.11.1 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). timeout: 45m script: - apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index f1cd1b6..267355c 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -73,6 +73,7 @@ "--rule", "--to", } +PRIOR_FINDING_RECHECK_MARKER = "Prior/filter-surviving findings remain unverified" MAX_TOOLS_NORMALIZATION_RE = re.compile( r"\[ocr\] --max-tools ([0-9]{1,12}) is below minimum ([0-9]{1,12}), " r"using ([0-9]{1,12})\n?\Z" @@ -803,6 +804,7 @@ class _StubHandler(http.server.BaseHTTPRequestHandler): completion_caps: list[object] = [] request_stages: list[str] = [] prior_finding_semantics: set[str] = set() + recheck_instruction_requests = 0 @staticmethod def _message_contents(messages: list[Any]) -> list[str]: @@ -909,6 +911,8 @@ def do_POST(self) -> None: stage = "filter" else: contents = type(self)._message_contents(messages) + if any(PRIOR_FINDING_RECHECK_MARKER in item for item in contents): + type(self).recheck_instruction_requests += 1 if type(self).main_mode == "exhaust-tools": if "file_read" in tool_names: function = { @@ -1032,6 +1036,7 @@ def _stub_gateway( _StubHandler.completion_caps = [] _StubHandler.request_stages = [] _StubHandler.prior_finding_semantics = set() + _StubHandler.recheck_instruction_requests = 0 server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -1626,6 +1631,13 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic _run(["git", "commit", "-am", "group related changes"], cwd=repo, env=git_env) head = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() + from ocr_toolkit.evidence.project import MANDATORY_EVIDENCE_INSTRUCTION + + if PRIOR_FINDING_RECHECK_MARKER not in MANDATORY_EVIDENCE_INSTRUCTION: + _fail("toolkit bootstrap lost the multi-round re-check instruction") + background = root / "bootstrap.md" + background.write_text(MANDATORY_EVIDENCE_INSTRUCTION, encoding="utf-8") + env = _isolated_probe_environment(root / "review-home") with _stub_gateway( grouping_mode="combined", @@ -1652,6 +1664,8 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic "json", "--audience", "agent", + "--background-file", + str(background), "--concurrency", "1", ], @@ -1662,6 +1676,7 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic completion_caps = list(_StubHandler.completion_caps) grouping_inventories = list(_StubHandler.grouping_inventories) prior_finding_semantics = set(_StubHandler.prior_finding_semantics) + recheck_instruction_requests = _StubHandler.recheck_instruction_requests try: sample = json.loads(output) except json.JSONDecodeError as exc: @@ -1701,7 +1716,10 @@ def _semantic_grouping_probe(binary: Path, version: str, directory: Path) -> dic if _version(version) >= (1, 11, 1): if prior_finding_semantics != {"filter_survivors_as_confirmed"}: _fail("multi-round review did not expose the qualified prior-finding semantics") + if recheck_instruction_requests != 3: + _fail("multi-round review did not retain toolkit re-check guidance in every round") result["prior_finding_semantics"] = "filter_survivors_as_confirmed" + result["recheck_instruction_requests"] = recheck_instruction_requests result["files"] = len(paths) return result @@ -1977,6 +1995,93 @@ def preview(home_name: str, *extra: str) -> dict[str, Any] | str: } +def _language_rule_probe(binary: Path, directory: Path) -> dict[str, object]: + """Prove consumed built-in language selection and rule ownership without an LLM.""" + + root = directory / "language-rule-probe" + root.mkdir() + git_env = _isolated_probe_environment(root / "git-home") + repo = root / "review" + repo.mkdir() + _run(["git", "init", "--initial-branch=main"], cwd=repo, env=git_env) + _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=git_env) + _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=git_env) + supported_paths = ( + "views/page.pug", + "rtl/module.v", + "rtl/include.vh", + "rtl/module.sv", + "rtl/entity.vhd", + "rtl/entity.vhdl", + ) + unsupported_path = "rtl/include.svh" + paths = (*supported_paths, unsupported_path) + for path in paths: + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("// baseline\n", encoding="utf-8") + _run(["git", "add", *paths], cwd=repo, env=git_env) + _run(["git", "commit", "-m", "language baseline"], cwd=repo, env=git_env) + base = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() + for path in paths: + (repo / path).write_text("// changed\n", encoding="utf-8") + _run(["git", "commit", "-am", "language changes"], cwd=repo, env=git_env) + head = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=git_env).strip() + env = _isolated_probe_environment(root / "review-home") + preview = _run( + [ + str(binary), + "review", + "--from", + base, + "--to", + head, + "--preview", + "--format", + "json", + ], + cwd=repo, + env=env, + ) + try: + payload = json.loads(preview) + except json.JSONDecodeError as exc: + raise CompatibilityError("language preview did not emit JSON") from exc + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + _fail("language preview emitted an invalid file manifest") + selected = { + item.get("path") + for item in files + if isinstance(item, dict) and item.get("will_review") is True + } + if selected != set(supported_paths): + missing = sorted(set(supported_paths) - selected) + unexpected = sorted(selected - set(supported_paths)) + _fail( + "candidate did not select the qualified built-in language set: " + f"missing={missing!r}, unexpected={unexpected!r}" + ) + unsupported_selected, unsupported_reason = _preview_file_selection(payload, unsupported_path) + if unsupported_selected or unsupported_reason != "unsupported_ext": + _fail("candidate unexpectedly selected the unqualified .svh extension") + for path in supported_paths: + output = _run([str(binary), "rules", "check", path], cwd=repo, env=env) + if ( + f"File: {path}\n" not in output + or "Source: System built-in\n" not in output + or "Pattern: " not in output + ): + _fail("candidate did not resolve a qualified built-in language rule") + return { + "extensions": [".pug", ".sv", ".v", ".vhd", ".vhdl", ".vh"], + "excluded_extensions": [".svh"], + "result": "passed", + "rule_source": "system_builtin", + "selected": len(supported_paths), + } + + def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any]: """Run deterministic CLI and JSON-consumer probes against one OCR binary.""" @@ -2127,6 +2232,7 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] contracts["small_change_grouping_probe"] = _small_change_grouping_probe( binary, version, directory ) + contracts["language_rule_probe"] = _language_rule_probe(binary, directory) if _version(version) >= (1, 9, 10): contracts["completion_cap_probe"] = _completion_cap_probe(binary, version, directory) if thinking_probe is not None: diff --git a/src/ocr_toolkit/evidence/project.py b/src/ocr_toolkit/evidence/project.py index 834711b..98412ab 100644 --- a/src/ocr_toolkit/evidence/project.py +++ b/src/ocr_toolkit/evidence/project.py @@ -25,10 +25,11 @@ class CapabilityView(Protocol): MAX_BOOTSTRAP_POLICY_SUMMARIES = 20 MAX_BOOTSTRAP_MAX_BYTES = 65_536 MANDATORY_EVIDENCE_INSTRUCTION = ( - "# Required evidence call\n\n" - "Call `ocr_toolkit_evidence(action=summary)` before analysis, even for " - "small/self-contained diffs. Model use is mandatory; preflight self-query does not " - "count; zero calls are rejected.\n\n" + "# Required evidence\n\n" + "Call `ocr_toolkit_evidence(action=summary)` before analysis; preflight excluded; " + "zero model calls fail.\n" + "Prior/filter-surviving findings remain unverified; re-check against current " + "code/tests/trusted evidence.\n\n" ) diff --git a/src/ocr_toolkit/preflight.py b/src/ocr_toolkit/preflight.py index e8c8bd7..203329c 100644 --- a/src/ocr_toolkit/preflight.py +++ b/src/ocr_toolkit/preflight.py @@ -28,7 +28,7 @@ "Accept": "application/json", "User-Agent": "open-code-review-ci-preflight/1.0", } -EXPECTED_OCR_VERSION = "1.11.0" +EXPECTED_OCR_VERSION = "1.11.1" class PreflightError(Exception): diff --git a/tests/test_environment_contract.py b/tests/test_environment_contract.py index 4f8a9ee..31f5612 100644 --- a/tests/test_environment_contract.py +++ b/tests/test_environment_contract.py @@ -69,8 +69,8 @@ } EXAMPLE_DEFAULTS = { - "OCR_VERSION": "v1.11.0", - "OCR_SHA256": "13f68cc2eca1a36d42140e9d37797b68fea5cbbf4b6345ec01ec1b06910fab60", + "OCR_VERSION": "v1.11.1", + "OCR_SHA256": "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f", "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_TOOLKIT_CHECKSUMS_URL": "Release URL derived from `OCR_TOOLKIT_VERSION`", "OCR_TOOLKIT_WHEEL": "open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl", @@ -299,7 +299,7 @@ def test_example_local_defaults_match_the_pipeline() -> None: encoding="utf-8" ) for name, value in { - "OCR_VERSION": "v1.11.0", + "OCR_VERSION": "v1.11.1", "OCR_SHA256": EXAMPLE_DEFAULTS["OCR_SHA256"], "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_MAX_TOOLS": "0", diff --git a/tests/test_evidence_repository.py b/tests/test_evidence_repository.py index 1f9770f..8329866 100644 --- a/tests/test_evidence_repository.py +++ b/tests/test_evidence_repository.py @@ -231,12 +231,12 @@ def test_collector_and_projections_keep_typed_facts_queryable( assert store.base and store.base.commit_sha == base_sha assert store.head and store.head.commit_sha == head_sha assert any(record.kind == "repository.change_category" for record in store.records) - assert bootstrap.startswith("# Required evidence call\n") + assert bootstrap.startswith("# Required evidence\n") instruction = bootstrap.index("ocr_toolkit_evidence(action=summary)") assert instruction < bootstrap.index(base_sha) - assert "small/self-contained diffs" in bootstrap - assert "preflight self-query does not count" in bootstrap - assert "zero calls are rejected" in bootstrap + assert "Prior/filter-surviving findings remain unverified" in bootstrap + assert "preflight excluded" in bootstrap + assert "zero model calls fail" in bootstrap assert "# Repository evidence bootstrap" in bootstrap assert "Untrusted repository data" in bootstrap assert f"- base: `{base_sha}`" in bootstrap @@ -428,9 +428,11 @@ def test_bootstrap_truncation_is_explicit() -> None: assert len(bootstrap) <= 400 assert len(bootstrap.encode("utf-8")) <= 1024 assert "bootstrap truncated" in bootstrap - assert bootstrap.startswith("# Required evidence call\n") + assert bootstrap.startswith("# Required evidence\n") assert "ocr_toolkit_evidence(action=summary)" in bootstrap - assert "zero calls are rejected" in bootstrap + assert "zero model calls fail" in bootstrap + assert "Prior/filter-surviving findings remain unverified" in bootstrap + assert "re-check against current code/tests/trusted evidence" in bootstrap def test_bootstrap_neutralizes_untrusted_diagnostic_markdown() -> None: diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index 04d3057..1073ff5 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -43,8 +43,8 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No module.validate_manifest(manifest, PROJECT_ROOT) - assert manifest["recommended_version"] == "1.11.0" - assert manifest["monitoring_floor"] == "1.11.0" + assert manifest["recommended_version"] == "1.11.1" + assert manifest["monitoring_floor"] == "1.11.1" assert [(item["version"], item["status"]) for item in manifest["releases"]] == [ ("1.7.17", "tested"), ("1.8.0", "tested"), @@ -73,6 +73,7 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No ("1.10.1", "tested"), ("1.10.2", "tested"), ("1.11.0", "tested"), + ("1.11.1", "tested"), ] @@ -157,9 +158,9 @@ def test_discovery_filters_known_prerelease_and_old_versions() -> None: def test_discovery_pages_until_the_monitoring_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - first_page = [release("1.11.1")] + first_page = [release("1.11.2")] first_page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) - second_page = [release("1.11.0")] + second_page = [release("1.11.1")] requested: list[str] = [] def fake_request(url: str) -> list[dict[str, Any]]: @@ -169,14 +170,14 @@ def fake_request(url: str) -> list[dict[str, Any]]: with patched_attr(module, "_request_json", fake_request): unseen = module.discover_unseen(manifest) - assert [item["tag_name"] for item in unseen] == ["v1.11.1"] + assert [item["tag_name"] for item in unseen] == ["v1.11.2"] assert len(requested) == 2 def test_discovery_fails_when_bounded_pages_do_not_reach_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - page = [release("1.11.1")] + page = [release("1.11.2")] page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) with patched_attr(module, "_request_json", lambda _url: page): @@ -221,14 +222,14 @@ def test_qualification_matrix_accepts_the_next_manual_patch() -> None: module = load_script() manifest = module.load_json(MANIFEST) - matrix = module.qualification_matrix(manifest, [release("1.11.1")]) + matrix = module.qualification_matrix(manifest, [release("1.11.2")]) assert matrix == { "include": [ { - "comparison_version": "1.11.0", - "tag": "v1.11.1", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tag": "v1.11.2", + "tested_baseline_version": "1.11.1", } ] } @@ -1176,9 +1177,9 @@ def test_schema_three_candidate_remains_chain_aware() -> None: manifest = module.load_json(MANIFEST) evidence = { "schema_version": 3, - "version": "1.11.1", - "tested_baseline_version": "1.11.0", - "comparison_version": "1.11.0", + "version": "1.11.2", + "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.1", "result": "compatible", "classification": "automatic-safe", } @@ -1188,9 +1189,9 @@ def test_schema_three_candidate_remains_chain_aware() -> None: assert result == { "automatic_blockers": [], "classification": "automatic-safe", - "target_version": "1.11.1", - "tested_baseline_version": "1.11.0", - "versions": ["1.11.1"], + "target_version": "1.11.2", + "tested_baseline_version": "1.11.1", + "versions": ["1.11.2"], } @@ -1656,11 +1657,11 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.1", + "version": "1.11.2", "result": "compatible", "classification": "human-review-required", - "comparison_version": "1.11.0", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tested_baseline_version": "1.11.1", } with pytest.raises(module.CompatibilityError, match="bounded conclusion"): @@ -1679,8 +1680,8 @@ def test_prepare_update_requires_human_review_for_minor_transition() -> None: "version": "1.12.0", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.0", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tested_baseline_version": "1.11.1", } with pytest.raises(module.CompatibilityError, match="explicit human review"): @@ -1721,8 +1722,8 @@ def test_prepare_update_rejects_nonadjacent_minor_transition() -> None: "version": "1.13.0", "result": "compatible", "classification": "human-review-required", - "comparison_version": "1.11.0", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tested_baseline_version": "1.11.1", } with pytest.raises(module.CompatibilityError, match="contiguous release sequence"): @@ -1739,11 +1740,11 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.1", + "version": "1.11.2", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.0", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tested_baseline_version": "1.11.1", } with pytest.raises(module.CompatibilityError, match="only evidence versions"): @@ -1751,7 +1752,7 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.2": "Synthetic unrelated conclusion."}, + human_conclusions={"1.11.3": "Synthetic unrelated conclusion."}, root=PROJECT_ROOT, ) @@ -1763,11 +1764,11 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( module = load_script() evidence = { "schema_version": 2, - "version": "1.11.1", + "version": "1.11.2", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.0", - "tested_baseline_version": "1.11.0", + "comparison_version": "1.11.1", + "tested_baseline_version": "1.11.1", } with pytest.raises(module.CompatibilityError, match="bounded plain text"): @@ -1775,7 +1776,7 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.1": conclusion}, + human_conclusions={"1.11.2": conclusion}, root=PROJECT_ROOT, ) diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index af49c97..bf02aee 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -233,10 +233,9 @@ def test_completion_cap_and_provider_failure_boundaries_are_public() -> None: assert "provider-specific" in document for document in (configuration, operations, gitlab): assert "OCR_LLM_MAX_COMPLETION_TOKENS=4096" not in document - current_compatibility = compatibility.split("### OCR 1.11.0 — toolkit 0.8.6 target", 1)[1] - assert "override `4096`" not in current_compatibility - assert "operator-selected positive completion-cap override" in current_compatibility - assert "historical OCR 1.10.0 through 1.10.2" in current_compatibility + current_compatibility = compatibility.split("### OCR 1.11.1 — toolkit 0.8.7 target", 1)[1] + assert "explicit toolkit override `4096`" in current_compatibility + assert "historical OCR 1.10.0 through 1.10.2" in compatibility for field in ("max_completion_tokens", "max_output_tokens", "max_tokens"): assert field in configuration for phrase in ( @@ -541,6 +540,7 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "OCR 1.10.1 — toolkit 0.8.4 target", "OCR 1.10.2 — toolkit 0.8.5 target", "OCR 1.11.0 — toolkit 0.8.6 target", + "OCR 1.11.1 — toolkit 0.8.7 target", "ocr.toolkit-advisory/v1", "ocr.llm-retry-report/v1", "not toolkit telemetry", @@ -548,6 +548,7 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "Deploy toolkit 0.8.4 directly with OCR 1.10.1", "Deploy toolkit 0.8.5 directly with OCR 1.10.2", "Deploy toolkit 0.8.6 directly with OCR 1.11.0", + "Deploy toolkit 0.8.7 directly with OCR 1.11.1", "max-tools runtime behavior is unchanged", "max_completion_tokens=16384", "do not install OCR 1.9.10 as an intermediate step", diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index 6f9149f..7b00d56 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -1493,7 +1493,7 @@ def test_invalid_json_error_does_not_echo_secret_payload(self) -> None: class PreflightTests(unittest.TestCase): def test_validate_ocr_binary_accepts_supported_version(self) -> None: completed = subprocess.CompletedProcess( - args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.0\n", stderr="" + args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.1\n", stderr="" ) with ( patched_attr(preflight.shutil, "which", lambda _name: "/usr/bin/ocr"), From 8c67406781a7a11e94186b82cb689b82bdd6ad4f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:20:00 +0200 Subject: [PATCH 05/12] Add bounded evidence search and coverage tools --- PLANS.md | 13 +- README.md | 4 +- changelog.d/160.feature.md | 1 + docs/codex/TASKS_BACKLOG.md | 2 +- docs/configuration.md | 12 +- docs/engineering/m5_context_contracts.md | 4 +- docs/engineering/review_signal_ownership.md | 8 +- docs/engineering/test_evidence_matrix.md | 4 +- docs/engineering/toolkit_strategy.md | 4 +- docs/gitlab.md | 4 +- docs/operations.md | 10 +- docs/review-context.md | 10 +- docs/security.md | 13 +- src/ocr_toolkit/evidence/actions.py | 4 +- src/ocr_toolkit/evidence/mcp.py | 293 +++++++++++++++++++- src/ocr_toolkit/evidence/project.py | 12 +- src/ocr_toolkit/mcp_config.py | 12 +- src/ocr_toolkit/ocr_result.py | 4 +- src/ocr_toolkit/posting/approval.py | 23 +- src/ocr_toolkit/posting/formatting.py | 19 +- src/ocr_toolkit/posting/workflow.py | 15 +- src/ocr_toolkit/review_runner.py | 36 ++- tests/installed_policy_e2e.py | 55 +++- tests/test_context_mcp.py | 26 +- tests/test_evidence_actions.py | 46 ++- tests/test_evidence_mcp.py | 202 +++++++++++++- tests/test_gitlab_provider.py | 10 +- tests/test_installed_policy_e2e.py | 2 +- tests/test_integration_contracts.py | 4 +- tests/test_operations_docs.py | 35 ++- tests/test_posting_approval.py | 87 +++--- tests/test_posting_helpers.py | 76 +++-- tests/test_review_runner.py | 160 ++++++++--- tests/test_runtime_helpers.py | 6 +- 34 files changed, 1005 insertions(+), 211 deletions(-) create mode 100644 changelog.d/160.feature.md diff --git a/PLANS.md b/PLANS.md index fc7dcfe..eb16b5f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -126,7 +126,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-03 | done | Hosted run 33400677367 accepted exact OCR 1.11.1 structural evidence | | WQ-04 | done | #159 suppresses unsafe deltas when either side was not admitted while preserving comparable pairs | | WQ-05 | done | #162 guidance, exact OCR 1.11.1 pins/rules/evidence, and local binary are complete | -| WQ-06 | pending | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | +| WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | pending | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | pending | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | | WQ-09 | pending | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | @@ -193,6 +193,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. requests; Pug, `.v`, `.vh`, `.sv`, `.vhd`, and `.vhdl` were selected with system rules while unqualified `.svh` remained excluded. The focused compatibility/runtime/docs suite passed with 291 tests and 104 subtests. +- 2026-08-31: #160 adds separate fixed literal-search and exact-coverage tools over the existing + DLP-admitted store. Action receipt v2 and toolkit receipt v6 reconcile every tool separately; + incomplete coverage cannot prove absence or support approval, and only non-zero reconciled + counts reach Technical details. Ruff format/check, `git diff --check`, and the focused boundary, + installed-artifact, documentation, receipt, publication, and approval suite passed with 621 + tests and 350 subtests. Self-review also corrected two current-contract references from receipt + v5 to v6 without rewriting historical compatibility records. #### Risks And Recovery @@ -212,8 +219,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Begin WQ-06 with the provider-neutral search/coverage query contracts, then connect the two -fixed tools to the existing built-in MCP, routing guidance, action receipt v2, and receipt v6. +Begin WQ-07 with the provider-neutral CI outcome model and protected context-policy v3, then +connect one bounded twice-read GitLab snapshot to store/MCP degradation and approval tests. #### Plan Fidelity Check diff --git a/README.md b/README.md index 81e6b41..e54a365 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,13 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. -After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v5's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, and adapter records do not independently block approval, while degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. +After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v6's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, and adapter records do not independently block approval, while degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries. ## Project architecture -The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. Protected-policy enriched reviews can acquire stable GitLab discussions, verified remediation history, and authorized external issue/document records before OCR. Forge-specific acquisition and posting stay at provider edges; the broker, DLP, store, MCP, receipts, and tests use common contracts so a future GitHub adapter can reuse them without inheriting GitLab API semantics. The same built-in MCP exposes only opaque committed `context_list`/`context_get` handles; it has no provider network or arbitrary identifier path. Direct external MCP remains a separate privileged, comment-only operator boundary. +The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, literal search, scoped absence checks, and base/head changes through three fixed tools in the mandatory built-in read-only MCP server. Protected-policy enriched reviews can acquire stable GitLab discussions, verified remediation history, and authorized external issue/document records before OCR. Forge-specific acquisition and posting stay at provider edges; the broker, DLP, store, MCP, receipts, and tests use common contracts so a future GitHub adapter can reuse them without inheriting GitLab API semantics. The same built-in MCP exposes only opaque committed `context_list`/`context_get` handles; it has no provider network or arbitrary identifier path. Direct external MCP remains a separate privileged, comment-only operator boundary. - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals. - [Bounded review context](docs/review-context.md) - protected policy, adapter protocol, GitLab discussions, opaque handles, DLP, receipt, and cleanup contracts. diff --git a/changelog.d/160.feature.md b/changelog.d/160.feature.md new file mode 100644 index 0000000..8229ead --- /dev/null +++ b/changelog.d/160.feature.md @@ -0,0 +1 @@ +Add two fixed read-only built-in MCP tools for bounded literal evidence search and exact scoped coverage checks. OCR now receives concise summary/list/search/get/coverage routing, while action receipt v2 and toolkit receipt v6 reconcile all five actions to OCR's per-tool counts. Only non-zero numeric counts may reach GitLab; queries, scopes, matched values, IDs, and results remain private, and incomplete coverage can never prove absence or authorize automatic approval. diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md index a0e6784..aedb4d8 100644 --- a/docs/codex/TASKS_BACKLOG.md +++ b/docs/codex/TASKS_BACKLOG.md @@ -44,7 +44,7 @@ M3 is established. BL-011 is complete and recorded above rather than retained as ## M6 Profiles and quality measurement -Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v5, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. +Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v6, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. ### BL-016: Evaluate explicit run-level model profiles diff --git a/docs/configuration.md b/docs/configuration.md index 4f6c251..25eed74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,7 +114,7 @@ These names belong to `examples/gitlab/ocr-review.gitlab-ci.yml`; they are shell ## MCP composition and trust boundary -OCR receives every MCP as an independent named entry in its `mcp_servers` registry. The toolkit always installs the fixed `ocr_toolkit_evidence` entry; each configured external MCP is an optional sibling with a non-empty explicit tool-name allowlist. Reserved names and cross-server tool collisions fail closed, so an external server cannot shadow mandatory evidence. +OCR receives every MCP as an independent named entry in its `mcp_servers` registry. The toolkit always installs the fixed `ocr_toolkit_evidence` server with `ocr_toolkit_evidence`, `ocr_toolkit_evidence_search`, and `ocr_toolkit_evidence_coverage`; each configured external MCP is an optional sibling with a non-empty explicit tool-name allowlist. Reserved server/tool names and cross-server tool collisions fail closed, so an external server cannot shadow mandatory evidence. The accepted transport schema depends on an internal execution profile, not on a public CI-detection variable. During a validated GitLab merge-request review, external entries must use `type=remote` with an absolute HTTPS `url`, optional bounded non-secret `headers`, environment-backed secret `headers_from`, and `tools`. `command`, `args`, `env`, `env_from`, and `setup` are rejected. Existing OCR MCP configuration is treated as hostile persisted input and revalidated through the same exact GitLab-MR schema before composition; the fixed toolkit-owned stdio evidence entry is the sole stdio exception. Outside the GitLab-MR provider path, the local profile preserves explicit developer-managed stdio entries with `command`, `args`, literal `env`, `env_from`, `tools`, and optional `setup`. Remote entries reject `setup` in every profile. `headers_from` writes a `$VARIABLE` reference and OCR expands it at connection time; sensitive literal headers are rejected. @@ -135,7 +135,7 @@ Generic external MCP is privileged operator configuration, not safe author-trigg Use direct composition only for reviewed narrow read-only tools, dedicated least-privilege credentials, server-enforced resource authorization, bounded server responses, and data acceptable for both model egress and OCR-session retention. A direct tool must be safe in both phases. Do not expose generic search, arbitrary URL/ID fetch, recursive traversal, writes, or broad service credentials to references that merge-request text can select. Treat command, endpoint, environment, headers, setup, descriptions, schemas, arguments, and responses according to their separate executable or untrusted boundaries. Local-profile `setup` runs as operator-owned shell configuration in the analyzed repository; keep it empty unless explicitly reviewed. Do not expose raw endpoint, setup, or credential values through toolkit diagnostics; OCR may emit operator-owned setup or transport details, so keep those values non-sensitive and retain OCR stderr privately. -An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v5 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a review comment-only even when unused; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. +An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v6 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a review comment-only even when unused; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. ### Review-context selector @@ -209,18 +209,18 @@ severity is exactly `low` and category is exactly `style`, `documentation`, or `maintainability`, are eligible. Missing, unknown, differently cased, or non-string metadata blocks approval, as do warnings, failed or waived coverage, partial/budget outcomes, any receipt other than v5, degraded selected metadata, any configured direct external MCP, required context degradation, a DLP-rejected selected source, admitted remediation context, -and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v5, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no +and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v6, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no environment variables for policy thresholds or category lists in this release. `ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only copy containing the toolkit's bounded MCP-use receipt. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. ## Repository evidence -`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP atomically records only completed `summary`, `list`, and `get` counts without arguments, IDs, paths, results, or content. The parent reads and removes that private receipt before cleanup and exposes the breakdown only when its total exactly matches OCR's `tool_calls.by_tool.ocr_toolkit_evidence`; missing, malformed, raced, or mismatched attribution is explicitly unavailable rather than zero. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` call; a legitimately skipped no-supported-files review remains exempt. +`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP atomically records only completed `summary`, `list`, `get`, `search`, and `coverage` counts without arguments, queries, IDs, paths, results, or content. The parent reads and removes private action receipt v2 before cleanup. Receipt v6 is approval-valid only when the primary-tool action subtotal and the two dedicated-tool counts exactly match OCR's three `tool_calls.by_tool` entries; missing, malformed, raced, or mismatched attribution is explicitly unavailable rather than zero and cannot authorize approval. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` call; a legitimately skipped no-supported-files review remains exempt. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, and count-only evidence-action receipt/lock are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt targets are rejected. In GitLab MR pipelines, the provider adapter captures the current protected target SHA, fetches that exact immutable object when needed, and materializes only an in-repository `--rule` blob from it; explicit absolute rules outside the repository remain operator-owned. OCR still reviews the original forge diff-base-to-source-head range. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. -The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. An explicit `kind=repository.evidence_delta` list query returns redacted base/head changes; `delta_kind` narrows them by their original fact kind, and their stable IDs can be passed to `get`. A unique semantic fact retains the established compact before/after value. If one semantic identity has multiple sources, or moves between sources, the value becomes a deterministic list of `source_path` and `fact` objects so no accepted record is overwritten. The ordinary unfiltered list remains facts and scoped coverage only. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry. +The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. Start with `ocr_toolkit_evidence(action=summary)` once. Use its paginated/filterable `list` for a known kind or delta and stable-ID `get` only for selected records. Use `ocr_toolkit_evidence_search` only when a location or identity is unknown: its NFKC/case-folded query accepts 1–128 characters and at most eight literal tokens, rejects regex/wildcards/operators/control and bidi/format characters, searches only DLP-admitted paths, identities, and per-kind allowlisted scalars, and returns stable IDs plus closed metadata without echoing the query or matched value. Before a negative claim, call `ocr_toolkit_evidence_coverage` with an exact kind/domain and base/head ref plus optional exact component/path. Only applicable complete coverage, zero matches, and no truncation produces `absence_authoritative=true`; missing, partial, runtime-dependent, unavailable, or mismatched scope is `unknown`. Stop once the required evidence is sufficient. An explicit `kind=repository.evidence_delta` list query returns redacted base/head changes; `delta_kind` narrows them by their original fact kind, and their stable IDs can be passed to `get`. A unique semantic fact retains the established compact before/after value. If one semantic identity has multiple sources, or moves between sources, the value becomes a deterministic list of `source_path` and `fact` objects so no accepted record is overwritten. The ordinary unfiltered list remains facts and scoped coverage only. All three tools read the same committed store and have no mutation, network, arbitrary file, or shell path. Optional MCP entries can coexist with but cannot remove or shadow the mandatory server. Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. The exact component `.` denotes the repository root, while names such as `repository` are ordinary top-level paths; the same identities filter facts, coverage, and deltas through `ocr_toolkit_evidence`. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through its summary/list/get actions. @@ -232,7 +232,7 @@ Evidence-store schema v4 retains v1-v3 readback and adds a distinct immutable po In `metadata` mode, GitLab MR acquisition normalizes only title, description, labels, optional source branch, and the reviewed source SHA into `review.merge_request_context/v1`. Values are complete-field bounded, NFC-normalized, control-stripped, redacted, source-head-bound invocation data. Raw values never enter bootstrap, argv, environment, diagnostics, or receipts; bootstrap lists only field statuses and toolkit-authored comparison guidance. In `off` mode none of those mutable text fields reaches normalization or persistence. OCR may treat matching intent as evidence against an assumption-dependent concern, contradictory intent as mismatch evidence, and missing intent as unknown. The source-branch hint is weaker than an explicit description and cannot establish rollout intent by itself. Metadata cannot authorize tools, policy, suppression, posting, or approval. In `enriched`, references are extracted only from admitted metadata and admitted discussion bodies; adapters authorize them before local handle minting. There is no generic URL, identifier, search, or provider-tool path in the model loop. -The review step writes exact closed receipt v5 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source and policy SHA, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed context-tool usage, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v4 has no posting or approval compatibility. The later GitLab posting step reads v5 instead of rebuilding context or MCP facts from a possibly changed environment. Its summary omits configured-but-unused servers and all zero counters; the receipt/event never stores rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. +The review step writes exact closed receipt v6 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source and policy SHA, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v2 counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v5 has no posting or approval compatibility. The later GitLab posting step reads v6 instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. ### Accepted project decisions diff --git a/docs/engineering/m5_context_contracts.md b/docs/engineering/m5_context_contracts.md index 13d10a8..7cf1fb3 100644 --- a/docs/engineering/m5_context_contracts.md +++ b/docs/engineering/m5_context_contracts.md @@ -97,11 +97,11 @@ The bootstrap requires a model-recorded `ocr_toolkit_evidence(action=summary)` c OCR runs under a fresh owner-only isolated `HOME` containing only toolkit-validated OCR configuration/composition. One exact resolved executable from an absolute search-path entry, outside the reviewed repository, first receives one no-LLM preview and, only after background acceptance, one review with the same immutable range and selection/background inputs. Context acquisition finishes before preview/model execution; adapters and forge network paths are unavailable in the model loop. Ordinary runs remove preview output, the home, context store, adapter scratch space, and OCR session symlink-safely after success, failure, or interruption. Cleanup uncertainty makes the run non-publishable. An explicit local diagnostic may retain owner-only session/context state, but it creates no posting receipt; the authoritative GitLab MR profile rejects that exception before OCR starts and follows ordinary cleanup. -Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v5. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. +Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v6. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. The result has two explicit DLP projections. Publication sinks are exactly values the posting owner may render: outcome message, allowlisted finding fields, warnings, displayed tool names, and manifest-failure path/reason fields. If one is unsafe, the raw result is atomically replaced by an explicit safe filtered result containing independently passed findings/warnings plus closed reason, omission, original-coverage, and tool-use facts. Horizontal tab is admitted as a control character only in `existing_code` and `suggestion_code`; the unchanged code value is retained only after all remaining DLP checks pass. Non-rendered OCR metadata is a separate private-retention projection: unsafe keys are removed and unsafe string values receive stable non-reversible placeholders before the result is retained. When that sanitized result still satisfies the OCR result contract, its original status, manifest, and safe findings remain intact; a required structural-field loss falls back to the filtered form. Neither path retains the rejected value or its location. GitLab may publish the safe result with a filtered signal, while retaining the previous review, consuming prior matching fingerprints one-for-one, replacing only an earlier toolkit setup-pending note on retry, and blocking approval. Validated original coverage kind/counts remain presentation evidence distinct from public-projection completeness; filtered warnings never create legacy coverage failures. This is containment of deterministic exact-output classes, not a claim to detect shorter arbitrary excerpts, arbitrary semantic paraphrase, or undo content already sent to the model. -Receipt schema `ocr.toolkit-receipt/v5` stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable flag, fixed tool usage, publication-DLP result, and cleanup result. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v4 result receipts are rejected rather than migrated. +Receipt schema `ocr.toolkit-receipt/v6` stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable flag, fixed tool usage, publication-DLP result, and cleanup result. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v5 result receipts are rejected rather than migrated. Schema versions protect serialized trust boundaries; they are not a database-retention promise. The review result crosses from the review process/job to hostile posting readback, so its version prevents an older field set from inheriting newer approval guarantees. Policy and adapter versions similarly bind independent producers/consumers. Ephemeral evidence/context stores accept only their exact current schema and intentionally have no migration or upgrade path. diff --git a/docs/engineering/review_signal_ownership.md b/docs/engineering/review_signal_ownership.md index e5678f3..9c997e9 100644 --- a/docs/engineering/review_signal_ownership.md +++ b/docs/engineering/review_signal_ownership.md @@ -9,14 +9,14 @@ a new telemetry API. | Signal | Authoritative source | Toolkit projection | Privacy and authority boundary | | --- | --- | --- | --- | -| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v5, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | +| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v6, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | | Prompt, completion, cached, reasoning, and total tokens | OCR result and OCR telemetry | Closed non-negative provider-neutral token buckets in the result summary and canonical publication comparison | Unknown fields are ignored; malformed or contradictory counters are unavailable. Token counts never authorize approval or automatic routing. | | Review effort and executed rounds | Operator-owned root `effort` config and OCR runtime | `OCR_REVIEW_EFFORT` writes one closed `low`, `medium`, or `high` setting; the toolkit does not publish inferred round telemetry | Merge-request content cannot select effort. Budget or incomplete coverage remains approval-ineligible through the existing result contract. | -| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v5, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | -| Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory evidence-action attribution in receipt v5 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | +| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v6, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | +| Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory evidence-action attribution in receipt v6 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | | Selection, completed/reused/failed/waived coverage, and aggregate-budget stop | OCR run manifest | Closed result outcome, summary, receipt validation, and approval blockers | Incomplete, malformed, failed, waived, or budget-stopped coverage fails closed; no duplicate toolkit budget metric is needed. | | Findings, severity, fingerprints, suppression, resolution, and repeated discussions | OCR findings plus toolkit-owned posting snapshots and human commands | GitLab discussions, summary, exact fingerprints, and closed lifecycle state | Remediation text and additive group metadata cannot change severity, prove resolution, suppress findings, or issue commands. | -| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v5 context/evidence state | Raw merge-request title, description, discussions, provider identities, rejected text, and record contents do not enter the receipt or telemetry event. | +| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v6 context/evidence state | Raw merge-request title, description, discussions, provider identities, rejected text, and record contents do not enter the receipt or telemetry event. | | Publication DLP and posting transaction state | Toolkit result projection and GitLab transaction owner | Receipt-v5 publication state, one parseable summary marker, and one local count-only log event | `private-sanitized` can preserve approval only when the canonical projection is unchanged; `publication-filtered` makes the public projection incomplete and blocks approval without relabelling independently complete OCR coverage. No rejected value or location is emitted. | | OCR compatibility qualification | Compatibility workflow and checksum-pinned evidence | Canonical issue plus bounded success or failure artifact | Public failure status contains only closed phase/reason/version/run fields. Raw qualification exceptions stay in the job log. | diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index 4219494..b77942f 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -102,7 +102,7 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | Exact OCR 1.10.0 review contract | compatibility gateway -> hosted Linux binary -> manifest/evidence promotion | path-aware comments, semantic grouping/filtering, default-medium two rounds, token/tool accounting, partial budget, inherited `16384` and explicit `4096` completion caps, and closed numeric CLI/effective-value boundaries | hosted run 32825123658; checksum-pinned `compatibility/evidence/ocr-1.10.0.json`; independent Darwin arm64 no-LLM gateway probes including actual max-tools loop exhaustion | deterministic gateway proves the consumed wire/result/loop contract, not provider quality or production spending policy | proven for exact qualified OCR wire contract | | Normalized max-tools preview reaches enriched production orchestration safely | `run_evidence_review` collection -> context/store -> MCP composition/self-query -> exact production OCR preview argv | exact recognized `30 -> 50` diagnostic crosses the gate, emits only a closed operator notice, and does not enter result warnings or approval inputs; unknown output remains rejected | parser adversarial matrix plus complete enriched/MCP production-caller regression in `test_review_runner.py` | a subprocess double beyond the production launcher emits the qualified OCR diagnostic and stops at the model-review boundary; the exact binary probe separately proves its grammar and effective rounds | proven for toolkit orchestration without a model-quality claim | | Operator effort stays closed and source-independent | `configure.review_effort` -> generated root `effort` -> OCR CLI precedence | unset/empty becomes `medium`; only `low|medium|high` is accepted; explicit OCR `--effort` remains the per-run override | environment/configuration, runtime-helper, integration, compatibility, and installed wheel/sdist tests | environment and local gateway fixtures prove configuration/round wiring; they do not prescribe a cost-quality choice for every repository | proven | -| Group/round diagnostics cannot gain posting or approval authority | private result -> recursive DLP -> canonical projection -> receipt v5 -> approval | safe group data leaves the canonical projection and eligibility unchanged; PII/secrets are privately sanitized; receipt extensions fail closed | `test_review_runner.py`, `test_posting_approval.py`, and BL-017 source-to-signal audit | result fixtures prove deterministic toolkit isolation, not the semantic quality of OCR grouping | proven | +| Group/round diagnostics cannot gain posting or approval authority | private result -> recursive DLP -> canonical projection -> receipt v6 -> approval | safe group data leaves the canonical projection and eligibility unchanged; PII/secrets are privately sanitized; receipt extensions fail closed | `test_review_runner.py`, `test_posting_approval.py`, and BL-017 source-to-signal audit | result fixtures prove deterministic toolkit isolation, not the semantic quality of OCR grouping | proven | | Result-file ownership remains toolkit-only | review argument validation -> pre-opened result descriptor -> atomic finalization/cleanup | caller `--output`, `--output=...`, `-o`, and attached short forms fail before preview | `test_review_runner.py` and installed-artifact tests | subprocess doubles prove rejection timing; the exact OCR binary independently proves the new flag exists | proven | | Failed qualification retains bounded coordination evidence without becoming green | qualification status owner -> always-run issue/artifact handling -> terminal failure step | closed phase/reason/version/run reaches the canonical issue/artifact; raw exception remains job-log-only; aggregate cannot run from failure | compatibility CLI/workflow tests and the repaired workflow contract | static Action tests prove control flow; the next real failed candidate run remains operational readback evidence | proven for workflow contract | @@ -112,7 +112,7 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | --- | --- | --- | --- | --- | --- | | Exact OCR 1.10.1 review contract | compatibility gateway -> hosted Linux binary -> manifest/evidence promotion | result/manifest, inherited `16384` and explicit `4096` completion caps, default-medium two rounds, semantic grouping, and behaviorally observed max-tools values remain compatible | hosted run 32955196785; checksum-pinned `compatibility/evidence/ocr-1.10.1.json`; independent checksum-verified Darwin arm64 local gateway probe | deterministic gateways prove the consumed wire/result/loop contract, not provider quality or production spending policy | proven for exact qualified OCR wire contract | | New upstream rule selection | exact OCR preview -> allowlist and built-in rule resolver | `.m` input is reviewable and content selects MATLAB or Objective-C behavior without changing the toolkit rule file | isolated Darwin arm64 no-LLM `ocr rules check` readback for MATLAB and Objective-C plus upstream semantic audit | selection evidence proves language/rule routing, not finding quality | proven | -| Bounded upstream session keys stay private | OCR runtime private cache/session owner -> toolkit result boundary | new bounded keys do not enter result, receipt v5, toolkit telemetry, GitLab text, or approval | upstream semantic audit plus unchanged result/manifest probes | source and result evidence prove the consumed boundary, not every upstream internal cache implementation | proven | +| Bounded upstream session keys stay private | OCR runtime private cache/session owner -> toolkit result boundary | new bounded keys do not enter result, receipt v6, toolkit telemetry, GitLab text, or approval | upstream semantic audit plus unchanged result/manifest probes | source and result evidence prove the consumed boundary, not every upstream internal cache implementation | proven | ## v0.8.5 OCR 1.10.2 evidence diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index e16f560..651432c 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -37,7 +37,7 @@ The legacy `context/*` Markdown renderer, its CLI/environment contract, and its The built-in evidence MCP is mandatory for ordinary evidence-backed reviews. External stdio and native HTTPS Streamable HTTP servers compose as independent optional entries; replacement mode may discard stale external entries but cannot remove or shadow the built-in server. The compact bootstrap is generated from the same validated capability composition that is written to OCR. -GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v5 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. +GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v6 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection and reconciles the fixed summary/list/get/search/coverage action counters. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. ## Implemented Repository Evidence Engine @@ -113,7 +113,7 @@ Safe direct composition is therefore limited to reviewed narrow read-only tools, ## Bounded review-context enrichment -M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 supersedes the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. It extends bounded invocation evidence without reopening M1/M4 or creating a second review engine. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. +M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advances current output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. It extends bounded invocation evidence without reopening M1/M4 or creating a second review engine. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. The target architecture acquires forge discussion snapshots and deterministic reference candidates before OCR. An immutable `.opencodereview/review-context-policy.json` read only from the captured protected-target SHA independently controls admission, retrieval, model egress, publication, and retention. Recognizers produce candidates but never authorize them. A provider adapter must authorize the exact tenant, canonical object, fields, and operation, retrieve a bounded version-bound projection, apply normalization and DLP, and atomically commit it to a run-local context store before an opaque unguessable handle is minted. Handles bind run, adapter, tenant, canonical object, projection, version or digest, policy version, expiry, and stored record without exposing the upstream identifier. diff --git a/docs/gitlab.md b/docs/gitlab.md index 06036ad..05adb58 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -24,7 +24,7 @@ OCR 1.11.0 semantically groups related changed files before review and filters c The summary keeps four inputs separate: OCR manifest coverage, publication integrity, published findings, and an optional OCR core advisory. Complete manifest coverage that later loses public fields is labelled `Review complete with publication filtering`; it is warning-bearing and approval-ineligible, but it is not called incomplete OCR coverage and does not invent failed-file diagnostics. Actual partial, failed, waived, or budget-stopped manifest coverage keeps its stronger status. Ordinary OCR warnings retain their own complete-with-warnings state. The reviewer guide ranks bounded focus areas only when at least two findings are published; one finding remains in its inline or fallback discussion without a duplicate snippet. Security focus uses explicit published metadata, strong vulnerability terms, and closed injection classes rather than the standalone word `injection`, so neutral knowledge/dependency terminology cannot increase its count or effort estimate. -The collapsed technical details keep aggregate input/output/cache token usage separate from OCR tool activity. The existing `all OCR tool calls` line lists every non-zero count for the closed review set (`file_read`, `file_read_diff`, `file_find`, `code_search`, `code_comment`, `task_done`, `ocr_toolkit_evidence`, `context_list`, and `context_get`) and is omitted when that admitted list is empty. These counts help explain whether OCR read context, searched the repository, checked cross-file diffs, consulted toolkit evidence, or emitted review output; they are not per-tool token attribution. Dynamic external MCP tool names remain private and appear only through the existing verified per-server aggregate. Tool and token lines remain independent of `passed`, `private-sanitized`, or `publication-filtered` receipt state. If OCR accepts a background above its recommended character count, a separate `OCR core advisory` line contains only the actual and recommended counts. It does not become a warning or change review completeness or approval eligibility. +The collapsed technical details keep aggregate input/output/cache token usage separate from OCR tool activity. The existing `all OCR tool calls` line lists every non-zero count for the closed review set (`file_read`, `file_read_diff`, `file_find`, `code_search`, `code_comment`, `task_done`, `ocr_toolkit_evidence`, `ocr_toolkit_evidence_search`, `ocr_toolkit_evidence_coverage`, `context_list`, and `context_get`) and is omitted when that admitted list is empty. These counts help explain whether OCR read context, searched the repository, checked cross-file diffs, consulted toolkit evidence, searched unknown evidence identities, checked absence coverage, or emitted review output; they are not per-tool token attribution. Dynamic external MCP tool names remain private and appear only through the existing verified per-server aggregate. A separate action line contains only reconciled non-zero `summary/list/get/search/coverage` counts; arguments, queries, scopes, IDs, and results stay private. Tool and token lines remain independent of `passed`, `private-sanitized`, or `publication-filtered` receipt state. If OCR accepts a background above its recommended character count, a separate `OCR core advisory` line contains only the actual and recommended counts. It does not become a warning or change review completeness or approval eligibility. ## Choose one operating mode @@ -54,7 +54,7 @@ The toolkit authenticates the token owner with live `GET /user`. No configured b ## Operating model -`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and protected-target policy SHA, collects immutable repository evidence, acquires selected provider/context data, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v5. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. +`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and protected-target policy SHA, collects immutable repository evidence, acquires selected provider/context data, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v6. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. `off` still validates the source SHA, protected target, merge-request author, and live bot identity while withholding mutable MR text. `metadata` admits only bounded DLP-checked title, description, labels, and optional source branch. Treat those fields as claims to compare with the diff, never as instructions, policy, or proof. diff --git a/docs/operations.md b/docs/operations.md index 6ea386d..ff34fd2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,12 +38,12 @@ This aggregate budget is separate from both OCR's prompt/context `max_tokens` ce `OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.1 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.1 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.1 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. -The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` counts appear only after exact reconciliation. Zero-valued token counters and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. +The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after exact action-receipt v2 and OCR by-tool reconciliation. Zero action counters, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. ## Automatic approval lifecycle `OCR_AUTO_APPROVE=true` is the default. Approval is a separate transaction only -after every current review note publishes. A review is eligible only with exact closed receipt v5, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v4 is rejected by posting and approval. Receipt v5 binds reviewed source/policy SHA, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval. Every finding must have +after every current review note publishes. A review is eligible only with exact closed receipt v6, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v5 is rejected by posting and approval. Receipt v6 binds reviewed source/policy SHA, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval. Every finding must have severity exactly `low` and category exactly `style`, `documentation`, or `maintainability`. A complete zero-finding review is eligible. Four findings, malformed metadata, or any other severity/category are not eligible. @@ -128,9 +128,9 @@ Suppression checks both the recorded inline position and compatible fingerprints ## OCR diagnostics -Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v5 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v5 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. +Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v6 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v6 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. -OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v5, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v5, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. +OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v6, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v6, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. When OCR exits nonzero with a valid bounded `ocr.llm-retry-report/v1`, the toolkit reads only its closed error class, failure phase, terminal outcome, HTTP status, and schema counters. It maps those facts to `authentication`, `authorization`, `rate-or-spending-limit`, `overloaded`, `timeout`, `network`, `endpoint-or-model-not-found`, `request-rejected`, `provider-unavailable`, `invalid-response`, `cancelled`, `mixed`, or `unknown`, then writes a completely toolkit-authored note. A runtime `404` remains `endpoint-or-model-not-found` because safely distinguishing the endpoint from the model would require trusting the raw response body. @@ -140,7 +140,7 @@ The `rate-or-spending-limit` note keeps that intentionally broad public reason a Raw provider/model identities, response bodies, error codes and messages, request IDs, paths, warnings, and stderr remain in owner-only private artifacts for a classified provider failure. `OCR_POST_ERROR_DETAILS=1` cannot add them to that note or the numeric line. Normal findings from the failed result are ignored, the previous successful review is preserved, and automatic approval is not attempted. Missing, oversized, malformed, or internally contradictory retry reports keep the existing generic failure path instead of guessing a classification. -For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v5; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. +For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v6; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. Two pre-execution outcomes have narrower static reporting. When the merge request introduces the configured repository-owned OCR rules path and that exact path is absent from both immutable policy-side baselines, `review` verifies only that the source object is a bounded regular blob, writes a closed private status, and stops before OCR. When installed OCR rejects the generated background during preview, the status instead carries the closed character/file-size reason plus actual, limit, and unit. `post` hostile-validates either v2 status against the current source and diff-base identities and renders only toolkit-authored text. A successful retry replaces only an earlier toolkit-owned setup-pending note; background rejection and generic failures never replace previous review findings or summaries. Neither outcome includes the rules/background path or raw stderr and neither trusts repository/provider display text. Malformed, stale, unsafe, unknown, or identity-mismatched state falls back to the generic failure note. `OCR_POST_EMOJI=false` removes the heading emoji; `OCR_POST_ERROR_DETAILS` does not add detail to recognized static outcomes. diff --git a/docs/review-context.md b/docs/review-context.md index 09bab0c..60e210f 100644 --- a/docs/review-context.md +++ b/docs/review-context.md @@ -10,7 +10,7 @@ Open Code Review Toolkit can enrich one validated forge review with bounded merg - `metadata` additionally admits bounded title, description, labels, and source-branch text. - `enriched` requires a validated GitLab merge-request environment and a valid protected-target policy. It includes the same metadata projection, a stable bounded GitLab discussion snapshot, verified toolkit-owned remediation threads, and policy-recognized external records when selected. Missing or invalid policy stops the review before OCR. -The lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v5 through one inode-checked atomic replacement. A cleanup or publication-validation failure blocks ordinary result publication. +The lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v6 through one inode-checked atomic replacement. A cleanup or publication-validation failure blocks ordinary result publication. ## Protected-target policy @@ -122,7 +122,7 @@ Candidates are extracted only from admitted merge-request metadata and admitted Projection fields are sorted unique lists. `model`, `publish`, and `retain` must each be subsets of `retrieve`. Retention is limited to `state`, `count`, `digest`, `version`, and `expiry`; it cannot retain text, upstream identifiers, URLs, commands, transport data, or personal display data. Retrieval, model egress, publication, and retention are deliberately separate decisions. -Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` is additive and permits the optional `remediation_threads` selector; v1 rejects that field instead of interpreting it with weaker semantics. New examples use v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v5 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. +Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` is additive and permits the optional `remediation_threads` selector; v1 rejects that field instead of interpreting it with weaker semantics. New examples use v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v6 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. ## Operator adapter allowlist @@ -179,7 +179,7 @@ A remediation bundle begins only at a toolkit-owned root whose author ID equals The private `ocr.context-store/v2` is independent from the repository evidence store and its budgets. It is atomically written owner-only and hostile-read before OCR. Only a fully normalized and DLP-checked committed record receives a `ctx1_` handle containing 32 random bytes encoded as unpadded base64url. The private mapping binds run, policy digest, adapter, tenant, canonical object, resource class, projections, version/digest, and expiry. It is not an encoded upstream ID. -In `off` and `metadata`, the built-in MCP exposes only `ocr_toolkit_evidence`. In `enriched`, it exposes exactly `ocr_toolkit_evidence`, `context_list`, and `context_get`: +In `off` and `metadata`, the built-in MCP exposes the fixed evidence tools `ocr_toolkit_evidence`, `ocr_toolkit_evidence_search`, and `ocr_toolkit_evidence_coverage`. In `enriched`, it additionally exposes exactly `context_list` and `context_get`: - `context_list` accepts only optional `resource_class`, admitted `source`, `page_size` from 1 through 20, and an opaque cursor. Resource classes are `issue`, `document`, and `remediation_thread`. It returns safe descriptors, minted handles, expiry, mutability, per-source completeness, and a next cursor. - `context_get` accepts exactly one listed `ctx1_` handle and returns only the record's protected `model` projection. A remediation record contains one DLP-checked root, safe anchor state, ordered pseudonymized replies, closed completeness, and reply/resolved/outdated counts. It never contains a raw provider identity or object. @@ -190,11 +190,11 @@ Both tools read the already committed local store. They have no network, subproc After OCR exits, the toolkit applies separate publication-sink and private-retention projections against forbidden/non-publishable context, configured secrets, closed PII patterns, controls, Markdown destinations, and Unicode/HTML/Markdown laundering forms. Publication sinks are exactly the result values the posting owner can render: finding fields, warnings, outcome message, displayed tool names, and manifest failure path/reason fields. Other OCR metadata remains private but is independently scanned and sanitized before persistence; it cannot make safe publication sinks partial merely because an opaque SHA, UUID, item identity, or bare build number resembles a phone number. The conservative detector can still classify a separator-bearing technical identifier as phone-like. Ordinary receipts intentionally expose only closed aggregate counts; an explicit local preservation run adds a private value-free path/subtype/size/hash decision sidecar so the operator can diagnose that false-positive class against the separately retained raw result without weakening the filter or disclosing the value. The checks compare whole values and normalized contiguous excerpts of at least 24 characters; a work-bound uncertainty filters or sanitizes the affected unit. This does not claim detection of shorter arbitrary excerpts or semantic paraphrases, and it cannot reverse data already sent to the model. -An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v5 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. +An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v6 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. The summary contains distinct private-sanitization and publication-filtering details with an exact `ocr.publication-dlp-signal/v2` HTML marker containing only low-cardinality counts. The posting command emits the same JSON as an `OCR toolkit telemetry event` log line so CI log collectors can alert without a new runtime network/exporter path. OCR remains authoritative for provider/token/request telemetry; the toolkit does not add an OTLP client, arbitrary telemetry endpoint, raw value/path, or mandatory external export. The explicit local `ocr-ci review --preserve-private-artifacts` diagnostic retains owner-only raw session/context state, adds a value-free `ocr.private-dlp-decisions/v1` path/reason/detector/size/hash attribution sidecar, and deliberately emits no posting receipt. It is local evidence for investigating conservative false positives, not a CI or publication artifact; the validated GitLab MR profile rejects it. -Receipt v5 stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, publication-DLP result, and cleanup result. It does not store context text, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v4 is rejected; ephemeral results have no migration path. +Receipt v6 stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, publication-DLP result, and cleanup result. It does not store context text, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v5 is rejected; ephemeral results have no migration path. Every existing manifest, coverage, warning, omission, finding, source-SHA, author, provider, and self-approval gate remains. Degraded selected metadata, a DLP-rejected selected source, required-source degradation, and any admitted remediation record make the run ineligible. DLP-clean generic discussions and adapter records do not independently block approval; optional non-DLP degradation remains visible and cannot prove absence. A complete enriched run without admitted remediation is not blocked solely by the selected mode. Direct operator MCP is a separate privileged boundary and remains comment-only. diff --git a/docs/security.md b/docs/security.md index e41b434..d2c5ea8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -23,7 +23,7 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp 5. Typed repository evidence crosses into the compact bootstrap and mandatory read-only evidence MCP. During enriched OCR, only the same built-in process's fixed `context_list`/`context_get` can read committed local handles; adapter/provider network paths are absent from the model loop. 6. Persisted evidence, context, results, OCR configuration, private pre-execution status, and receipts re-enter hostile parsing. OCR runs in a fresh isolated home; session, configuration, adapter, and context data crosses deterministic cleanup before a result becomes publishable. 7. In the direct-MCP GitLab profile, operator-configured external MCP is remote HTTPS only; developer-local execution may pass explicit stdio command/setup configuration. Server-authored descriptions and schemas cross into plan and main model context; model-generated arguments cross to allowed tools; textual responses cross back to the model and OCR session. This path is separate from M5 adapters. -8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v5 approval policy. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. +8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v6 approval policy. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. 9. A release candidate crosses protected-base authorization, publication, provenance, and live readback. ### Security objectives @@ -38,7 +38,7 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp - Context policy cannot come from the source branch, context budgets cannot evict repository evidence, and model-facing context cannot add a network, arbitrary ID/URL, search, traversal, or write path. - Publication and retention are independent from retrieval/model egress. Cleanup uncertainty blocks publication. DLP selects exact posting sinks separately from private OCR metadata. Unsafe sinks produce an explicit safe partial result; unsafe private-only keys/values are removed or replaced before retention without discarding a still-valid manifest or safe findings. Both paths atomically destroy the rejected value/location and expose only closed counts. Publication filtering preserves prior review state, matches repeated fingerprints one-for-one, and blocks approval; private-only sanitization may continue through all existing approval gates only after exact canonical equivalence. - Classified provider failure reporting consumes only validated retry class, phase, terminal outcome, HTTP status, and schema counters. Provider/model identity, URL, response body, error code or message, request ID, path, warning text, token, and stderr cannot enter the static note or closed numeric line or become a receipt, DLP, telemetry, severity, finding, or approval signal. A status-derived label describes protocol state only; it cannot assert the provider's billing or admission cause. -- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v5, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. +- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v6, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. - Provider mutations bind reviewed identity where supported; ambiguous inline creates use one author-bound readback without retry, and unresolved ambiguity preserves prior state. - Secrets remain outside repository-controlled context, public notes, fixtures, and release artifacts. @@ -59,7 +59,7 @@ These observations establish the current safe-use envelope, not the safety of ar Server command, endpoint, setup, schemas, descriptions, arguments, and responses cross separate executable or untrusted boundaries. GitLab-MR external entries are remote HTTPS only and reject stdio/setup fields; local-profile `setup` remains operator-owned shell configuration executed in the analyzed repository. Existing OCR MCP configuration is hostile persisted input and is revalidated against the active profile. Raw endpoint, setup, and credential values are not safe diagnostic material. Toolkit diagnostics avoid them, but OCR may emit operator-owned transport details; keep those values non-sensitive and retain OCR stderr privately. Managed OAuth authenticates a client but does not authorize an object and remains conditional. -Receipt v5 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt can further attribute completed `summary`, `list`, and `get` actions after exact reconciliation with OCR's tool total; absence, malformed content, races, or mismatch remains unavailable and does not weaken the mandatory-summary gate or add an approval blocker. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes the review comment-only, independent of use; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. +Receipt v6 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt can further attribute completed `summary`, `list`, and `get` actions after exact reconciliation with OCR's tool total; absence, malformed content, races, or mismatch remains unavailable and does not weaken the mandatory-summary gate or add an approval blocker. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes the review comment-only, independent of use; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. ## Preserved current safety properties @@ -69,7 +69,8 @@ Receipt v5 records the bounded configured capability inventory and positive call - Result and provider reads have byte limits; notes enforce character and UTF-8 byte limits. Position-bearing inline creates reserve independent unguessable markers, classify closed outcomes, and perform at most one complete author-bound reconciliation read with no retry. - Publication DLP admits ordinary horizontal tab only in `existing_code` and `suggestion_code`. The unchanged code value is retained only after it passes the existing size, secret, PII, forbidden-value, and laundering checks; tabs elsewhere and vertical tab, form feed, NUL, bidi/format controls, and other unsupported controls remain blocking. - LLM provider URLs are normalized by one forge-neutral owner shared by configuration and preflight; only credential-free absolute HTTPS roots or protocol-compatible terminal endpoints are accepted. Explicit protocol remains authoritative, and ambiguous auxiliary URL derivation fails closed. -- Automatic approval binds the exact synchronized reviewed head and MR author from receipt v5, skips self-approval, and never removes an existing approval. Partial, warning, non-v5, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. +- Automatic approval binds the exact synchronized reviewed head and MR author from receipt v6, skips self-approval, and never removes an existing approval. Partial, warning, non-v6, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. +- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v2 retains only five counters; receipt v6 requires exact by-tool reconciliation before automatic approval. - Human replies are ownership boundaries. Merge-request source SHA, protected-target policy SHA, and merge-result SHA remain distinct. - The evidence engine stores recursively redacted typed facts/deltas in owner-only files and serves a closed bounded network-independent MCP. Absence supports a negative claim only for applicable complete scope. - OCR rules, decisions, and guidance come only from captured protected-target blobs; source changes cannot create policy. Full guidance remains untrusted context, not authority. @@ -84,7 +85,7 @@ Ansible Galaxy requirements use the same immutable-object boundary. Relative inc ## M5 bounded enrichment boundary -The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v5 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. +The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v6 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. ### Data flow and trust transitions @@ -116,7 +117,7 @@ Policy v2 adds optional toolkit-owned remediation threads without changing these - **TOCTOU/replay/cache poisoning:** bind snapshots and handles to version/ETag or digest, policy version, run, and expiry; use atomic store commits and uniform unavailable outcomes. - **PII/secret bypass and output laundering:** make retrieval, model egress, publication sinks, and private retention separate decisions. Minimize before model egress; publication DLP cannot undo prior disclosure. Scan decoded source and rendered forms so HTML comments/entities/tags, Markdown inline/reference/autolink destinations, controls, and Unicode formatting cannot hide exact protected values. Sink uncertainty yields a safe partial result; private-only uncertainty yields an atomically sanitized valid result or falls back to that partial form. Expose only low-cardinality receipt/summary/log signals in ordinary runs. The explicit local-only diagnostic switch retains owner-only raw private state, emits no receipt, and is rejected by the validated GitLab MR profile. - **Upstream session and telemetry privacy:** OCR 1.11.0 can retain readable reasoning, signed/encrypted provider-native replay payloads, tool arguments/results, sorted changed paths, model-produced group labels, and round counters in its owner-only session. Telemetry may also carry repository-derived high-cardinality grouping values. OCR telemetry is disabled by default, and the toolkit removes the isolated session home before publication. Enabling an exporter authorizes it to receive the fields OCR emits; content-logging disablement is not a promise that every identifier disappears. The toolkit neither ingests nor duplicates these values. -- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v5 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. +- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v6 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. - **Local executable and subprocess confused deputy:** accept the OCR executable only through absolute search-path entries and reject resolution inside the reviewed repository. Stdio adapter deadlines cover request delivery as well as response/exit, use a clean environment and isolated directory, and terminate the process group on uncertainty. - **Marker and retry spoofing:** recognize ownership, run-summary, and setup identities only in the exact toolkit preamble. Repository/provider text containing marker lookalikes has no authority. Duplicate findings consume visible prior occurrences one-for-one; successful setup retries replace only previous toolkit setup-pending notes. - **Approval/suppression manipulation:** every admitted remediation thread blocks automatic approval; partial, required-degraded, or DLP-rejected context cannot prove absence or restore eligibility. Safe MR metadata, generic discussions, and adapter records do not themselves block an otherwise eligible receipt. Remediation text cannot change severity, prove a fix, suppress or resolve a finding, issue a lifecycle command, or authorize approval. Existing suppression, exact slash/mention commands, fingerprints, and discussion ownership remain separate deterministic consumers. diff --git a/src/ocr_toolkit/evidence/actions.py b/src/ocr_toolkit/evidence/actions.py index 70c2b50..e429cb7 100644 --- a/src/ocr_toolkit/evidence/actions.py +++ b/src/ocr_toolkit/evidence/actions.py @@ -13,8 +13,8 @@ from ocr_toolkit.evidence.store.atomic import atomic_write -ACTION_RECEIPT_SCHEMA = "ocr.evidence-action-receipt/v1" -EVIDENCE_ACTIONS = ("summary", "list", "get") +ACTION_RECEIPT_SCHEMA = "ocr.evidence-action-receipt/v2" +EVIDENCE_ACTIONS = ("summary", "list", "get", "search", "coverage") MAX_ACTION_CALLS = 1_000_000_000 MAX_ACTION_RECEIPT_BYTES = 4_096 diff --git a/src/ocr_toolkit/evidence/mcp.py b/src/ocr_toolkit/evidence/mcp.py index 7ca74d1..bcd0d99 100644 --- a/src/ocr_toolkit/evidence/mcp.py +++ b/src/ocr_toolkit/evidence/mcp.py @@ -7,7 +7,8 @@ import json import sys import time -from collections.abc import Iterator +import unicodedata +from collections.abc import Iterator, Mapping from dataclasses import dataclass from pathlib import Path from typing import TextIO, cast @@ -27,6 +28,8 @@ from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError TOOL_NAME = "ocr_toolkit_evidence" +SEARCH_TOOL_NAME = "ocr_toolkit_evidence_search" +COVERAGE_TOOL_NAME = "ocr_toolkit_evidence_coverage" SERVER_NAME = "open-code-review-toolkit-evidence" PROTOCOL_VERSION = "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS = { @@ -39,6 +42,24 @@ MAX_RESPONSE_BYTES = 64_000 DEFAULT_PAGE_SIZE = 20 MAX_PAGE_SIZE = 50 +DEFAULT_SEARCH_RESULTS = 20 +MAX_SEARCH_RESULTS = 50 +MAX_SEARCH_QUERY_CHARS = 128 +MAX_SEARCH_QUERY_TOKENS = 8 +_SEARCH_OPERATOR_CHARACTERS = frozenset("*?[]{}()|\\^$~:=!<>`\"'") +_SEARCH_VALUE_KEYS: dict[str, frozenset[str]] = { + "dependency": frozenset({"identity", "name", "package", "requirement", "version"}), + "runtime": frozenset({"identity", "name", "runtime", "version"}), + "container": frozenset({"digest", "identity", "image", "name", "tag", "version"}), + "ci": frozenset({"digest", "identity", "image", "name", "tag", "version"}), + "application": frozenset({"identity", "name", "version"}), + "framework": frozenset({"framework", "identity", "name", "plugin", "version"}), + "template": frozenset({"engine", "identity", "name", "path"}), + "repository": frozenset( + {"decision_id", "identity", "matched_paths", "name", "path", "scope", "scopes"} + ), + "ansible": frozenset({"collection", "group", "identity", "name", "role", "version"}), +} _MISSING_REQUEST_ID = object() @@ -162,6 +183,11 @@ def _optional_filter(arguments: dict[str, object], name: str) -> str | None: return None if not isinstance(value, str) or len(value) > 256: raise EvidenceMCPError(f"{name} must be a non-empty string of at most 256 characters") + if any( + character == "\x7f" or unicodedata.category(character) in {"Cc", "Cf"} + for character in value + ): + raise EvidenceMCPError(f"{name} must not contain control or format characters") return value @@ -270,6 +296,163 @@ def _get_record(store: EvidenceStore, arguments: dict[str, object]) -> dict[str, } +def _search_query(value: object) -> tuple[str, ...]: + """Normalize one bounded literal query without accepting search operators.""" + + if not isinstance(value, str) or not 1 <= len(value) <= MAX_SEARCH_QUERY_CHARS: + raise EvidenceMCPError( + f"query must contain between 1 and {MAX_SEARCH_QUERY_CHARS} characters" + ) + if any( + character in _SEARCH_OPERATOR_CHARACTERS or unicodedata.category(character) in {"Cc", "Cf"} + for character in value + ): + raise EvidenceMCPError("query must be literal text without operators or controls") + normalized = unicodedata.normalize("NFKC", value).casefold().strip() + if len(normalized) > MAX_SEARCH_QUERY_CHARS: + raise EvidenceMCPError("normalized query exceeds the character limit") + tokens = tuple(normalized.split()) + if not tokens or len(tokens) > MAX_SEARCH_QUERY_TOKENS: + raise EvidenceMCPError( + f"query must contain between 1 and {MAX_SEARCH_QUERY_TOKENS} literal tokens" + ) + if any(token in {"and", "or", "not"} for token in tokens): + raise EvidenceMCPError("query must be literal text without boolean operators") + return tokens + + +def _search_scalars(value: object, allowed_keys: frozenset[str], *, key: str = "") -> list[str]: + """Extract only per-kind allowlisted scalar leaves from one admitted value.""" + + if isinstance(value, Mapping): + result: list[str] = [] + for child_key, child in value.items(): + if isinstance(child_key, str): + result.extend(_search_scalars(child, allowed_keys, key=child_key)) + return result + if isinstance(value, (list, tuple)): + if key not in allowed_keys: + return [] + return [ + str(item) + for item in value + if isinstance(item, (str, int)) and not isinstance(item, bool) + ] + if key in allowed_keys and isinstance(value, (str, int)) and not isinstance(value, bool): + return [str(value)] + return [] + + +def _normalized_search_fields(record: EvidenceRecord | EvidenceDelta) -> tuple[str, ...]: + """Return normalized searchable fields without widening the response projection.""" + + if isinstance(record, EvidenceDelta): + values = [record.kind, record.component, record.identity] + allowed = _SEARCH_VALUE_KEYS.get(record.kind.split(".", 1)[0], frozenset({"identity"})) + values.extend(_search_scalars(record.before, allowed)) + values.extend(_search_scalars(record.after, allowed)) + else: + values = [record.kind, record.component, record.source_path] + allowed = _SEARCH_VALUE_KEYS.get(record.kind.split(".", 1)[0], frozenset({"identity"})) + values.extend(_search_scalars(record.value, allowed)) + return tuple(unicodedata.normalize("NFKC", value).casefold() for value in values) + + +def _search_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[str, object]: + """Search admitted metadata and allowlisted scalars without returning matched values.""" + + tokens = _search_query(arguments.get("query")) + kind = _optional_filter(arguments, "kind") + component = _optional_filter(arguments, "component") + ref = _optional_filter(arguments, "ref") + if ref not in {None, "base", "head", "policy", "shared"}: + raise EvidenceMCPError("ref must be base, head, policy, or shared") + max_results = arguments.get("max_results", DEFAULT_SEARCH_RESULTS) + if isinstance(max_results, bool) or not isinstance(max_results, int): + raise EvidenceMCPError("max_results must be an integer") + if not 1 <= max_results <= MAX_SEARCH_RESULTS: + raise EvidenceMCPError(f"max_results must be between 1 and {MAX_SEARCH_RESULTS}") + candidates: tuple[EvidenceRecord | EvidenceDelta, ...] = (*store.records, *store.safe_deltas) + matches: list[dict[str, object]] = [] + total_matches = 0 + for record in candidates: + record_kind = record.kind + record_ref = None if isinstance(record, EvidenceDelta) else record.ref.value + if ( + (kind is not None and record_kind != kind) + or (component is not None and record.component != component) + or (ref is not None and record_ref != ref) + ): + continue + fields = _normalized_search_fields(record) + if not all(any(token in field for field in fields) for token in tokens): + continue + total_matches += 1 + if len(matches) >= max_results: + continue + item: dict[str, object] = { + "id": record.id, + "kind": ( + "repository.evidence_delta" if isinstance(record, EvidenceDelta) else record.kind + ), + "component": record.component, + } + if isinstance(record, EvidenceDelta): + item["delta_kind"] = record.kind + else: + item.update({"ref": record.ref.value, "source_path": record.source_path}) + matches.append(item) + return { + "schema_version": "ocr.evidence-search/v1", + "matches": matches, + "returned": len(matches), + "total_matches": total_matches, + "truncated": total_matches > len(matches), + } + + +def _coverage_query(store: EvidenceStore, arguments: dict[str, object]) -> dict[str, object]: + """Return whether one exact evidence scope can support a negative conclusion.""" + + kind = _optional_filter(arguments, "kind") + ref = _optional_filter(arguments, "ref") + component = _optional_filter(arguments, "component") + source_path = _optional_filter(arguments, "path") + if kind is None: + raise EvidenceMCPError("kind is required") + if ref not in {"base", "head"}: + raise EvidenceMCPError("ref must be base or head") + matching_records = [ + record + for record in store.records + if record.kind == kind + and record.ref.value == ref + and (component is None or record.component == component) + and (source_path is None or record.source_path == source_path) + ] + applicable = [ + record + for record in store.coverage + if record.domain == kind + and record.ref.value == ref + and (component is None or record.component == component) + and (source_path is None or record.scope == source_path) + ] + state_counts: dict[str, int] = {} + for record in applicable: + state_counts[record.state.value] = state_counts.get(record.state.value, 0) + 1 + complete = bool(applicable) and all(record.state.value == "complete" for record in applicable) + return { + "schema_version": "ocr.evidence-coverage-query/v1", + "state": "complete" if complete else "unknown", + "matches": len(matching_records), + "coverage_records": len(applicable), + "coverage_states": dict(sorted(state_counts.items())), + "truncated": False, + "absence_authoritative": complete and not matching_records, + } + + def call_tool(store: EvidenceStore, arguments: object) -> dict[str, object]: """Execute one closed, read-only evidence action.""" @@ -306,6 +489,27 @@ def call_tool(store: EvidenceStore, arguments: object) -> dict[str, object]: return _text_result(payload) +def call_named_tool(store: EvidenceStore, name: str, arguments: object) -> dict[str, object]: + """Dispatch one fixed built-in evidence tool through its closed contract.""" + + if name == TOOL_NAME: + return call_tool(store, arguments) + if not isinstance(arguments, dict): + raise EvidenceMCPError("tool arguments must be an object") + typed = cast(dict[str, object], arguments) + if name == SEARCH_TOOL_NAME: + unknown = set(typed) - {"query", "kind", "component", "ref", "max_results"} + if unknown: + raise EvidenceMCPError(f"unsupported tool argument: {sorted(unknown)[0]}") + return _text_result(_search_records(store, typed)) + if name == COVERAGE_TOOL_NAME: + unknown = set(typed) - {"kind", "ref", "component", "path"} + if unknown: + raise EvidenceMCPError(f"unsupported tool argument: {sorted(unknown)[0]}") + return _text_result(_coverage_query(store, typed)) + raise EvidenceMCPError("unknown built-in evidence tool") + + def _tool_definition() -> dict[str, object]: """Return the versioned public MCP tool declaration.""" @@ -378,6 +582,82 @@ def _tool_definition() -> dict[str, object]: } +def _search_tool_definition() -> dict[str, object]: + """Declare the literal, bounded evidence search tool.""" + + return { + "name": SEARCH_TOOL_NAME, + "description": ( + "Locate unknown evidence records by bounded literal text after using the summary. " + "Search covers admitted paths, identities, and per-kind allowlisted scalar fields; " + "results contain stable IDs but never matched values. Use the primary evidence tool " + "with action=get for a selected ID." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["query"], + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SEARCH_QUERY_CHARS, + "description": "One to eight literal NFKC/case-insensitive tokens; no operators.", + }, + "kind": {"type": "string", "maxLength": 256}, + "component": {"type": "string", "maxLength": 256}, + "ref": {"type": "string", "enum": ["base", "head", "policy", "shared"]}, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": MAX_SEARCH_RESULTS, + }, + }, + }, + "annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False}, + } + + +def _coverage_tool_definition() -> dict[str, object]: + """Declare the exact scoped absence-proof tool.""" + + return { + "name": COVERAGE_TOOL_NAME, + "description": ( + "Check exact scoped evidence completeness before making a negative claim. " + "absence_authoritative is true only for applicable complete coverage with zero " + "matching records and no truncation; every missing, partial, runtime-dependent, " + "or unavailable scope returns unknown." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["kind", "ref"], + "properties": { + "kind": { + "type": "string", + "maxLength": 256, + "description": "Exact evidence kind and coverage domain.", + }, + "ref": {"type": "string", "enum": ["base", "head"]}, + "component": {"type": "string", "maxLength": 256}, + "path": { + "type": "string", + "maxLength": 256, + "description": "Optional exact record source path and coverage scope.", + }, + }, + }, + "annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False}, + } + + +def evidence_tool_definitions() -> list[dict[str, object]]: + """Return the fixed built-in evidence tools in stable routing order.""" + + return [_tool_definition(), _search_tool_definition(), _coverage_tool_definition()] + + def _success(request_id: object, result: object) -> dict[str, object]: """Create a JSON-RPC success response.""" @@ -435,7 +715,7 @@ def handle_request( if method == "ping": return _success(request_id, {}) if method == "tools/list": - tools = [_tool_definition()] + tools = evidence_tool_definitions() if context_store is not None: tools.extend(tool_definitions()) return _success(request_id, {"tools": tools}) @@ -443,9 +723,9 @@ def handle_request( if not isinstance(params, dict): return _error(request_id, -32602, "Invalid tool call") name = params.get("name") - if name == TOOL_NAME: + if isinstance(name, str) and name in {TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME}: try: - result = call_tool(store, params.get("arguments", {})) + result = call_named_tool(store, str(name), params.get("arguments", {})) except EvidenceMCPError as exc: return _success( request_id, @@ -453,7 +733,10 @@ def handle_request( ) if action_receipt_path is not None: arguments = params.get("arguments", {}) - action = arguments.get("action") if isinstance(arguments, dict) else None + if name == TOOL_NAME: + action = arguments.get("action") if isinstance(arguments, dict) else None + else: + action = "search" if name == SEARCH_TOOL_NAME else "coverage" try: record_action(action_receipt_path, action) except (OSError, ValueError): diff --git a/src/ocr_toolkit/evidence/project.py b/src/ocr_toolkit/evidence/project.py index 98412ab..3ad3787 100644 --- a/src/ocr_toolkit/evidence/project.py +++ b/src/ocr_toolkit/evidence/project.py @@ -145,16 +145,22 @@ def render_bootstrap( if capabilities: for capability in capabilities: marker = " (built-in)" if capability.builtin else "" + if capability.builtin and capability.server == "ocr_toolkit_evidence": + lines.append(f"- {inline_code(capability.server)}{marker}: fixed read-only tools") + continue tool_names = ", ".join(inline_code(tool) for tool in capability.tools) lines.append( f"- {inline_code(capability.server)}{marker}: " f"{tool_names or 'all allowlisted server tools'}" ) else: - lines.append("- `ocr_toolkit_evidence` (built-in): `ocr_toolkit_evidence`") + lines.append( + "- `ocr_toolkit_evidence` (built-in): `ocr_toolkit_evidence`, " + "`ocr_toolkit_evidence_search`, `ocr_toolkit_evidence_coverage`" + ) lines.append( - "Use `action=summary`, `action=list`, then `action=get`; list " - "`kind=repository.evidence_delta` with optional `delta_kind` for changes." + "Use `action=summary` once; `action=list` for known facts; literal search for unknown " + "locations; `action=get` for selected IDs; coverage before absence; stop when sufficient." ) if any("context_list" in capability.tools for capability in capabilities): lines.extend( diff --git a/src/ocr_toolkit/mcp_config.py b/src/ocr_toolkit/mcp_config.py index 9e86486..0ac8d29 100644 --- a/src/ocr_toolkit/mcp_config.py +++ b/src/ocr_toolkit/mcp_config.py @@ -14,7 +14,7 @@ from ocr_toolkit.config_writer import OCRConfigError, read_ocr_config, update_ocr_config from ocr_toolkit.context.mcp import GET_TOOL as CONTEXT_GET_TOOL from ocr_toolkit.context.mcp import LIST_TOOL as CONTEXT_LIST_TOOL -from ocr_toolkit.evidence.mcp import TOOL_NAME +from ocr_toolkit.evidence.mcp import COVERAGE_TOOL_NAME, SEARCH_TOOL_NAME, TOOL_NAME MAX_MCP_CONFIG_BYTES = 64_000 MAX_MCP_SERVERS = 16 @@ -379,8 +379,10 @@ def parse_mcp_servers(raw: str | None = None, *, profile: str = "local") -> list tools = _tool_names(server.get("tools", []), f"servers.{name}.tools") if not tools: raise MCPConfigError(f"servers.{name}.tools must explicitly allow at least one tool") - if TOOL_NAME in tools: - raise MCPConfigError(f"MCP tool name {TOOL_NAME!r} is reserved by the toolkit") + reserved_tools = {TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME} + conflict = next((tool for tool in tools if tool in reserved_tools), None) + if conflict is not None: + raise MCPConfigError(f"MCP tool name {conflict!r} is reserved by the toolkit") setup = server.get("setup", "") if not isinstance(setup, str): raise MCPConfigError(f"servers.{name}.setup must be a string") @@ -583,7 +585,7 @@ def compose_mcp_servers( "the running Python executable must be absolute for built-in MCP launch" ) builtin_args = ["-I", "-m", "ocr_toolkit.evidence"] - builtin_tools = [TOOL_NAME] + builtin_tools = [TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME] if context is not None: if not os.path.isabs(context.store_path): raise MCPConfigError("built-in context store path must be absolute") @@ -700,7 +702,7 @@ def configure_mcp_servers() -> int: f"OCR MCP server configured: {server.name} type={server.transport} " f"{detail} tools={len(server.tools)}" ) - print(f"OCR MCP server configured: {BUILTIN_EVIDENCE_SERVER} type=stdio args=3 env=0 tools=1") + print(f"OCR MCP server configured: {BUILTIN_EVIDENCE_SERVER} type=stdio args=3 env=0 tools=3") return 0 diff --git a/src/ocr_toolkit/ocr_result.py b/src/ocr_toolkit/ocr_result.py index d8a14e7..545fe9b 100644 --- a/src/ocr_toolkit/ocr_result.py +++ b/src/ocr_toolkit/ocr_result.py @@ -19,7 +19,7 @@ DEFAULT_MAX_RESULT_BYTES = 2_000_000 MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000 TOOLKIT_RESULT_KEY = "_ocr_toolkit" -TOOLKIT_RESULT_SCHEMA_VERSION = 5 +TOOLKIT_RESULT_SCHEMA_VERSION = 6 SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({TOOLKIT_RESULT_SCHEMA_VERSION}) TOOLKIT_ADVISORY_KEY = "_ocr_toolkit_advisory" TOOLKIT_ADVISORY_SCHEMA_VERSION = "ocr.toolkit-advisory/v1" @@ -46,6 +46,8 @@ "file_read", "file_read_diff", "ocr_toolkit_evidence", + "ocr_toolkit_evidence_coverage", + "ocr_toolkit_evidence_search", "task_done", } ) diff --git a/src/ocr_toolkit/posting/approval.py b/src/ocr_toolkit/posting/approval.py index 0f2b602..b798905 100644 --- a/src/ocr_toolkit/posting/approval.py +++ b/src/ocr_toolkit/posting/approval.py @@ -6,6 +6,8 @@ from enum import Enum from typing import Any +from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS +from ocr_toolkit.evidence.mcp import COVERAGE_TOOL_NAME, SEARCH_TOOL_NAME, TOOL_NAME from ocr_toolkit.ocr_result import ( MAX_TOOLKIT_MCP_TOOL_NAME_CHARS, MAX_TOOLKIT_MCP_TOOLS_PER_SERVER, @@ -160,7 +162,7 @@ def _sha256(value: Any) -> bool: def publication_dlp_state(value: Any) -> str | None: - """Validate the exact v5 publication-policy receipt.""" + """Validate the exact v6 publication-policy receipt.""" if value == {"state": "passed"}: return "passed" @@ -258,7 +260,7 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: invalid = INVALID_APPROVAL_RECEIPT_REASON if not isinstance(toolkit_metadata, dict): return invalid - if toolkit_metadata.get("schema_version") != 5 or set(toolkit_metadata) != { + if toolkit_metadata.get("schema_version") != 6 or set(toolkit_metadata) != { "schema_version", "review", "context", @@ -363,7 +365,8 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: servers: set[str] = set() tool_owners: set[str] = set() external = False - builtin_server = "ocr_toolkit_evidence" + builtin_server = TOOL_NAME + builtin_tools = [TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME] for capability in capabilities: if not isinstance(capability, dict) or set(capability) != {"server", "transport", "tools"}: return invalid @@ -388,9 +391,9 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: server == builtin_server and tools != ( - [builtin_server, "context_list", "context_get"] + [*builtin_tools, "context_list", "context_get"] if mode == "enriched" - else [builtin_server] + else builtin_tools ) ) ): @@ -452,7 +455,7 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: - """Return whether metadata is an exact receipt v5, including valid blockers.""" + """Return whether metadata is an exact receipt v6, including valid blockers.""" return automatic_approval_metadata_reason(toolkit_metadata) != INVALID_APPROVAL_RECEIPT_REASON @@ -500,10 +503,11 @@ def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: """Validate verified counts or an explicit unavailable attribution state.""" if value == {"state": "unavailable"}: - return True - if not isinstance(value, dict) or set(value) != {"state", "summary", "list", "get"}: + return evidence_calls == 0 + expected = {"state", *EVIDENCE_ACTIONS} + if not isinstance(value, dict) or set(value) != expected: return False - counts = [value.get(action) for action in ("summary", "list", "get")] + counts = [value.get(action) for action in EVIDENCE_ACTIONS] return bool( value.get("state") == "verified" and all( @@ -513,4 +517,5 @@ def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: for count in counts ) and sum(counts) == evidence_calls + and (evidence_calls == 0 or value.get("summary", 0) >= 1) ) diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index bec5490..cdf5b22 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -17,6 +17,7 @@ inline_code as _inline_code, ) from ocr_toolkit.common.redaction import redact_sensitive +from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS from ocr_toolkit.ocr_result import ( MAX_TOOLKIT_MCP_USAGE_COUNT, MAX_TOOLKIT_MCP_USAGE_SERVERS, @@ -515,10 +516,8 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: lines = [f"- verified MCP calls: {len(used)} server(s) ({details})"] evidence = toolkit_metadata.get("evidence") actions = evidence.get("actions") if isinstance(evidence, dict) else None - if actions == {"state": "unavailable"}: - lines.append("- built-in evidence actions: unavailable") - elif isinstance(actions, dict) and set(actions) == {"state", "summary", "list", "get"}: - action_counts = [actions[action] for action in ("summary", "list", "get")] + if isinstance(actions, dict) and set(actions) == {"state", *EVIDENCE_ACTIONS}: + action_counts = [actions[action] for action in EVIDENCE_ACTIONS] evidence_calls = evidence.get("calls") if isinstance(evidence, dict) else None if not ( actions.get("state") == "verified" @@ -534,17 +533,19 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: and sum(action_counts) == evidence_calls ): return "\n".join(lines) - lines.append( - "- built-in evidence actions: " - + ", ".join(f"{action}: {actions[action]}" for action in ("summary", "list", "get")) - ) + positive = [action for action in EVIDENCE_ACTIONS if actions[action] > 0] + if positive: + lines.append( + "- built-in evidence actions: " + + ", ".join(f"{action}: {actions[action]}" for action in positive) + ) return "\n".join(lines) def publication_dlp_signal( publication: Any, *, carried_forward_comments: int = 0 ) -> dict[str, Any] | None: - """Return one low-cardinality signal from an exact v5 DLP receipt.""" + """Return one low-cardinality signal from an exact v6 DLP receipt.""" state = publication_dlp_state(publication) if ( diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index a75a3a8..5ef3362 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -19,6 +19,7 @@ from ocr_toolkit.ocr_result import ( TOOLKIT_ADVISORY_KEY, TOOLKIT_RESULT_KEY, + TOOLKIT_RESULT_SCHEMA_VERSION, OcrResultMalformed, OcrResultMissing, OcrResultTooLarge, @@ -159,7 +160,10 @@ def mr_head_sha() -> str: def approval_receipt_identity(toolkit_metadata: Any) -> tuple[str, int | None]: """Return only validated-by-policy receipt identities for provider readback.""" - if not isinstance(toolkit_metadata, dict) or toolkit_metadata.get("schema_version") != 5: + if ( + not isinstance(toolkit_metadata, dict) + or toolkit_metadata.get("schema_version") != TOOLKIT_RESULT_SCHEMA_VERSION + ): return "", None review = toolkit_metadata.get("review") if not isinstance(review, dict): @@ -618,14 +622,17 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: ) if toolkit_metadata is None: publication_state = "direct" - elif isinstance(toolkit_metadata, dict) and toolkit_metadata.get("schema_version") == 5: + elif ( + isinstance(toolkit_metadata, dict) + and toolkit_metadata.get("schema_version") == TOOLKIT_RESULT_SCHEMA_VERSION + ): publication_state = publication_dlp_state(publication) else: publication_state = None if publication_state is None: return invalid_ocr_schema_exit( config, - "receipt v5 publication state is invalid", + "receipt v6 publication state is invalid", intro="OCR result publication policy state could not be validated.", title="**Open Code Review publication policy error**", ) @@ -639,7 +646,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if not toolkit_receipt_is_valid(toolkit_metadata): return invalid_ocr_schema_exit( config, - "OCR toolkit advisory is not bound to a valid receipt v5", + "OCR toolkit advisory is not bound to a valid receipt v6", ) ocr_core_advisory_summary = format_ocr_core_advisory(advisory) diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index 7381d96..bebb4f9 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -59,7 +59,13 @@ ) from ocr_toolkit.evidence.collect import collect_repository_evidence from ocr_toolkit.evidence.invocation import collect_invocation_evidence -from ocr_toolkit.evidence.mcp import TOOL_NAME, call_tool, evidence_summary +from ocr_toolkit.evidence.mcp import ( + COVERAGE_TOOL_NAME, + SEARCH_TOOL_NAME, + TOOL_NAME, + call_tool, + evidence_summary, +) from ocr_toolkit.evidence.project import render_bootstrap from ocr_toolkit.evidence.repository import ( GitRepositoryReader, @@ -380,20 +386,34 @@ def _review_receipt( or total_calls < known_usage_total ): raise ReviewRunnerError("OCR result has inconsistent aggregate MCP usage") - evidence_calls = by_tool.get(TOOL_NAME, 0) if isinstance(by_tool, dict) else 0 + evidence_by_tool = { + name: by_tool.get(name, 0) if isinstance(by_tool, dict) else 0 + for name in (TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME) + } + evidence_calls = sum(evidence_by_tool.values()) evidence_used = isinstance(evidence_calls, int) and evidence_calls > 0 - if outcome.requires_evidence_mcp and not evidence_used: + if outcome.requires_evidence_mcp and not evidence_by_tool[TOOL_NAME]: raise ReviewRunnerError(f"OCR review did not call the mandatory {TOOL_NAME} tool") - action_attribution: dict[str, object] = {"state": "unavailable"} - if ( + action_attribution: dict[str, object] + if evidence_calls == 0 and evidence_action_counts is None: + action_attribution = { + "state": "verified", + **dict.fromkeys(EVIDENCE_ACTIONS, 0), + } + elif ( evidence_action_counts is not None and set(evidence_action_counts) == set(EVIDENCE_ACTIONS) - and sum(evidence_action_counts.values()) == evidence_calls + and sum(evidence_action_counts[action] for action in ("summary", "list", "get")) + == evidence_by_tool[TOOL_NAME] + and evidence_action_counts["search"] == evidence_by_tool[SEARCH_TOOL_NAME] + and evidence_action_counts["coverage"] == evidence_by_tool[COVERAGE_TOOL_NAME] ): action_attribution = { "state": "verified", **{action: evidence_action_counts[action] for action in EVIDENCE_ACTIONS}, } + else: + action_attribution = {"state": "unavailable"} capabilities = [ { "server": capability.server, @@ -894,7 +914,7 @@ def _publication_projection( forbidden: tuple[str, ...], allowed_tools: frozenset[str], ) -> tuple[dict[str, object], dict[str, object], bool]: - """Return a DLP-safe result plus one exact v5 publication state.""" + """Return a DLP-safe result plus one exact v6 publication state.""" budgets = TextBudgets(max_chars=2_000_000, max_bytes=8_000_000, max_lines=100_000) matcher = ForbiddenMatcher.compile(forbidden) @@ -1589,7 +1609,7 @@ def _bounded_combined_records( def _remediation_mutable_admitted(records: Sequence[ContextRecord]) -> bool: - """Report only admitted remediation as the receipt-v5 comment-only condition.""" + """Report only admitted remediation as the receipt-v6 comment-only condition.""" return any( record.mutable and record.resource_class == "remediation_thread" for record in records diff --git a/tests/installed_policy_e2e.py b/tests/installed_policy_e2e.py index e2eb644..8f87e74 100644 --- a/tests/installed_policy_e2e.py +++ b/tests/installed_policy_e2e.py @@ -206,7 +206,11 @@ def main() -> int: assert "title=admitted" in bootstrap builtin = composition.payload[mcp_config.BUILTIN_EVIDENCE_SERVER] - assert builtin["tools"] == ["ocr_toolkit_evidence"] + assert builtin["tools"] == [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ] assert os.path.isabs(str(builtin["command"])) assert list(builtin["args"]) == ["-I", "-m", "ocr_toolkit.evidence"] process = subprocess.Popen( @@ -242,12 +246,13 @@ def main() -> int: process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, )["result"]["tools"] - assert len(tools) == 1 and tools[0]["name"] == "ocr_toolkit_evidence" - assert tools[0]["annotations"] == { - "readOnlyHint": True, - "destructiveHint": False, - "openWorldHint": False, - } + assert [tool["name"] for tool in tools] == builtin["tools"] + for tool in tools: + assert tool["annotations"] == { + "readOnlyHint": True, + "destructiveHint": False, + "openWorldHint": False, + } request_id = 3 def call(arguments: dict[str, Any]) -> dict[str, Any]: @@ -278,6 +283,23 @@ def call(arguments: dict[str, Any]) -> dict[str, Any]: return _tool_payload(response) summary = call({"action": "summary"}) + + def call_named(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Call one additional fixed installed evidence tool.""" + + nonlocal request_id + response = _rpc( + process, + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + ) + request_id += 1 + return _tool_payload(response) + unknown_response = _rpc( process, { @@ -327,6 +349,25 @@ def call(arguments: dict[str, Any]) -> dict[str, Any]: assert "current decision" in decision["value"]["fact"]["rationale"] assert "source-only decision" not in decision["value"]["fact"]["rationale"] assert call({"action": "get", "id": decision["id"]})["record"] == decision + searched = call_named( + "ocr_toolkit_evidence_search", + {"query": "current-policy-choice", "kind": "repository.accepted_decision"}, + ) + assert searched["matches"] == [ + { + "id": decision["id"], + "kind": "repository.accepted_decision", + "component": "repository", + "ref": "policy", + "source_path": ".opencodereview/accepted-decisions.md", + } + ] + unknown_coverage = call_named( + "ocr_toolkit_evidence_coverage", + {"kind": "repository.accepted_decision", "ref": "head"}, + ) + assert unknown_coverage["state"] == "unknown" + assert unknown_coverage["absence_authoritative"] is False assert ( call({"action": "list", "kind": "repository.accepted_decision", "ref": "head"})["records"] == [] diff --git a/tests/test_context_mcp.py b/tests/test_context_mcp.py index b4bf2c8..2c6ed65 100644 --- a/tests/test_context_mcp.py +++ b/tests/test_context_mcp.py @@ -169,9 +169,15 @@ def test_enriched_tools_are_declared_only_with_bound_context(tmp_path: Path) -> assert ordinary is not None and enriched is not None assert malformed_name is not None and malformed_name["error"]["code"] == -32602 # type: ignore[index] - assert [tool["name"] for tool in ordinary["result"]["tools"]] == [TOOL_NAME] # type: ignore[index] + assert [tool["name"] for tool in ordinary["result"]["tools"]] == [ # type: ignore[index] + TOOL_NAME, + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ] assert [tool["name"] for tool in enriched["result"]["tools"]] == [ # type: ignore[index] TOOL_NAME, + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", "context_list", "context_get", ] @@ -186,8 +192,20 @@ def test_enriched_tools_are_declared_only_with_bound_context(tmp_path: Path) -> ), ) builtin = composition.payload[mcp_config.BUILTIN_EVIDENCE_SERVER] - assert builtin["tools"] == [TOOL_NAME, "context_list", "context_get"] - assert composition.capabilities[0].tools == (TOOL_NAME, "context_list", "context_get") + assert builtin["tools"] == [ + TOOL_NAME, + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + "context_list", + "context_get", + ] + assert composition.capabilities[0].tools == ( + TOOL_NAME, + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + "context_list", + "context_get", + ) def test_real_stdio_mcp_serves_evidence_and_committed_context_in_one_process( @@ -245,6 +263,8 @@ def test_real_stdio_mcp_serves_evidence_and_committed_context_in_one_process( assert completed.returncode == 0, completed.stderr assert [tool["name"] for tool in responses[0]["result"]["tools"]] == [ TOOL_NAME, + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", "context_list", "context_get", ] diff --git a/tests/test_evidence_actions.py b/tests/test_evidence_actions.py index 2f7c890..adf7faa 100644 --- a/tests/test_evidence_actions.py +++ b/tests/test_evidence_actions.py @@ -22,10 +22,16 @@ def _record_repeated_actions(path: str, action: str, count: int) -> None: def test_action_receipt_is_closed_atomic_private_and_count_only(tmp_path: Path) -> None: path = tmp_path / "actions.json" - for action in ("summary", "list", "get", "list"): + for action in ("summary", "list", "get", "search", "coverage", "list"): record_action(path, action) - assert read_action_receipt(path) == {"summary": 1, "list": 2, "get": 1} + assert read_action_receipt(path) == { + "summary": 1, + "list": 2, + "get": 1, + "search": 1, + "coverage": 1, + } assert stat.S_IMODE(path.stat().st_mode) == 0o600 raw = path.read_text(encoding="utf-8") assert set(json.loads(raw)) == {"schema_version", "actions"} @@ -36,11 +42,23 @@ def test_action_receipt_is_closed_atomic_private_and_count_only(tmp_path: Path) "content", [ "not-json", - json.dumps({"schema_version": "ocr.evidence-action-receipt/v1", "actions": {}}), json.dumps( { "schema_version": "ocr.evidence-action-receipt/v1", - "actions": {"summary": 1, "list": True, "get": 0}, + "actions": {"summary": 1, "list": 0, "get": 0}, + } + ), + json.dumps({"schema_version": "ocr.evidence-action-receipt/v2", "actions": {}}), + json.dumps( + { + "schema_version": "ocr.evidence-action-receipt/v2", + "actions": { + "summary": 1, + "list": True, + "get": 0, + "search": 0, + "coverage": 0, + }, } ), ], @@ -59,20 +77,30 @@ def test_hostile_action_receipt_is_unavailable_and_never_overwritten( def test_unknown_action_is_rejected_without_creating_receipt(tmp_path: Path) -> None: path = tmp_path / "actions.json" with pytest.raises(ValueError, match="closed"): - record_action(path, "search") + record_action(path, "delete") assert not path.exists() def test_action_receipt_serializes_concurrent_completed_calls(tmp_path: Path) -> None: path = tmp_path / "actions.json" - work = [(str(path), action, 20) for action in ("summary", "list", "get") for _ in range(4)] + work = [ + (str(path), action, 20) + for action in ("summary", "list", "get", "search", "coverage") + for _ in range(4) + ] with ProcessPoolExecutor(max_workers=12) as executor: futures = [executor.submit(_record_repeated_actions, *item) for item in work] for future in futures: future.result() - assert read_action_receipt(path) == {"summary": 80, "list": 80, "get": 80} + assert read_action_receipt(path) == { + "summary": 80, + "list": 80, + "get": 80, + "search": 80, + "coverage": 80, + } lock_path = path.with_name(f".{path.name}.lock") assert stat.S_IMODE(lock_path.stat().st_mode) == 0o600 @@ -101,8 +129,8 @@ def test_action_receipt_rejects_unsafe_existing_file(tmp_path: Path, unsafe: str target = tmp_path / "outside" payload = json.dumps( { - "schema_version": "ocr.evidence-action-receipt/v1", - "actions": {"summary": 1, "list": 0, "get": 0}, + "schema_version": "ocr.evidence-action-receipt/v2", + "actions": {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, } ) target.write_text(payload, encoding="utf-8") diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py index 0c4ef4f..6e10f3d 100644 --- a/tests/test_evidence_mcp.py +++ b/tests/test_evidence_mcp.py @@ -22,8 +22,10 @@ ) from ocr_toolkit.evidence.actions import read_action_receipt from ocr_toolkit.evidence.mcp import ( + COVERAGE_TOOL_NAME, MAX_REQUEST_BYTES, PROTOCOL_VERSION, + SEARCH_TOOL_NAME, SUPPORTED_PROTOCOL_VERSIONS, TOOL_NAME, call_tool, @@ -266,6 +268,192 @@ def test_coverage_is_summarized_filtered_and_addressable() -> None: assert fetched["record"] == coverage.to_dict() +def test_literal_search_returns_only_stable_metadata_and_normalizes_unicode() -> None: + """Locate admitted facts without echoing the query or matched scalar values.""" + + store = _store(0) + assert store.add( + EvidenceRecord( + kind="dependency.declared", + value={"identity": "requirements.txt:CaféPackage", "name": "CaféPackage"}, + source_path="services/café/requirements.txt", + ref=RefRole.HEAD, + commit_sha=SHA, + component="python-api", + provenance="synthetic parser", + trust=TrustClass.SOURCE_REPOSITORY, + ) + ) + + result = handle_request( + store, + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": SEARCH_TOOL_NAME, + "arguments": {"query": "CAFE\u0301PACKAGE", "ref": "head"}, + }, + }, + ) + assert result is not None + payload = _payload(result["result"]) + + assert payload == { + "schema_version": "ocr.evidence-search/v1", + "matches": [ + { + "id": store.records[0].id, + "kind": "dependency.declared", + "component": "python-api", + "ref": "head", + "source_path": "services/café/requirements.txt", + } + ], + "returned": 1, + "total_matches": 1, + "truncated": False, + } + serialized = json.dumps(payload, ensure_ascii=False) + assert "CaféPackage" not in serialized and "CAFE" not in serialized + + +@pytest.mark.parametrize( + "query", + [ + "", + "name:*", + "a OR b", + "one two three four five six seven eight nine", + "safe\u202eunsafe", + "\N{VULGAR FRACTION ONE QUARTER}" * 128, + ], +) +def test_literal_search_rejects_empty_operator_broad_and_format_queries(query: str) -> None: + """Keep search literal, bounded, and independent from regex or query languages.""" + + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": SEARCH_TOOL_NAME, "arguments": {"query": query}}, + }, + ) + assert result is not None and result["result"]["isError"] is True + + +def test_search_truncation_is_explicit_and_deterministic() -> None: + """Bound result projection without turning omitted matches into absence.""" + + result = handle_request( + _store(3), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": SEARCH_TOOL_NAME, + "arguments": {"query": "package", "max_results": 1}, + }, + }, + ) + assert result is not None + payload = _payload(result["result"]) + assert payload["returned"] == 1 + assert payload["total_matches"] == 3 + assert payload["truncated"] is True + + +def test_coverage_tool_proves_only_complete_exact_zero_match_scope() -> None: + """Distinguish authoritative absence from missing, partial, and positive evidence.""" + + store = _store(0) + assert store.add_coverage( + CoverageRecord( + component="python", + domain="dependency.declared", + scope="requirements.txt", + state=CoverageState.COMPLETE, + reasons=("bounded-source-complete",), + ref=RefRole.HEAD, + commit_sha=SHA, + ) + ) + arguments = { + "kind": "dependency.declared", + "ref": "head", + "component": "python", + "path": "requirements.txt", + } + absent = handle_request( + store, + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": COVERAGE_TOOL_NAME, "arguments": arguments}, + }, + ) + assert absent is not None + absent_payload = _payload(absent["result"]) + assert absent_payload == { + "schema_version": "ocr.evidence-coverage-query/v1", + "state": "complete", + "matches": 0, + "coverage_records": 1, + "coverage_states": {"complete": 1}, + "truncated": False, + "absence_authoritative": True, + } + + assert store.add( + EvidenceRecord( + kind="dependency.declared", + value={"identity": "requirements.txt:package", "name": "package"}, + source_path="requirements.txt", + ref=RefRole.HEAD, + commit_sha=SHA, + component="python", + provenance="synthetic parser", + trust=TrustClass.SOURCE_REPOSITORY, + ) + ) + present = handle_request( + store, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": COVERAGE_TOOL_NAME, "arguments": arguments}, + }, + ) + assert present is not None + present_payload = _payload(present["result"]) + assert present_payload["matches"] == 1 + assert present_payload["absence_authoritative"] is False + + unknown = handle_request( + store, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": COVERAGE_TOOL_NAME, + "arguments": {**arguments, "path": "other.txt"}, + }, + }, + ) + assert unknown is not None + unknown_payload = _payload(unknown["result"]) + assert unknown_payload["state"] == "unknown" + assert unknown_payload["coverage_records"] == 0 + assert unknown_payload["absence_authoritative"] is False + + @pytest.mark.parametrize( "arguments", [ @@ -395,7 +583,11 @@ def test_initialized_notification_and_post_handshake_operations() -> None: assert pinged["result"] == {} listed = handle_request(store, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) assert listed is not None - assert [tool["name"] for tool in listed["result"]["tools"]] == [TOOL_NAME] + assert [tool["name"] for tool in listed["result"]["tools"]] == [ + TOOL_NAME, + SEARCH_TOOL_NAME, + COVERAGE_TOOL_NAME, + ] called = handle_request( store, { @@ -435,7 +627,13 @@ def test_server_records_only_completed_model_time_evidence_actions(tmp_path: Pat assert successful and successful["result"].get("isError", False) is False assert failed and failed["result"].get("isError", False) is True - assert read_action_receipt(receipt) == {"summary": 1, "list": 0, "get": 0} + assert read_action_receipt(receipt) == { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + } def test_notifications_never_receive_json_rpc_responses() -> None: diff --git a/tests/test_gitlab_provider.py b/tests/test_gitlab_provider.py index d2fce8c..5054254 100644 --- a/tests/test_gitlab_provider.py +++ b/tests/test_gitlab_provider.py @@ -790,7 +790,7 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie assert _git(checkout, "status", "--short") == "" payload = json.loads(result.read_text(encoding="utf-8")) assert payload["_ocr_toolkit"] == { - "schema_version": 5, + "schema_version": 6, "review": {"source_sha": head, "policy_sha": policy, "mr_author_id": 41}, "context": { "mode": "metadata", @@ -808,7 +808,11 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie { "server": "ocr_toolkit_evidence", "transport": "builtin", - "tools": ["ocr_toolkit_evidence"], + "tools": [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ], } ], "usage": {"ocr_toolkit_evidence": 3}, @@ -822,6 +826,8 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie "summary": 1, "list": 2, "get": 0, + "search": 0, + "coverage": 0, }, }, "publication": {"state": "passed"}, diff --git a/tests/test_installed_policy_e2e.py b/tests/test_installed_policy_e2e.py index 42728e9..00513f4 100644 --- a/tests/test_installed_policy_e2e.py +++ b/tests/test_installed_policy_e2e.py @@ -183,7 +183,7 @@ def test_installed_wheel_and_sdist_expose_target_policy_through_real_mcp( ) receipt = json.loads(output) assert receipt["installed_version"] == ARTIFACT_VERSION - assert receipt["bootstrap_chars"] == 2_185 + assert receipt["bootstrap_chars"] == 2_244 assert receipt["bootstrap_truncated"] is False assert receipt["base"] != receipt["policy_sha"] != receipt["head"] assert receipt["merge_request_context"] == { diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index 06e8150..14028bb 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -293,14 +293,14 @@ def test_public_docs_describe_the_established_m5_boundary() -> None: "ocr.context-adapter-response/v1", "context_list", "context_get", - "receipt v5", + "receipt v6", "schema_version", "no store or receipt migration path", "semantic paraphrase", ): assert contract in bounded for document in (configuration, gitlab, operations, security): - assert "receipt v5" in document + assert "receipt v6" in document assert "review-context.md" in document assert "M5's foundation is established in v0.7.0" in strategy assert "M5 Bounded review-context enrichment
established" in roadmap diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index bf02aee..d7d132b 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -342,9 +342,9 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "`metadata`" in document assert "`enriched`" in document assert 'OCR_REVIEW_CONTEXT_MODE: "off"' in example - assert "receipt v5" in configuration - assert "Receipt v1-v4" in configuration - assert "Receipt v1-v4" in operations + assert "receipt v6" in configuration + assert "Receipt v1-v5" in configuration + assert "Receipt v1-v5" in operations assert "complete `metadata` context" in operations.lower() assert "Every configured direct external MCP" in configuration assert "required context degradation" in operations @@ -353,6 +353,35 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "sole stdio exception" in configuration +def test_builtin_search_coverage_and_receipt_v6_boundaries_are_public() -> None: + """Document efficient routing without exposing search or coverage arguments.""" + + configuration = CONFIGURATION.read_text(encoding="utf-8") + operations = OPERATIONS.read_text(encoding="utf-8") + security = (PROJECT_ROOT / "docs" / "security.md").read_text(encoding="utf-8") + gitlab = GITLAB_GUIDE.read_text(encoding="utf-8") + + for name in ( + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ): + assert name in configuration + assert name in gitlab + for phrase in ( + "1\u2013128 characters", + "at most eight literal tokens", + "absence_authoritative=true", + "Stop once the required evidence is sufficient", + "action receipt v2", + "Receipt v6", + ): + assert phrase in configuration + assert "DLP-admitted store" in security + assert "Zero action counters, queries, scopes, IDs" in operations + assert "arguments, queries, scopes, IDs, and results stay private" in gitlab + + def test_production_bot_modes_and_current_contract_are_public() -> None: gitlab = GITLAB_GUIDE.read_text(encoding="utf-8") bounded = (PROJECT_ROOT / "docs" / "review-context.md").read_text(encoding="utf-8") diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index 1174b81..40cc4af 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -42,7 +42,7 @@ def finding(category: Any = "style", severity: Any = "low") -> dict[str, Any]: return {"category": category, "severity": severity} -def receipt_v5( +def receipt_v6( *, context_state: str = "disabled", external: bool = False, author_id: int | None = 41 ) -> dict[str, Any]: """Return one closed synthetic review-time receipt.""" @@ -52,7 +52,11 @@ def receipt_v5( { "server": "ocr_toolkit_evidence", "transport": "builtin", - "tools": ["ocr_toolkit_evidence"], + "tools": [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ], } ] if external: @@ -60,7 +64,7 @@ def receipt_v5( {"server": "documentation", "transport": "remote", "tools": ["docs_read"]} ) return { - "schema_version": 5, + "schema_version": 6, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -85,7 +89,14 @@ def receipt_v5( "mandatory": True, "used": True, "calls": 1, - "actions": {"state": "unavailable"}, + "actions": { + "state": "verified", + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, }, "publication": {"state": "passed"}, "cleanup": {"result": "passed"}, @@ -108,14 +119,14 @@ def eligibility( comments or [], warnings or [], omitted, - receipt_v5(), + receipt_v6(), ) def enriched_receipt(*, mutable: bool = False, required_degraded: bool = False) -> dict[str, Any]: - """Return one v5 local-store-only enrichment receipt.""" + """Return one v6 local-store-only enrichment receipt.""" - receipt = receipt_v5() + receipt = receipt_v6() receipt["context"] = { "mode": "enriched", "state": "degraded" if required_degraded else "complete", @@ -133,6 +144,8 @@ def enriched_receipt(*, mutable: bool = False, required_degraded: bool = False) } receipt["mcp"]["capabilities"][0]["tools"] = [ "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", "context_list", "context_get", ] @@ -214,7 +227,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> [], [], 0, - receipt_v5(context_state="degraded", author_id=41), + receipt_v6(context_state="degraded", author_id=41), ) self.assertFalse(decision.eligible) @@ -222,7 +235,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> self.assertEqual(decision.result.reason, "the selected review context was degraded") def test_publication_dlp_filtered_receipt_is_valid_but_never_eligible(self) -> None: - receipt = receipt_v5() + receipt = receipt_v6() receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -265,7 +278,7 @@ def test_provider_private_fields_cannot_enter_approval_receipt(self) -> None: """Reject replay or request-control fields at the closed receipt boundary.""" for field in ("reasoning_content", "native_payload", "tool_choice"): - receipt = receipt_v5() + receipt = receipt_v6() receipt[field] = "private" with self.subTest(field=field): @@ -320,7 +333,7 @@ def test_filtered_receipt_rejects_outcomes_that_contradict_coverage(self) -> Non self.assertIsNone(approval.publication_dlp_state(candidate)) def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: - receipt = receipt_v5() + receipt = receipt_v6() receipt["publication"] = { "state": "private-sanitized", "reason_counts": { @@ -341,12 +354,14 @@ def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: self.assertTrue(decision.eligible) def test_evidence_action_attribution_does_not_change_approval_eligibility(self) -> None: - receipt = receipt_v5() + receipt = receipt_v6() receipt["evidence"]["actions"] = { "state": "verified", "summary": 1, "list": 0, "get": 0, + "search": 0, + "coverage": 0, } decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt @@ -367,10 +382,10 @@ def test_complete_metadata_and_external_mcp_have_independent_approval_effects(se [], [], 0, - receipt_v5(context_state="complete", author_id=41), + receipt_v6(context_state="complete", author_id=41), ) external = approval.evaluate_approval_policy( - settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v5(external=True) + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v6(external=True) ) self.assertTrue(complete_metadata.eligible) @@ -420,7 +435,7 @@ def test_optional_context_mutation_remains_visible_without_becoming_required_fai self.assertTrue(decision.eligible) def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: - metadata = receipt_v5(external=True) + metadata = receipt_v6(external=True) metadata["mcp"]["capabilities"][1]["tools"] = ["repo.search", "records/read"] decision = approval.evaluate_approval_policy( @@ -433,8 +448,8 @@ def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: "external MCP was configured for a comment-only review", ) - def test_every_pre_v5_receipt_is_rejected(self) -> None: - for version in range(1, 5): + def test_every_pre_v6_receipt_is_rejected(self) -> None: + for version in range(1, 6): with self.subTest(version=version): decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), @@ -451,15 +466,15 @@ def test_every_pre_v5_receipt_is_rejected(self) -> None: "the review-time approval receipt is missing or invalid", ) - def test_missing_or_malformed_v5_receipt_fails_closed(self) -> None: - cases: list[Any] = [None, {"schema_version": 5}] + def test_missing_or_malformed_v6_receipt_fails_closed(self) -> None: + cases: list[Any] = [None, {"schema_version": 6}] for mutate in ( lambda value: value["context"].update({"state": "complete"}), lambda value: value["review"].update({"source_sha": "invalid"}), lambda value: value["mcp"].update({"usage": {"unknown": 1}}), lambda value: value["evidence"].update({"used": False}), ): - candidate = receipt_v5() + candidate = receipt_v6() mutate(candidate) cases.append(candidate) for metadata in cases: @@ -480,7 +495,7 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: ("groups", [{"label": "core", "files": ["src/core.py"]}]), ("review_rounds", 2), ): - receipt = receipt_v5() + receipt = receipt_v6() receipt[field] = value decision = approval.evaluate_approval_policy( @@ -493,43 +508,43 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: "the review-time approval receipt is missing or invalid", ) - def test_impossible_v5_capability_and_evidence_states_fail_closed(self) -> None: + def test_impossible_v6_capability_and_evidence_states_fail_closed(self) -> None: cases: list[dict[str, Any]] = [] - builtin_remote = receipt_v5() + builtin_remote = receipt_v6() builtin_remote["mcp"]["capabilities"][0]["transport"] = "remote" cases.append(builtin_remote) - external_builtin = receipt_v5(external=True) + external_builtin = receipt_v6(external=True) external_builtin["mcp"]["capabilities"][1]["transport"] = "builtin" cases.append(external_builtin) - wrong_builtin_tool = receipt_v5() + wrong_builtin_tool = receipt_v6() wrong_builtin_tool["mcp"]["capabilities"][0]["tools"] = ["other_read"] cases.append(wrong_builtin_tool) - duplicate_tool = receipt_v5(external=True) + duplicate_tool = receipt_v6(external=True) duplicate_tool["mcp"]["capabilities"][1]["tools"] = ["ocr_toolkit_evidence"] cases.append(duplicate_tool) - too_many_tools = receipt_v5(external=True) + too_many_tools = receipt_v6(external=True) too_many_tools["mcp"]["capabilities"][1]["tools"] = [ f"tool_{index}" for index in range(129) ] cases.append(too_many_tools) - usage_mismatch = receipt_v5() + usage_mismatch = receipt_v6() usage_mismatch["mcp"]["usage"] = {} cases.append(usage_mismatch) - usage_overflow = receipt_v5() + usage_overflow = receipt_v6() usage_overflow["mcp"]["usage"] = {"ocr_toolkit_evidence": 1_000_000_001} cases.append(usage_overflow) - missing_author = receipt_v5(author_id=None) + missing_author = receipt_v6(author_id=None) cases.append(missing_author) - mandatory_mismatch = receipt_v5() + mandatory_mismatch = receipt_v6() mandatory_mismatch["evidence"] = {"mandatory": False, "used": True} cases.append(mandatory_mismatch) @@ -909,7 +924,7 @@ def tearDown(self) -> None: settings.post_mode.cache_clear() def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: - valid = receipt_v5(author_id=41) + valid = receipt_v6(author_id=41) self.assertEqual(workflow.approval_receipt_identity(valid), ("a" * 40, 41)) for mutate in ( @@ -917,7 +932,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: lambda value: value["review"].update({"mr_author_id": None}), lambda value: value["review"].update({"mr_author_id": True}), ): - candidate = receipt_v5(author_id=41) + candidate = receipt_v6(author_id=41) mutate(candidate) with self.subTest(candidate=candidate): self.assertEqual(workflow.approval_receipt_identity(candidate), ("", None)) @@ -925,7 +940,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: def test_valid_receipt_binds_advisory_without_changing_summary_or_approval_inputs(self) -> None: """Publish one closed advisory in Technical details with ordinary clean status.""" - receipt = receipt_v5(author_id=41) + receipt = receipt_v6(author_id=41) self.assertTrue(approval.toolkit_receipt_is_valid(receipt)) notes: list[str] = [] @@ -985,7 +1000,7 @@ def test_complete_filtered_review_keeps_coverage_and_activity_dimensions_separat ) -> None: """Render scenario B without inventing partial coverage or a failed item.""" - receipt = receipt_v5(author_id=41) + receipt = receipt_v6(author_id=41) receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -1070,7 +1085,7 @@ def capture_note( def test_private_sanitized_review_keeps_tool_and_token_activity_visible(self) -> None: """Keep independent numeric activity lines after private-only sanitization.""" - receipt = receipt_v5(author_id=41) + receipt = receipt_v6(author_id=41) receipt["publication"] = { "state": "private-sanitized", "reason_counts": { diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index c5d24fd..ac92846 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -67,13 +67,13 @@ def fake_api_request(*args: Any, **kwargs: Any) -> dict[str, int]: self.assertEqual(exit_code, 1) self.assertEqual(calls, []) - def test_invalid_v5_publication_state_never_reaches_normal_result_flow(self) -> None: + def test_invalid_v6_publication_state_never_reaches_normal_result_flow(self) -> None: notes: list[str] = [] result_data = { "status": "failed", "comments": [{"content": "locally retained safe finding"}], "_ocr_toolkit": { - "schema_version": 5, + "schema_version": 6, "publication": {"dlp": "blocked"}, }, } @@ -92,7 +92,7 @@ def test_invalid_v5_publication_state_never_reaches_normal_result_flow(self) -> self.assertEqual(exit_code, 0) self.assertEqual(notes, ["**Open Code Review publication policy error**"]) - def test_advisory_without_exact_receipt_v5_never_reaches_normal_result_flow(self) -> None: + def test_advisory_without_exact_receipt_v6_never_reaches_normal_result_flow(self) -> None: """Treat a correctly shaped but unbound advisory as an invalid result.""" notes: list[str] = [] @@ -1311,7 +1311,7 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: "status": "completed_with_errors", "comments": [old, new], "warnings": [], - "_ocr_toolkit": {"schema_version": 5, "publication": publication}, + "_ocr_toolkit": {"schema_version": 6, "publication": publication}, }, ) @@ -1354,7 +1354,7 @@ def capture_note( }, }, "_ocr_toolkit": { - "schema_version": 5, + "schema_version": 6, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -1376,7 +1376,11 @@ def capture_note( { "server": "ocr_toolkit_evidence", "transport": "builtin", - "tools": ["ocr_toolkit_evidence"], + "tools": [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ], } ], "usage": {}, @@ -2053,9 +2057,9 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None for metadata in ( None, {"schema_version": 4}, - {"schema_version": 5, "review": []}, + {"schema_version": 6, "review": []}, { - "schema_version": 5, + "schema_version": 6, "review": {"source_sha": "invalid", "mr_author_id": True}, }, ): @@ -2064,7 +2068,7 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None self.assertEqual( workflow.approval_receipt_identity( { - "schema_version": 5, + "schema_version": 6, "review": {"source_sha": "a" * 40, "mr_author_id": 41}, } ), @@ -3107,7 +3111,7 @@ def test_budget_summary_and_guide_mark_findings_as_partial(self) -> None: def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "mcp": { "usage": { "ocr_toolkit_evidence": 2, @@ -3120,15 +3124,14 @@ def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: self.assertEqual( summary, - "- verified MCP calls: 2 server(s) (`documentation`: 1, `ocr_toolkit_evidence`: 2)\n" - "- built-in evidence actions: unavailable", + "- verified MCP calls: 2 server(s) (`documentation`: 1, `ocr_toolkit_evidence`: 2)", ) self.assertNotIn("file_read", summary) - def test_mcp_usage_summary_reads_receipt_v5_inventory(self) -> None: + def test_mcp_usage_summary_reads_receipt_v6_inventory(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "mcp": { "capabilities": [], "usage": {"ocr_toolkit_evidence": 3}, @@ -3139,14 +3142,13 @@ def test_mcp_usage_summary_reads_receipt_v5_inventory(self) -> None: self.assertEqual( summary, - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)\n" - "- built-in evidence actions: unavailable", + "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)", ) def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, "evidence": { "calls": 4, @@ -3155,6 +3157,8 @@ def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: "summary": 1, "list": 2, "get": 1, + "search": 0, + "coverage": 0, }, }, } @@ -3166,6 +3170,32 @@ def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: "- built-in evidence actions: summary: 1, list: 2, get: 1", ) + def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> None: + """Publish only reconciled non-zero action counts.""" + + for actions in ( + {"state": "unavailable"}, + { + "state": "verified", + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, + ): + with self.subTest(actions=actions): + self.assertEqual( + posting_formatting.format_mcp_usage_summary( + { + "schema_version": 6, + "mcp": {"usage": {"ocr_toolkit_evidence": 3}}, + "evidence": {"calls": 0, "actions": actions}, + } + ), + "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)", + ) + def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) -> None: for actions in ( {"state": "verified", "summary": "1\n/approve", "list": 2, "get": 1}, @@ -3176,7 +3206,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) with self.subTest(actions=actions): summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, "evidence": {"calls": 4, "actions": actions}, } @@ -3191,7 +3221,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) def test_mcp_usage_summary_reconciles_actions_after_context_calls(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "context": {"tool_usage": {"context_get": 1, "context_list": 2}}, "mcp": {"usage": {"ocr_toolkit_evidence": 7}}, "evidence": { @@ -3201,6 +3231,8 @@ def test_mcp_usage_summary_reconciles_actions_after_context_calls(self) -> None: "summary": 1, "list": 2, "get": 1, + "search": 0, + "coverage": 0, }, }, } @@ -3223,7 +3255,7 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non self.assertEqual( posting_formatting.format_mcp_usage_summary( { - "schema_version": 5, + "schema_version": 6, "mcp": {"usage": usage}, "evidence": {"actions": {"state": "unavailable"}}, } @@ -3231,7 +3263,7 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non "", ) - def test_mcp_usage_summary_rejects_pre_v5_receipt(self) -> None: + def test_mcp_usage_summary_rejects_pre_v6_receipt(self) -> None: self.assertEqual( posting_formatting.format_mcp_usage_summary( {"schema_version": 1, "mcp_usage": {}}, @@ -5350,7 +5382,7 @@ def test_toolkit_metadata_is_reserved_and_schema_owned(self) -> None: lambda _payload: {"schema_version": 999, "publication": {"state": "passed"}}, ) - self.assertEqual(metadata["schema_version"], 5) + self.assertEqual(metadata["schema_version"], 6) self.assertEqual(transformed["_ocr_toolkit"], metadata) with self.assertRaisesRegex(ocr_result.OcrResultMalformed, "reserved field"): diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 9ad8041..851754d 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -38,6 +38,18 @@ context_mode="off", context=None, ) +BUILTIN_EVIDENCE_TOOLS = ( + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", +) +SUMMARY_ACTION_COUNTS = { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, +} def enriched_identity() -> review_runner.ReviewIdentity: @@ -334,7 +346,13 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does capabilities=( MCPCapability( "ocr_toolkit_evidence", - ("ocr_toolkit_evidence", "context_list", "context_get"), + ( + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + "context_list", + "context_get", + ), True, ), ), @@ -361,8 +379,9 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does composition, identity, safe_enrichment, + {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, ) - metadata["schema_version"] = 5 + metadata["schema_version"] = 6 eligible = approval.evaluate_approval_policy( settings.BooleanSetting(True), parse_result_outcome(payload), @@ -388,8 +407,14 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does "the selected review context was degraded", ), ): - blocked = review_runner._review_receipt(payload, composition, identity, changed) - blocked["schema_version"] = 5 + blocked = review_runner._review_receipt( + payload, + composition, + identity, + changed, + {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + ) + blocked["schema_version"] = 6 decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), parse_result_outcome(payload), @@ -481,7 +506,7 @@ def test_evidence_mcp_self_query_does_not_satisfy_model_usage(tmp_path: Path) -> ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -505,7 +530,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -513,7 +538,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa "ocr_toolkit_evidence": 2 } assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 5, + "schema_version": 6, "review": {"source_sha": "a" * 40, "policy_sha": "b" * 40, "mr_author_id": None}, "context": { "mode": "off", @@ -531,7 +556,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa { "server": "ocr_toolkit_evidence", "transport": "builtin", - "tools": ["ocr_toolkit_evidence"], + "tools": list(BUILTIN_EVIDENCE_TOOLS), } ], "usage": {"ocr_toolkit_evidence": 2}, @@ -559,13 +584,19 @@ def test_evidence_action_attribution_is_verified_only_on_exact_reconciliation( ) -> None: composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) cases = ( - ({"summary": 1, "list": 1, "get": 0}, "verified"), - ({"summary": 1, "list": 0, "get": 0}, "unavailable"), + ( + {"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, + "verified", + ), + ( + {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + "unavailable", + ), (None, "unavailable"), ) for index, (counts, expected) in enumerate(cases): @@ -591,6 +622,53 @@ def test_evidence_action_attribution_is_verified_only_on_exact_reconciliation( assert actions["state"] == expected +def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( + tmp_path: Path, +) -> None: + """Bind search and coverage counts to their exact OCR by-tool identities.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": { + "total": 5, + "by_tool": { + "ocr_toolkit_evidence": 3, + "ocr_toolkit_evidence_search": 1, + "ocr_toolkit_evidence_coverage": 1, + }, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + counts = {"summary": 1, "list": 1, "get": 1, "search": 1, "coverage": 1} + + usage = review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts=counts, + ) + receipt = json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] + + assert usage == {"ocr_toolkit_evidence": 5} + assert receipt["evidence"] == { + "mandatory": True, + "used": True, + "calls": 5, + "actions": {"state": "verified", **counts}, + } + + def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( tmp_path: Path, ) -> None: @@ -606,7 +684,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -618,7 +696,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( ) assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 5, + "schema_version": 6, "review": {"source_sha": "a" * 40, "policy_sha": "b" * 40, "mr_author_id": 41}, "context": { "mode": "metadata", @@ -636,7 +714,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( { "server": "ocr_toolkit_evidence", "transport": "builtin", - "tools": ["ocr_toolkit_evidence"], + "tools": list(BUILTIN_EVIDENCE_TOOLS), } ], "usage": {"ocr_toolkit_evidence": 1}, @@ -858,7 +936,7 @@ def test_publication_dlp_retains_only_safe_local_findings_and_closed_receipt( ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -868,6 +946,7 @@ def test_publication_dlp_retains_only_safe_local_findings_and_closed_receipt( composition, DEFAULT_IDENTITY, None, + SUMMARY_ACTION_COUNTS, forbidden=("private discussion sentence",), ) @@ -939,7 +1018,7 @@ def test_background_preview_advisory_is_atomically_finalized_without_blocking_ap ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -950,6 +1029,7 @@ def test_background_preview_advisory_is_atomically_finalized_without_blocking_ap composition, replace(DEFAULT_IDENTITY, mr_author_id=41), None, + SUMMARY_ACTION_COUNTS, forbidden=(), toolkit_advisory=advisory, ) @@ -1000,7 +1080,7 @@ def test_raw_ocr_result_cannot_supply_the_reserved_toolkit_advisory(tmp_path: Pa ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1027,7 +1107,7 @@ def test_raw_ocr_result_rejects_duplicate_reserved_advisory_keys(tmp_path: Path) ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1335,7 +1415,7 @@ def test_finalized_result_drops_provider_private_fields_before_receipt_binding( ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1345,6 +1425,7 @@ def test_finalized_result_drops_provider_private_fields_before_receipt_binding( composition, replace(DEFAULT_IDENTITY, mr_author_id=41), None, + SUMMARY_ACTION_COUNTS, forbidden=(), ) @@ -1413,7 +1494,7 @@ def test_review_groups_remain_private_and_cannot_change_approval(tmp_path: Path) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1467,6 +1548,7 @@ def test_review_groups_remain_private_and_cannot_change_approval(tmp_path: Path) composition, identity, None, + SUMMARY_ACTION_COUNTS, forbidden=(), ) @@ -1766,7 +1848,7 @@ def test_publication_dlp_atomically_sanitizes_private_fields_without_losing_mani ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1776,6 +1858,7 @@ def test_publication_dlp_atomically_sanitizes_private_fields_without_losing_mani composition, DEFAULT_IDENTITY, None, + SUMMARY_ACTION_COUNTS, forbidden=(), ) @@ -1939,7 +2022,7 @@ def test_ocr_result_allows_manifest_failure_without_tool_calls(tmp_path: Path) - ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -1950,7 +2033,14 @@ def test_ocr_result_allows_manifest_failure_without_tool_calls(tmp_path: Path) - "mandatory": False, "used": False, "calls": 0, - "actions": {"state": "unavailable"}, + "actions": { + "state": "verified", + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, } assert persisted["_ocr_toolkit"]["mcp"]["usage"] == {} @@ -1973,7 +2063,7 @@ def test_ocr_result_allows_skipped_review_without_tool_calls(tmp_path: Path) -> composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2009,7 +2099,7 @@ def test_ocr_result_allows_manifest_skipped_message_without_tool_calls(tmp_path: ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2044,7 +2134,7 @@ def test_ocr_result_manifest_complete_requires_builtin_mcp_usage(tmp_path: Path) ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2078,7 +2168,7 @@ def test_ocr_result_rejects_unpinned_skipped_contract( result.write_text(json.dumps(payload), encoding="utf-8") composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2106,7 +2196,7 @@ def test_ocr_result_rejects_provider_owned_toolkit_receipt(tmp_path: Path) -> No ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2140,7 +2230,7 @@ def test_ocr_result_receipt_attributes_independent_mcp_servers(tmp_path: Path) - composition = MCPComposition( payload={}, capabilities=( - MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), builtin=True), + MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, builtin=True), MCPCapability("documentation", ("search_docs", "get_docs")), ), external_servers=(), @@ -2178,7 +2268,7 @@ def test_ocr_result_rejects_unbounded_known_mcp_usage( composition = MCPComposition( payload={}, capabilities=( - MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True), + MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True), MCPCapability("external", ("external_a", "external_b")), ), external_servers=(), @@ -2209,7 +2299,7 @@ def test_budget_limited_result_preserves_verified_mcp_usage(tmp_path: Path) -> N ) composition = MCPComposition( payload={}, - capabilities=(MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), True),), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), external_servers=(), secret_values=(), ) @@ -2240,9 +2330,7 @@ def test_ocr_result_receipt_rejects_hard_link_without_rewriting(tmp_path: Path) os.link(target, result) composition = MCPComposition( payload={}, - capabilities=( - MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), builtin=True), - ), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, builtin=True),), external_servers=(), secret_values=(), ) @@ -2922,9 +3010,7 @@ def write(self, path: Path) -> None: composition = MCPComposition( payload={}, - capabilities=( - MCPCapability("ocr_toolkit_evidence", ("ocr_toolkit_evidence",), builtin=True), - ), + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, builtin=True),), external_servers=(), secret_values=(), ) diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index 7b00d56..4a7b431 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -1300,7 +1300,11 @@ def test_configure_mcp_servers_writes_config_without_subprocess(self) -> None: self.assertEqual(config["mcp_servers"]["remote"]["env"], ["AUTH=bridge-secret-value"]) self.assertEqual( config["mcp_servers"][mcp_config.BUILTIN_EVIDENCE_SERVER]["tools"], - ["ocr_toolkit_evidence"], + [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ], ) def test_configure_mcp_servers_replaces_stale_servers(self) -> None: From c3622de2da2b065a11020ddb66b039a6ce6b876c Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:01:42 +0200 Subject: [PATCH 06/12] Admit protected same-revision CI context --- PLANS.md | 14 +- README.md | 6 +- ROADMAP.md | 2 +- changelog.d/161.feature.md | 1 + docs/configuration.md | 2 +- docs/engineering/m5_context_contracts.md | 14 +- docs/engineering/review_signal_ownership.md | 2 +- docs/engineering/test_evidence_matrix.md | 1 + docs/engineering/toolkit_strategy.md | 2 +- docs/gitlab.md | 7 +- docs/operations.md | 2 +- docs/review-context.md | 21 +- docs/security.md | 3 +- examples/gitlab/context/README.md | 3 + .../gitlab/context/policy-ci-outcomes.json | 25 ++ src/ocr_toolkit/context/ci_outcomes.py | 141 +++++++++ src/ocr_toolkit/context/contracts.py | 32 +- src/ocr_toolkit/context/policy.py | 86 +++++- src/ocr_toolkit/context/store.py | 90 +++++- src/ocr_toolkit/evidence/project.py | 10 + src/ocr_toolkit/providers/gitlab_ci.py | 286 ++++++++++++++++++ src/ocr_toolkit/review_runner.py | 73 ++++- tests/test_context_broker.py | 68 ++++- tests/test_context_mcp.py | 46 ++- tests/test_context_policy.py | 74 +++++ tests/test_context_store.py | 89 ++++++ tests/test_gitlab_ci.py | 226 ++++++++++++++ tests/test_integration_contracts.py | 10 + tests/test_review_context.py | 12 +- tests/test_review_runner.py | 56 +++- 30 files changed, 1363 insertions(+), 41 deletions(-) create mode 100644 changelog.d/161.feature.md create mode 100644 examples/gitlab/context/policy-ci-outcomes.json create mode 100644 src/ocr_toolkit/context/ci_outcomes.py create mode 100644 src/ocr_toolkit/providers/gitlab_ci.py create mode 100644 tests/test_gitlab_ci.py diff --git a/PLANS.md b/PLANS.md index eb16b5f..45bb3f7 100644 --- a/PLANS.md +++ b/PLANS.md @@ -127,7 +127,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-04 | done | #159 suppresses unsafe deltas when either side was not admitted while preserving comparable pairs | | WQ-05 | done | #162 guidance, exact OCR 1.11.1 pins/rules/evidence, and local binary are complete | | WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | -| WQ-07 | pending | Implement #161 protected policy v3 and same-revision GitLab CI evidence | +| WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | pending | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | | WQ-09 | pending | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | @@ -200,6 +200,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. installed-artifact, documentation, receipt, publication, and approval suite passed with 621 tests and 350 subtests. Self-review also corrected two current-contract references from receipt v5 to v6 without rewriting historical compatibility records. +- 2026-08-31: #161 adds protected policy v3, a bounded twice-read GitLab pipeline/job adapter, + and one immutable provider-neutral `ci_outcome` projection through the existing store and MCP. + Exact-head and protected path scope are mandatory; pagination, stale checks, duplicate retries, + mutation, DLP mismatch, hostile persistence, and replay fail closed. Only count hints enter the + bootstrap, while raw provider identities/payloads and CI status stay outside receipts, public + summaries, and approval authority. Ruff format/check, `git diff --check`, and the focused + policy/provider/store/MCP/review/publication suite passed with 350 tests and 83 subtests. #### Risks And Recovery @@ -219,8 +226,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Begin WQ-07 with the provider-neutral CI outcome model and protected context-policy v3, then -connect one bounded twice-read GitLab snapshot to store/MCP degradation and approval tests. +Begin WQ-08 with #157's stable-distribution badges, then reconcile current public docs, +strategy/roadmap/backlog, changelog, and final Draft-only plan truth without rewriting historical +release records. #### Plan Fidelity Check diff --git a/README.md b/README.md index e54a365..ec02b28 100644 --- a/README.md +++ b/README.md @@ -59,16 +59,16 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. -After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v6's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, and adapter records do not independently block approval, while degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. +After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v6's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not independently block approval, while degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries. ## Project architecture -The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, literal search, scoped absence checks, and base/head changes through three fixed tools in the mandatory built-in read-only MCP server. Protected-policy enriched reviews can acquire stable GitLab discussions, verified remediation history, and authorized external issue/document records before OCR. Forge-specific acquisition and posting stay at provider edges; the broker, DLP, store, MCP, receipts, and tests use common contracts so a future GitHub adapter can reuse them without inheriting GitLab API semantics. The same built-in MCP exposes only opaque committed `context_list`/`context_get` handles; it has no provider network or arbitrary identifier path. Direct external MCP remains a separate privileged, comment-only operator boundary. +The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, literal search, scoped absence checks, and base/head changes through three fixed tools in the mandatory built-in read-only MCP server. Protected-policy enriched reviews can acquire stable GitLab discussions, verified remediation history, protected same-revision CI outcomes, and authorized external issue/document records before OCR. Forge-specific acquisition and posting stay at provider edges; the broker, DLP, store, MCP, receipts, and tests use common contracts so a future GitHub adapter can reuse them without inheriting GitLab API semantics. The same built-in MCP exposes only opaque committed `context_list`/`context_get` handles; it has no provider network or arbitrary identifier path. Direct external MCP remains a separate privileged, comment-only operator boundary. - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals. -- [Bounded review context](docs/review-context.md) - protected policy, adapter protocol, GitLab discussions, opaque handles, DLP, receipt, and cleanup contracts. +- [Bounded review context](docs/review-context.md) - protected policy, GitLab discussions and same-revision CI outcomes, adapter protocol, opaque handles, DLP, receipt, and cleanup contracts. - [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals. - [Backlog](docs/codex/TASKS_BACKLOG.md) - inactive implementation-ready work; active execution remains in `PLANS.md`. diff --git a/ROADMAP.md b/ROADMAP.md index 67acd74..873b881 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,7 @@ flowchart LR | M2 Ecosystem and framework coverage | Established | Supply framework and template evidence selected from demonstrated use without creating framework-specific review engines. | Established evidence, snapshot/delta, scoped-completeness, and built-in MCP contracts. | Selected static plugins and template review rules have deterministic fixtures, bounds, provenance, component ownership, completeness, first-class source/target delta queries, installed-artifact validation, verified use through the existing built-in MCP, and independently read-back stable delivery. | | M3 External MCP hardening | Established | Qualify and document the safe-use envelope and residual limits of the shipped generic external-MCP composition boundary. | Existing external MCP and built-in composition plus BL-011 real-OCR qualification. | Canonical security and configuration guidance records the direct-composition trust boundaries, tool-name allowlist limits, server-owned object authorization, shared plan/main exposure, response/session persistence, failure degradation, and receipt non-claims observed with checksum-verified OCR and a real synthetic stdio peer. Managed OAuth remains conditional. | | M4 Policy and project guidance | Established | Supply relevant target-branch decisions and guidance without allowing self-whitelisting. | Evidence scoping and target/source snapshots. | Stable delivery independently proves backward-compatible structured decisions, bounded target-derived guidance, one read-only MCP lifecycle, and closure of the tracked release work. | -| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | +| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, protected same-revision CI outcomes, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. v0.8.7 adds policy-v3 exact-head CI outcomes as scoped review evidence without suppression or approval authority. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | | M6 Profiles and quality measurement | Established / conditional | Keep the completed review-signal ownership audit current; add model-profile aliases only after demonstrated operational need. | The BL-017 audit establishes OCR/provider telemetry and toolkit lifecycle-signal ownership; a demonstrated alias need and owner-approved matrix are required only for profile implementation. | The audit concludes `no-new-layer`; any later model profiles remain conditional and independent from explicit coverage and budget controls. | | M7 Later and conditional work | Conditional | Activate routing, more ecosystems, fuzzing, configuration, forge adapters, or governance work only from demonstrated need. | Milestone-specific activation signals and stable preceding contracts. | Each item meets its own trigger and ships as a coherent validated slice without weakening core invariants. | diff --git a/changelog.d/161.feature.md b/changelog.d/161.feature.md new file mode 100644 index 0000000..4ed57cc --- /dev/null +++ b/changelog.d/161.feature.md @@ -0,0 +1 @@ +Add protected review-context policy v3 for bounded same-revision CI outcomes. Operators may map exact GitLab job names to protected path prefixes; the toolkit twice reads exact-head pipeline/job metadata and exposes only closed status, required/advisory classification, current-or-same-revision provenance, scope, and completion time through the existing private context MCP. Logs, artifacts, URLs, provider IDs, identities, variables, and raw payloads remain private and unpersisted. CI outcomes provide review context only: they cannot suppress findings, prove unrelated absence, change severity/lifecycle, or authorize approval. Policies v1/v2 remain valid without `ci_outcomes`. ([#161](https://github.com/xeonvs/open-code-review-toolkit/issues/161)) diff --git a/docs/configuration.md b/docs/configuration.md index 25eed74..c84e850 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -141,7 +141,7 @@ An unavailable optional server or tool/protocol error can degrade while OCR cont `OCR_REVIEW_CONTEXT_MODE` is parsed before provider acquisition or OCR execution. Missing, empty, and `off` select identity-only acquisition: the provider still validates the source SHA, protected target identity, and positive merge-request author ID needed by policy and approval, but title, description, labels, and source branch are not normalized or stored. `metadata` requires a validated GitLab merge-request environment and admits only the existing bounded title, description, label, and optional source-branch projection. Every field reports a closed status; metadata is `complete` only when every selected field is absent or admitted. Invalid, over-limit, collision, redaction-limit, or partial states are `degraded`. -`enriched` requires a validated GitLab merge request and a valid `.opencodereview/review-context-policy.json` read only from the captured protected-target policy SHA. It adds policy-selected generic GitLab discussions, verified remediation threads, and adapter records through a separate private store and fixed `context_list`/`context_get` tools. A source policy cannot expand access; missing or invalid protected policy fails before OCR. `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` is an exact operator allowlist, and protected policy may only narrow it. See [Bounded review context](review-context.md) for policy v1/v2 compatibility, the stdio/HTTPS proxy protocol, fixed remediation projection, handles, DLP, completeness, receipt, and cleanup contracts. +`enriched` requires a validated GitLab merge request and a valid `.opencodereview/review-context-policy.json` read only from the captured protected-target policy SHA. It adds policy-selected generic GitLab discussions, verified remediation threads, protected same-revision CI outcomes, and adapter records through a separate private store and fixed `context_list`/`context_get` tools. A source policy cannot expand access; missing or invalid protected policy fails before OCR. `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` is an exact operator allowlist, and protected policy may only narrow it. See [Bounded review context](review-context.md) for policy v1/v2/v3 compatibility, the stdio/HTTPS proxy protocol, fixed remediation/CI projections, handles, DLP, completeness, receipt, and cleanup contracts. Complete DLP-admitted metadata, generic discussions, and dynamic records remain untrusted evidence but do not independently block approval. Degraded metadata, any DLP rejection, required enriched-source degradation, and every admitted remediation thread do block approval; optional non-DLP degradation remains visible and cannot prove absence. A complete enriched run without admitted remediation may pass the remaining receipt and evidence gates. No context mode can change policy, suppression, posting authority, credentials, or approval thresholds. diff --git a/docs/engineering/m5_context_contracts.md b/docs/engineering/m5_context_contracts.md index 7cf1fb3..4577c18 100644 --- a/docs/engineering/m5_context_contracts.md +++ b/docs/engineering/m5_context_contracts.md @@ -12,7 +12,7 @@ OCR performs one review and one model loop. The toolkit does not run contextual The only policy path is `.opencodereview/review-context-policy.json`. It is read as a bounded regular immutable Git blob from the captured protected-target policy SHA. A working-tree or source-branch file, symlink, submodule, missing/unsafe object, invalid UTF-8, duplicate JSON key, oversized input, unknown field/version, or impossible projection is rejected. Explicit `enriched` mode without a valid policy fails before OCR. -Policy v1 remains accepted for existing discussion/reference configurations. Policy v2 is additive and is required only when `remediation_threads` is selected: +Policy v1 remains accepted for existing discussion/reference configurations. Policy v2 adds `remediation_threads`. Policy v3 is required only when protected same-revision `ci_outcomes` are selected: ```json { @@ -34,6 +34,8 @@ Policy v1 remains accepted for existing discussion/reference configurations. Pol `remediation_threads` is optional only in v2. It has the same required/account/resolution/age/thread/reply/item/text-budget bounds but no configurable projection. `toolkit_bot` is not a reply allowlist choice: the root is independently required to match the live authenticated bot identity and a valid toolkit marker/fingerprint, while admitted replies use only explicitly selected non-toolkit account classes. The fixed model projection is one thread object containing a DLP-checked root with a run-local actor identity, a closed anchor state, ordered DLP-checked pseudonymized replies, closed completeness, and bounded reply/resolved/outdated counts. Policy v1 rejects this field rather than silently ignoring it. +Policy v3 adds exact check names and sorted protected repository-relative path prefixes, with `required=false` and a 24-hour age bound by default. The GitLab edge supplies exact-head status and current/same-revision-pipeline provenance through a twice-read bounded snapshot. The provider-neutral core receives only closed outcomes; raw pipeline/job IDs, URLs, users, runners, variables, logs, artifacts, and response objects never cross the adapter boundary. A pass is scoped contradiction evidence, not finding suppression, clean-review, lifecycle, severity, or approval authority. + Each reference entry contains `adapter`, `tenant`, `resource_class`, `recognizer`, `required`, `max_records`, `max_age_seconds`, source text budgets, and `projections`. Resource classes are `issue` and `document`. Recognizers are exact toolkit grammars: protected-prefix issue keys, exact protected HTTPS origin/path prefix, or an explicit bounded reference form. User-configurable regular expressions and repository-wide text search are absent. Generic discussions and reference sources use the exact projection object: @@ -47,7 +49,7 @@ Generic discussions and reference sources use the exact projection object: } ``` -The allowed field vocabulary is closed per generic projection. `model`, `publish`, and `retain` must each be subsets of `retrieve`; retention additionally rejects text, upstream identifiers, URLs, commands, transport data, personal display data, and raw payloads. Remediation's nested projection cannot be selected by generic discussions or reference adapters and cannot appear in publish or retain projections. A policy with none of generic discussions, remediation threads, or references is invalid. +The allowed field vocabulary is closed per generic projection. `model`, `publish`, and `retain` must each be subsets of `retrieve`; retention additionally rejects text, upstream identifiers, URLs, commands, transport data, personal display data, and raw payloads. Remediation's nested projection cannot be selected by generic discussions or reference adapters and cannot appear in publish or retain projections. A policy with none of generic discussions, remediation threads, CI outcomes, or references is invalid. ## Protected rules-path setup outcome @@ -81,15 +83,15 @@ Stdio uses a clean environment, isolated owner-only working/home directory, boun ## Context store and handles -The private artifact is `ocr.context-store/v2`. It is written owner-only through atomic replacement and read back as hostile input. The envelope binds store/run/policy identity, creation/expiry, completeness, records, handle index, and a canonical digest. Symlink, non-regular file, extra hard link, unsafe permissions, oversize, duplicate/colliding key, partial replacement, impossible projection, record/index mismatch, and stale identity fail closed. Store v2 adds the `remediation_thread` resource class and its fixed nested model projection; generic adapter resource classes remain only `issue` and `document`. +The private artifact is `ocr.context-store/v2`. It is written owner-only through atomic replacement and read back as hostile input. The envelope binds store/run/policy identity, creation/expiry, completeness, records, handle index, and a canonical digest. Symlink, non-regular file, extra hard link, unsafe permissions, oversize, duplicate/colliding key, partial replacement, impossible projection, record/index mismatch, and stale identity fail closed. Store v2 supports fixed nested `remediation_thread` and immutable `ci_outcome` model projections; generic adapter resource classes remain only `issue` and `document`. A handle is `ctx1_` plus 32 random bytes encoded as unpadded base64url. It is minted only after a completely normalized, retrieval-DLP-checked record is committed. Private mapping binds run, adapter, tenant, canonical object digest, resource class, allowed projections, version/digest, policy digest, expiry, and record. A caller-supplied upstream ID or URL is never accepted as a handle. ## Built-in MCP -`off` and `metadata` expose only `ocr_toolkit_evidence`. `enriched` exposes exactly `ocr_toolkit_evidence`, `context_list`, and `context_get` from the same built-in stdio process. +`off` and `metadata` expose the three fixed evidence tools. `enriched` additionally exposes exactly `context_list` and `context_get` from the same built-in stdio process. -`context_list` accepts only closed source/resource-class filters (`issue`, `document`, or `remediation_thread`), an opaque store-bound cursor, and a bounded page size. It returns minted handles, safe descriptors, per-source completeness, and the next cursor. `context_get` accepts exactly one minted handle and returns only its policy-admitted model projection from the committed local store. For remediation it returns no raw GitLab identity or provider object: only run-local actors, DLP-checked root/replies, closed anchor/completeness state, and bounded counts. Invalid arguments, arbitrary identifiers, wrong-run/policy, expired or missing handles are rejected before record access. Neither tool performs network, subprocess, search, write, or provider operations. +`context_list` accepts only closed source/resource-class filters (`issue`, `document`, `remediation_thread`, or `ci_outcome`), an opaque store-bound cursor, and a bounded page size. It returns minted handles, safe descriptors, per-source completeness, and the next cursor. `context_get` accepts exactly one minted handle and returns only its policy-admitted model projection from the committed local store. For remediation it returns no raw GitLab identity or provider object: only run-local actors, DLP-checked root/replies, closed anchor/completeness state, and bounded counts. For CI it returns only protected scope, closed status/requirement/provenance, the `reviewed_head` binding, and completion time. Invalid arguments, arbitrary identifiers, wrong-run/policy, expired or missing handles are rejected before record access. Neither tool performs network, subprocess, search, write, or provider operations. The bootstrap requires a model-recorded `ocr_toolkit_evidence(action=summary)` call before analysis. Toolkit preflight self-query never satisfies this requirement. @@ -105,7 +107,7 @@ Receipt schema `ocr.toolkit-receipt/v6` stores only closed review/policy identit Schema versions protect serialized trust boundaries; they are not a database-retention promise. The review result crosses from the review process/job to hostile posting readback, so its version prevents an older field set from inheriting newer approval guarantees. Policy and adapter versions similarly bind independent producers/consumers. Ephemeral evidence/context stores accept only their exact current schema and intentionally have no migration or upgrade path. -Automatic approval preserves every existing manifest, coverage, warning, omission, finding, exact-SHA, author, provider, and self-approval gate. Required-source degradation and any admitted remediation thread block approval. Optional non-DLP degradation is visible and cannot prove source absence. DLP-clean title, description, generic discussion, and adapter context do not block approval solely because they were inspected. DLP rejection degrades its source and can never restore or enable approval. Remediation admission is an independent comment-only condition regardless of the reply's wording. +Automatic approval preserves every existing manifest, coverage, warning, omission, finding, exact-SHA, author, provider, and self-approval gate. Required-source degradation and any admitted remediation thread block approval. Optional non-DLP degradation is visible and cannot prove source absence. DLP-clean title, description, generic discussion, protected CI outcome, and adapter context do not block approval solely because they were inspected; CI status never authorizes approval. DLP rejection degrades its source and can never restore or enable approval. Remediation admission is an independent comment-only condition regardless of the reply's wording. ## Capability decision for OCR 1.9.8 diff --git a/docs/engineering/review_signal_ownership.md b/docs/engineering/review_signal_ownership.md index 9c997e9..7f22ee9 100644 --- a/docs/engineering/review_signal_ownership.md +++ b/docs/engineering/review_signal_ownership.md @@ -16,7 +16,7 @@ a new telemetry API. | Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory evidence-action attribution in receipt v6 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | | Selection, completed/reused/failed/waived coverage, and aggregate-budget stop | OCR run manifest | Closed result outcome, summary, receipt validation, and approval blockers | Incomplete, malformed, failed, waived, or budget-stopped coverage fails closed; no duplicate toolkit budget metric is needed. | | Findings, severity, fingerprints, suppression, resolution, and repeated discussions | OCR findings plus toolkit-owned posting snapshots and human commands | GitLab discussions, summary, exact fingerprints, and closed lifecycle state | Remediation text and additive group metadata cannot change severity, prove resolution, suppress findings, or issue commands. | -| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v6 context/evidence state | Raw merge-request title, description, discussions, provider identities, rejected text, and record contents do not enter the receipt or telemetry event. | +| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v6 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | | Publication DLP and posting transaction state | Toolkit result projection and GitLab transaction owner | Receipt-v5 publication state, one parseable summary marker, and one local count-only log event | `private-sanitized` can preserve approval only when the canonical projection is unchanged; `publication-filtered` makes the public projection incomplete and blocks approval without relabelling independently complete OCR coverage. No rejected value or location is emitted. | | OCR compatibility qualification | Compatibility workflow and checksum-pinned evidence | Canonical issue plus bounded success or failure artifact | Public failure status contains only closed phase/reason/version/run fields. Raw qualification exceptions stay in the job log. | diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index b77942f..a331945 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -81,6 +81,7 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | Oversize, omission, TOCTOU, replay | separate request/admission/streaming limits, repeated bounded GitLab digest, aggregate orchestration, and `context.store.ContextStore` binding | real child/TLS no-read, request-write timeout, oversize, partial/multiple frame and bounded-pagination mutation; authorization-before-budget, exact/changed duplicate, hard-link/persistence/recursive-readback, version, lifetime and replay cases | proven through production transport owners, committed store, and local MCP readback | | PII/secret leakage and output laundering | `context.dlp.check_text`, `review_runner._publication_projection`, and `ocr_result.transform_ocr_result` own one inode-checked sink/private-retention projection and receipt replacement after cleanup; explicit local preservation may add only the bounded value-free `_private_dlp_decisions` attribution | multibyte/line/PII/secret, bare SHA/build and separator-bearing technical-ID false-positive classes, whole-value, 24-character exact excerpt, hidden-only/nested HTML source, inline/reference/autolink Markdown, private-key/value sanitization, path/decision bounds, and work-bound tests cover retrieval and raw OCR result; unsafe sink units disappear while private-only fields are sanitized without losing a valid manifest; prior review, one-for-one fingerprint matching, approval blocking, summary marker and log event remain | corrected publication behavior and value-free local attribution are proven deterministically through the production transform/sidecar/posting owners. The real triggering run predates the final correction and is not cited as exact-final-tree proof; operators must compare sidecar digests to separately retained private data before classifying a conservative match | | Identity spoofing | `providers.gitlab_discussions.acquire_discussions` provider class plus run-local pseudonym; unknown fails closed | repeated real-TLS provider classification, ordered pagination, unknown actor, mutation, and display-identity exclusion | proven for GitLab acquisition | +| Same-revision CI identity and scope | `providers.gitlab_ci.acquire_gitlab_ci_outcomes` plus `context.ci_outcomes.prepare_ci_outcome_records`; protected policy v3 owns exact check names and path prefixes | exact-head/current-versus-other-pipeline, stale, duplicate retry, pagination, mutation, hostile payload, DLP, store replay, MCP list/get, bootstrap, and approval-independence tests | proven for bounded GitLab acquisition and provider-neutral projection; job truth and model use remain service/model non-claims | | Prompt injection and authority escalation | fixed adapter fields, static bootstrap authority text, exact marker preambles, and fixed `context_list`/`context_get`; context cannot alter policy/tools/commands/suppression/approval | hostile schema/description and marker-smuggling tests, fixed MCP declarations, separate evidence-call accounting, and receipt approval tests pass; the real OCR run saw the fixed three-tool declaration but did not call context tools | deterministic authority boundaries proven; model judgment and model context consumption remain non-claims | | Model skips mandatory repository evidence | `evidence.project.build_bootstrap` places an unclippable static summary-call instruction before repository-derived detail; `review_runner._attach_receipt` requires OCR-recorded usage | exact minimum-budget ordering/rendering/truncation tests and zero-call receipt rejection; self-query is independently recorded and never counted; final real OCR 1.9.8 output recorded 44 evidence calls | deterministic bootstrap/receipt gate and real model compliance at the reviewed pre-remediation head proven | | GitLab CI unresolved inheritance invents defaults | protected `examples/gitlab/rules.json` GitLab CI rule; optional compiled facts use only the M5 document adapter/broker | four synthetic cases freeze unknown/proven expectations; real stdio broker records prove compiled true/false delivery; checksum-verified OCR 1.9.8 receives the exact rule and field set during a real review | rule selection/delivery proven; finding judgment remains explicitly non-deterministic and no compiled-config fetch is claimed | diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index 651432c..bd12547 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -121,7 +121,7 @@ During OCR, the model may list or read only minted handles through fixed toolkit OCR remains the only model review engine. If a separate contextual adjudication phase is required, M5 waits for a native structured OCR capability instead of running and merging a second toolkit-driven review. OCR uses an isolated owner-only home with deterministic session cleanup; inability to contain or clean ordinary runs blocks publication. An explicit local diagnostic can retain owner-only private state only without a posting receipt, and validated GitLab MR execution rejects it before OCR. Publication validation and DLP are independent from retrieval and model egress and cannot reverse an earlier disclosure to the model. -Toolkit 0.6.3 remains the historical context-selection, receipt-v3, approval-identity, and GitLab-MR transport foundation tracked by #100. v0.7.0 activates discussion/reference acquisition, the broker/store/handle lifecycle, and enriched mode through BL-023. Configured direct external MCP still makes a review comment-only rather than carrying an enforceable read-only approval claim. Same-session annotation enforcement is not a separate M5 path: annotations remain server-authored claims, and external records cross toolkit-owned broker authorization and fixed tools instead of direct provider-specific model capabilities. +Toolkit 0.6.3 remains the historical context-selection, receipt-v3, approval-identity, and GitLab-MR transport foundation tracked by #100. v0.7.0 activates discussion/reference acquisition, the broker/store/handle lifecycle, and enriched mode through BL-023. Toolkit 0.8.7 extends that same path with protected policy-v3 same-revision CI outcomes: GitLab remains an acquisition adapter, protected policy owns exact job names/path scopes, and the provider supplies a twice-read exact-head closed outcome without raw forge IDs, URLs, logs, artifacts, users, runners, or variables. Scoped execution evidence may challenge a runtime claim but never becomes suppression, clean-review, severity, lifecycle, or approval authority. Configured direct external MCP still makes a review comment-only rather than carrying an enforceable read-only approval claim. Same-session annotation enforcement is not a separate M5 path: annotations remain server-authored claims, and external records cross toolkit-owned broker authorization and fixed tools instead of direct provider-specific model capabilities. Public qualification uses private-safe controlled peers. Issue-tracker, documentation/wiki, native API, and read-only MCP bridge peers are validated only behind the broker with dedicated credentials, real process/TLS protocol and persistence evidence, hostile-provider cases, installed-artifact/real-OCR gates, and explicit model-dependent claim limits. Vendor-specific production clients are not added by M5. diff --git a/docs/gitlab.md b/docs/gitlab.md index 05adb58..a7475e4 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -36,9 +36,10 @@ The collapsed technical details keep aggregate input/output/cache token usage se | [Metadata](../examples/gitlab/modes/metadata.gitlab-ci.yml) | Bounded MR title, description, labels, and source branch | GitLab reads before OCR | May approve when DLP and every other gate pass | | [Enriched discussions](../examples/gitlab/modes/enriched-discussions.gitlab-ci.yml) | Metadata plus protected-policy generic discussions and verified remediation threads | Stable double-read GitLab snapshot before OCR | Recipe is explicitly comment-only | | [Enriched adapters](../examples/gitlab/modes/enriched-adapters.gitlab-ci.yml) | Metadata plus protected-policy discussion and adapter records | Fixed authorize-and-resolve protocol before OCR | Recipe is explicitly comment-only | +| [Same-revision CI outcomes](../examples/gitlab/context/policy-ci-outcomes.json) | Protected-policy exact check names and path prefixes | Twice-read exact-head GitLab pipeline/job metadata; no logs or artifacts | Context only; never suppression or approval authority | | [Direct MCP](../examples/gitlab/modes/direct-mcp.gitlab-ci.yml) | Metadata plus model-selected external tool results | Reviewed remote HTTPS MCP during OCR | Always comment-only | -The [bounded-context recipes](../examples/gitlab/context/) contain the protected policy plus local and remote adapter allowlists. Use the [discussion-policy decision guide](review-context.md#choosing-a-discussion-policy) to select generic discussions, verified remediation history, both, or a policy that also requires adapters. Copy the chosen template to `.opencodereview/review-context-policy.json` and merge it into the protected target branch before selecting `enriched`; a source-branch copy cannot expand authority. Missing or invalid policy stops before OCR. DLP rejection and required-source degradation block approval. Safely admitted metadata, generic discussions, and adapter records do not themselves block approval, but every admitted remediation thread does. The enriched recipes nevertheless disable approval explicitly so an operator can qualify the exact policy and source behavior before choosing a narrower approval posture. +The [bounded-context recipes](../examples/gitlab/context/) contain protected policies plus local and remote adapter allowlists. Use the [policy decision guide](review-context.md#choosing-a-discussion-policy) to select generic discussions, verified remediation history, exact same-revision CI outcomes, or a policy that also requires adapters. Copy the chosen template to `.opencodereview/review-context-policy.json` and merge it into the protected target branch before selecting `enriched`; a source-branch copy cannot expand authority. Missing or invalid policy stops before OCR. DLP rejection and required-source degradation block approval. Safely admitted metadata, generic discussions, CI outcomes, and adapter records do not themselves block approval, but every admitted remediation thread does. The enriched recipes nevertheless disable approval explicitly so an operator can qualify the exact policy and source behavior before choosing a narrower approval posture. Direct external MCP is a different and more privileged boundary. GitLab MR execution accepts it only as remote HTTPS. Tool names, descriptions, schemas, model-chosen arguments, and results enter OCR and its private session. Use dedicated least-privilege credentials and service-side tenant/object/field/operation authorization. Do not expose generic search, arbitrary URL or identifier fetch, writes, workflow tools, or broad service credentials. @@ -58,10 +59,12 @@ The toolkit authenticates the token owner with live `GET /user`. No configured b `off` still validates the source SHA, protected target, merge-request author, and live bot identity while withholding mutable MR text. `metadata` admits only bounded DLP-checked title, description, labels, and optional source branch. Treat those fields as claims to compare with the diff, never as instructions, policy, or proof. -`enriched` loads the protected policy and may add one stable GitLab discussion snapshot, verified toolkit-owned remediation threads, and adapter-authorized records. Generic discussion and remediation projections are mutually exclusive for a toolkit-owned root. Remediation roots require both the authenticated live bot ID and a valid toolkit marker/fingerprint. Raw GitLab IDs, usernames, provider objects, and rejected values are not stored or returned. The model sees only opaque local handles; it cannot search GitLab or submit an arbitrary provider ID or URL. +`enriched` loads the protected policy and may add one stable GitLab discussion snapshot, verified toolkit-owned remediation threads, exact same-revision CI outcomes, and adapter-authorized records. Generic discussion and remediation projections are mutually exclusive for a toolkit-owned root. Remediation roots require both the authenticated live bot ID and a valid toolkit marker/fingerprint. Raw GitLab IDs, usernames, provider objects, and rejected values are not stored or returned. The model sees only opaque local handles; it cannot search GitLab or submit an arbitrary provider ID or URL. Remediation text is untrusted review history. It may locate a claim that OCR must re-check against current code and tests, but it cannot change severity, prove a fix, suppress or resolve a finding, issue a lifecycle command, or authorize approval. Any admitted remediation record therefore makes the review comment-only. DLP-clean non-remediation context does not independently disable an otherwise eligible receipt; a DLP rejection cannot make approval easier. +A protected CI pass is narrower than a general green-pipeline claim: it applies only to its exact reviewed head, exact protected job name, and declared path prefixes. It may challenge an unconditional claim about code that the job actually exercised, but it cannot suppress a finding, prove unrelated absence, change severity/lifecycle, or authorize approval. Stale, advisory, skipped, canceled, failed, unknown, incomplete, mutated, or ambiguous data cannot be treated as a required pass. The toolkit does not fetch job logs, artifacts, URLs, users, runners, variables, or raw IDs. + Provider configuration is forge-neutral. Configure and preflight share one normalized absolute HTTPS API root, explicit protocol, headers, request-body controls, and optional models URL. A protocol-mismatched terminal endpoint, embedded credential, or fragment fails before OCR. On a classified provider failure, `post` publishes only a static safe reason and guidance; raw provider/model fields, response bodies, request IDs, paths, warnings, and stderr remain private. The review log may contain one toolkit-authored line of closed HTTP detail and non-zero aggregate retry counts from a fully validated retry report, but the GitLab summary does not. A `rate-or-spending-limit` note suggests lowering `OCR_REVIEW_CONCURRENCY` and/or `OCR_LLM_MAX_COMPLETION_TOKENS`, starting a new merge request pipeline, and then checking provider request/account limits without claiming either setting caused the failure. The previous successful review remains visible, no failed-result findings are posted, and approval is not attempted. When a merge request introduces a repository-owned OCR rules path absent from both trusted baselines, `review` stops before OCR and `post` may publish only the static setup-pending message after hostile identity validation. The source file never becomes policy evidence for its own merge request. diff --git a/docs/operations.md b/docs/operations.md index ff34fd2..bf7bc5b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -43,7 +43,7 @@ The outcome wording distinguishes skipped, complete, complete-with-warnings, inc ## Automatic approval lifecycle `OCR_AUTO_APPROVE=true` is the default. Approval is a separate transaction only -after every current review note publishes. A review is eligible only with exact closed receipt v6, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v5 is rejected by posting and approval. Receipt v6 binds reviewed source/policy SHA, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval. Every finding must have +after every current review note publishes. A review is eligible only with exact closed receipt v6, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v5 is rejected by posting and approval. Receipt v6 binds reviewed source/policy SHA, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval. Every finding must have severity exactly `low` and category exactly `style`, `documentation`, or `maintainability`. A complete zero-finding review is eligible. Four findings, malformed metadata, or any other severity/category are not eligible. diff --git a/docs/review-context.md b/docs/review-context.md index 60e210f..989f4b2 100644 --- a/docs/review-context.md +++ b/docs/review-context.md @@ -1,6 +1,6 @@ # Bounded review context -Open Code Review Toolkit can enrich one validated forge review with bounded merge-request metadata, discussions, verified remediation history, and records resolved by operator-managed adapters. GitLab is the current provider implementation; acquisition normalizes into provider-neutral broker views before storage. Enrichment is a single pre-OCR phase. OCR remains the only review engine, and its model loop can read only committed local handles through the toolkit's existing built-in MCP process. +Open Code Review Toolkit can enrich one validated forge review with bounded merge-request metadata, discussions, verified remediation history, protected same-revision CI outcomes, and records resolved by operator-managed adapters. GitLab is the current provider implementation; acquisition normalizes into provider-neutral broker views before storage. Enrichment is a single pre-OCR phase. OCR remains the only review engine, and its model loop can read only committed local handles through the toolkit's existing built-in MCP process. ## Modes and lifecycle @@ -8,7 +8,7 @@ Open Code Review Toolkit can enrich one validated forge review with bounded merg - Empty or `off` validates immutable review and posting identities but does not normalize or persist mutable merge-request text. - `metadata` additionally admits bounded title, description, labels, and source-branch text. -- `enriched` requires a validated GitLab merge-request environment and a valid protected-target policy. It includes the same metadata projection, a stable bounded GitLab discussion snapshot, verified toolkit-owned remediation threads, and policy-recognized external records when selected. Missing or invalid policy stops the review before OCR. +- `enriched` requires a validated GitLab merge-request environment and a valid protected-target policy. It includes the same metadata projection plus selected stable GitLab discussions, verified toolkit-owned remediation threads, protected same-revision CI outcomes, and policy-recognized external records. Missing or invalid policy stops the review before OCR. The lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v6 through one inode-checked atomic replacement. A cleanup or publication-validation failure blocks ordinary result publication. @@ -91,6 +91,8 @@ The following complete v2 policy selects generic GitLab discussions, verified to The top-level aggregate budget limits independent record, character, UTF-8 byte, physical-line, and wall-time dimensions. Each source has its own text, age, item, and provider-specific limits. Hitting one limit does not silently relabel the source complete. +Policy v3 adds an optional `ci_outcomes` selector. Each `checks` entry contains one exact GitLab job name and a sorted unique list of protected repository-relative `path_prefixes`. `required` defaults to `false`; `max_age_seconds` defaults to `86400` and accepts 60 through 604800. The protected policy supplies scope, while the authenticated forge API supplies only the exact-head outcome. Repository-authored status text, job logs, artifacts, URLs, runner/user identities, raw IDs, and variables are never inputs. + Discussion account classes are the closed set `user`, `automation`, `system`, and `toolkit_bot`. GitLab classifies accounts before storage and replaces display identity with a run-local pseudonym. Remediation reply classes cannot include `toolkit_bot`; the separately verified root owns the toolkit-bot role. Name, username, email, avatar/profile URL, and raw provider IDs are never model fields. ### Choosing a discussion policy @@ -103,8 +105,11 @@ The runtime always reads the fixed protected-target path `.opencodereview/review | Earlier OCR finding plus human remediation replies only | `policy-discussions.json` | `remediation_threads`; remove `forge_discussions` | `OCR_REVIEW_CONTEXT_ADAPTERS_JSON=[]` | | Both ordinary conversation and remediation history | `policy-discussions.json` | Keep both selectors | `OCR_REVIEW_CONTEXT_ADAPTERS_JSON=[]` | | Discussions plus authorized issue/document records | `policy-adapters.json` | Keep the needed discussion selectors and references | Supply one matching reviewed adapter allowlist | +| Same-revision CI execution context | `policy-ci-outcomes.json` | `ci_outcomes` with exact check names and truthful path prefixes | `OCR_REVIEW_CONTEXT_ADAPTERS_JSON=[]` | + +Use policy v1 unchanged only for generic discussions/references. Choose v2 for `remediation_threads`. Choose v3 whenever `ci_outcomes` is selected; v3 may also contain v2 selectors and references. -Use policy v1 unchanged only when an existing project needs generic discussions or references and does not need remediation history. Choose policy v2 for any `remediation_threads` selector; v2 may also select generic discussions and references. +CI outcome scope is deliberately narrow. A required `passed` record proves only that the named job completed successfully for the exact reviewed head and that protected policy declares the finding path inside its prefixes. `failed`, `skipped`, `canceled`, `unknown`, advisory, stale, incomplete, or unknown-scope state cannot prove execution success or absence. Even a required pass may challenge a falsifiable unconditional runtime claim, but it cannot suppress a finding, establish a clean review, change severity/lifecycle, or authorize approval. Start each discussion source with `required: false`. Set it to `true` only when the review must treat an unavailable, mutated, DLP-rejected, or bounded-partial source as a blocking loss of required evidence. `required` does not mean that at least one matching thread must exist: a stable complete snapshot with zero selected threads is still complete. `include_resolved` and `include_outdated` should remain false unless historical or stale anchors are intentionally relevant. Keep `account_classes` to the smallest set needed; `remediation_threads.account_classes` applies to replies and cannot include `toolkit_bot`. @@ -122,7 +127,7 @@ Candidates are extracted only from admitted merge-request metadata and admitted Projection fields are sorted unique lists. `model`, `publish`, and `retain` must each be subsets of `retrieve`. Retention is limited to `state`, `count`, `digest`, `version`, and `expiry`; it cannot retain text, upstream identifiers, URLs, commands, transport data, or personal display data. Retrieval, model egress, publication, and retention are deliberately separate decisions. -Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` is additive and permits the optional `remediation_threads` selector; v1 rejects that field instead of interpreting it with weaker semantics. New examples use v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v6 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. +Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` adds optional `remediation_threads`. Policy `ocr.review-context-policy/v3` adds optional protected `ci_outcomes`; v1/v2 reject that selector instead of interpreting it with weaker semantics. New CI examples use v3 while existing discussion/remediation examples remain valid v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v6 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. ## Operator adapter allowlist @@ -171,18 +176,20 @@ An admitted response has exact schema `ocr.context-adapter-response/v1`, matchin Unknown fields/statuses, mismatched identities, changed version/expiry, partial or multiple frames, invalid UTF-8, excess bytes, timeout, redirect, or transport failure make the record unavailable and mint no handle. The adapter's own schema, description, endpoint, command, headers, diagnostics, and upstream identifiers never enter the model context or receipt. -## GitLab discussions, handles, and completeness +## GitLab context, handles, and completeness The GitLab owner reads the exact validated project and merge request with bounded pagination. It does not fetch another page after the protected thread bound is filled; a provider-declared next page becomes a visible omission. It reads the ordered snapshot twice and admits records only when the identity and digest match. Reordering, edits, changed pages, invalid identity/classification, unsupported notes, or limit exhaustion remains visible as `mutated`, `partial`, or `unavailable`; it is never treated as proof that no record exists. A remediation bundle begins only at a toolkit-owned root whose author ID equals the live authenticated bot and whose body contains a valid toolkit marker and finding fingerprint. That root and its selected human/automation/system replies become one opaque record. Recognized slash or live-username mention commands are lifecycle control and are excluded from model text. A verified root selected as remediation is not duplicated in generic discussions, and none of its replies participates in external-reference discovery. Remediation text can locate a claim for re-checking against current code and tests; it cannot change severity, prove a fix, suppress or resolve a finding, issue a command, or authorize approval. +For CI outcomes, the GitLab edge queries bounded project pipelines by the exact reviewed SHA and bounded jobs by pipeline, includes retried jobs so duplicate exact names cannot masquerade as a unique result, and reads the normalized snapshot twice. Different-revision data fails closed; mutation, pagination loss, duplicate retries, stale completion, missing selected checks, malformed provider fields, and ambiguous equally recent results remain partial, mutated, or unavailable. A record exposes only the protected check name/path prefixes, `reviewed_head`, closed status, required/advisory classification, current/same-revision-pipeline provenance, and completion time. The common broker/store does not import GitLab. + The private `ocr.context-store/v2` is independent from the repository evidence store and its budgets. It is atomically written owner-only and hostile-read before OCR. Only a fully normalized and DLP-checked committed record receives a `ctx1_` handle containing 32 random bytes encoded as unpadded base64url. The private mapping binds run, policy digest, adapter, tenant, canonical object, resource class, projections, version/digest, and expiry. It is not an encoded upstream ID. In `off` and `metadata`, the built-in MCP exposes the fixed evidence tools `ocr_toolkit_evidence`, `ocr_toolkit_evidence_search`, and `ocr_toolkit_evidence_coverage`. In `enriched`, it additionally exposes exactly `context_list` and `context_get`: -- `context_list` accepts only optional `resource_class`, admitted `source`, `page_size` from 1 through 20, and an opaque cursor. Resource classes are `issue`, `document`, and `remediation_thread`. It returns safe descriptors, minted handles, expiry, mutability, per-source completeness, and a next cursor. -- `context_get` accepts exactly one listed `ctx1_` handle and returns only the record's protected `model` projection. A remediation record contains one DLP-checked root, safe anchor state, ordered pseudonymized replies, closed completeness, and reply/resolved/outdated counts. It never contains a raw provider identity or object. +- `context_list` accepts only optional `resource_class`, admitted `source`, `page_size` from 1 through 20, and an opaque cursor. Resource classes are `issue`, `document`, `remediation_thread`, and `ci_outcome`. It returns safe descriptors, minted handles, expiry, mutability, per-source completeness, and a next cursor. +- `context_get` accepts exactly one listed `ctx1_` handle and returns only the record's protected `model` projection. A remediation record contains one DLP-checked root, safe anchor state, ordered pseudonymized replies, closed completeness, and reply/resolved/outdated counts. A CI record contains only closed same-revision status/provenance plus protected declared scope. Neither contains a raw provider identity or object. Both tools read the already committed local store. They have no network, subprocess, search, arbitrary URL/ID, traversal, or write path. Invalid, expired, wrong-run, wrong-policy, missing, or non-minted handles fail before record access. OCR must still record at least one `ocr_toolkit_evidence(action=summary)` call; context calls do not satisfy that requirement. diff --git a/docs/security.md b/docs/security.md index d2c5ea8..4dbebdd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -64,7 +64,8 @@ Receipt v6 records the bounded configured capability inventory and positive call ## Preserved current safety properties - Repository reads are bounded, rooted, symlink-aware, immutable-object reads that exclude common dependency/build trees and never execute repository content. -- Review context uses a closed `off|metadata|enriched` selector. `off` retains only validated source/protected-target/author identities; `metadata` admits bounded MR fields; `enriched` requires the immutable protected policy and admits only stable bounded discussion/adapter projections. Source policy, unknown fields/classes, raw display identities, arbitrary URLs/IDs, tokens, and ambient environment values cannot expand it. +- Review context uses a closed `off|metadata|enriched` selector. `off` retains only validated source/protected-target/author identities; `metadata` admits bounded MR fields; `enriched` requires the immutable protected policy and admits only stable bounded discussion, CI-outcome, and adapter projections. Source policy, unknown fields/classes, raw display identities, arbitrary URLs/IDs, tokens, and ambient environment values cannot expand it. +- Protected CI outcome context is acquired only from authenticated GitLab project APIs, bound to the exact reviewed SHA, normalized twice, and scoped by exact check names/path prefixes from protected policy v3. Logs, artifacts, URLs, raw IDs, users, runners, variables, provider payloads, and repository-authored status text never enter the model/store/receipt. Passing CI is contextual execution evidence only; it cannot suppress findings, prove unrelated absence, alter severity/lifecycle, or authorize approval. - Generated Markdown neutralizes controls and GitLab quick actions. Actionable suggestions require exact reviewed-head proof; unverifiable replacements retain prose only. - Result and provider reads have byte limits; notes enforce character and UTF-8 byte limits. Position-bearing inline creates reserve independent unguessable markers, classify closed outcomes, and perform at most one complete author-bound reconciliation read with no retry. - Publication DLP admits ordinary horizontal tab only in `existing_code` and `suggestion_code`. The unchanged code value is retained only after it passes the existing size, secret, PII, forbidden-value, and laundering checks; tabs elsewhere and vertical tab, form feed, NUL, bidi/format controls, and other unsupported controls remain blocking. diff --git a/examples/gitlab/context/README.md b/examples/gitlab/context/README.md index b19ef4d..db70e81 100644 --- a/examples/gitlab/context/README.md +++ b/examples/gitlab/context/README.md @@ -4,6 +4,7 @@ These files contain no live hostname or credential. Copy exactly one policy to ` - `policy-discussions.json` selects generic discussions and verified remediation threads without requiring an external adapter. - `policy-adapters.json` adds a required `tracker` issue source and is paired with exactly one operator-side adapter JSON supplied through `OCR_REVIEW_CONTEXT_ADAPTERS_JSON`. +- `policy-ci-outcomes.json` selects exact same-revision GitLab checks and binds each check to path prefixes declared by protected policy v3. - `adapters-stdio.json` shows an operator-managed local proxy. Replace the example absolute command and provide `TRACKER_CONTEXT_TOKEN` in the runner environment. - `adapters-remote.json` shows the same tracker adapter behind HTTPS. Replace the `.invalid` endpoint and provide `TRACKER_CONTEXT_AUTHORIZATION` in the runner environment. @@ -11,3 +12,5 @@ These files contain no live hostname or credential. Copy exactly one policy to ` The protected policy can narrow these allowlists but cannot add a command, endpoint, tenant, resource class, or credential. Both proxy variants must implement `ocr.context-adapter-request/v1` and `ocr.context-adapter-response/v1`, including object-level authorization in the atomic `authorize_and_resolve` operation. See [Bounded review context](../../../docs/review-context.md) for the exact contract and deployment boundary. For ordinary conversations only, remove `remediation_threads` from `policy-discussions.json`. For verified OCR-rooted remediation history only, remove `forge_discussions`. Keep both for both sources; the toolkit prevents a verified remediation root from appearing twice. See [Choosing a discussion policy](../../../docs/review-context.md#choosing-a-discussion-policy) before changing `required`, account classes, resolved/outdated selection, or approval posture. + +CI outcomes are execution context, not clean-review or approval authority. Keep `required: false` unless losing the selected provider snapshot must block approval, and list only stable job names whose declared prefixes accurately describe what the job executes. The toolkit never downloads logs or artifacts. diff --git a/examples/gitlab/context/policy-ci-outcomes.json b/examples/gitlab/context/policy-ci-outcomes.json new file mode 100644 index 0000000..5a5595b --- /dev/null +++ b/examples/gitlab/context/policy-ci-outcomes.json @@ -0,0 +1,25 @@ +{ + "schema_version": "ocr.review-context-policy/v3", + "budgets": { + "max_records": 16, + "max_chars": 12000, + "max_bytes": 24000, + "max_lines": 300, + "timeout_ms": 15000 + }, + "ci_outcomes": { + "required": false, + "max_age_seconds": 86400, + "checks": [ + { + "name": "functional-tests", + "path_prefixes": ["src/", "tests/"] + }, + { + "name": "package", + "path_prefixes": ["pyproject.toml", "src/"] + } + ] + }, + "references": [] +} diff --git a/src/ocr_toolkit/context/ci_outcomes.py b/src/ocr_toolkit/context/ci_outcomes.py new file mode 100644 index 0000000..006f0fc --- /dev/null +++ b/src/ocr_toolkit/context/ci_outcomes.py @@ -0,0 +1,141 @@ +"""Project provider-neutral same-revision CI outcomes into private context.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass + +from ocr_toolkit.context.contracts import CIOutcomePolicy, TextBudgets +from ocr_toolkit.context.dlp import check_text +from ocr_toolkit.context.store import PendingContextRecord + +CI_OUTCOME_STATUSES = frozenset({"passed", "failed", "skipped", "canceled", "unknown"}) +CI_OUTCOME_REQUIREMENTS = frozenset({"required", "advisory"}) +CI_OUTCOME_ORIGINS = frozenset({"current_pipeline", "same_revision_pipeline"}) +CI_IDENTITY_BUDGETS = TextBudgets(max_chars=256, max_bytes=1_024, max_lines=1) + + +@dataclass(frozen=True, slots=True) +class CIOutcome: + """Carry one normalized provider outcome without raw forge identities.""" + + check: str + status: str + requirement: str + path_prefixes: tuple[str, ...] + origin: str + completed_at: int + version: str + digest: str + + +@dataclass(frozen=True, slots=True) +class CIOutcomeSnapshot: + """Carry a stable provider snapshot and its closed completeness state.""" + + state: str + records: tuple[CIOutcome, ...] + omitted: int + invalid: int + + +def prepare_ci_outcome_records( + snapshot: CIOutcomeSnapshot, + *, + policy: CIOutcomePolicy, + now: int, + forbidden: tuple[str, ...] = (), +) -> tuple[PendingContextRecord, ...]: + """DLP-check one closed snapshot and build exact immutable store records.""" + + if ( + snapshot.state not in {"complete", "partial", "mutated"} + or not isinstance(snapshot.omitted, int) + or isinstance(snapshot.omitted, bool) + or snapshot.omitted < 0 + or not isinstance(snapshot.invalid, int) + or isinstance(snapshot.invalid, bool) + or snapshot.invalid < 0 + or (snapshot.state == "complete" and (snapshot.omitted or snapshot.invalid)) + or (snapshot.state == "mutated" and snapshot.records) + ): + return () + if snapshot.state == "mutated": + return () + authorized = {check.name: check.path_prefixes for check in policy.checks} + pending: list[PendingContextRecord] = [] + for record in snapshot.records: + normalized_identity = { + "check": record.check, + "status": record.status, + "requirement": record.requirement, + "path_prefixes": list(record.path_prefixes), + "origin": record.origin, + "completed_at": record.completed_at, + } + expected_digest = hashlib.sha256( + json.dumps(normalized_identity, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + if ( + record.check not in authorized + or record.path_prefixes != authorized[record.check] + or record.status not in CI_OUTCOME_STATUSES + or record.requirement not in CI_OUTCOME_REQUIREMENTS + or record.origin not in CI_OUTCOME_ORIGINS + or not isinstance(record.completed_at, int) + or isinstance(record.completed_at, bool) + or record.completed_at < 0 + or record.completed_at > now + 300 + or now - record.completed_at > policy.max_age_seconds + or record.version != str(record.completed_at) + or len(record.digest) != 64 + or any(character not in "0123456789abcdef" for character in record.digest) + or record.digest != expected_digest + ): + continue + values = (record.check, *record.path_prefixes) + checked = [ + check_text(value, budgets=CI_IDENTITY_BUDGETS, forbidden=forbidden) for value in values + ] + if any( + not value.admitted or value.text != original for value, original in zip(checked, values) + ): + continue + model = { + "check": record.check, + "revision": "reviewed_head", + "status": record.status, + "requirement": record.requirement, + "scope": {"mode": "declared", "path_prefixes": list(record.path_prefixes)}, + "origin": record.origin, + "completed_at": record.completed_at, + } + canonical = json.dumps(model, sort_keys=True, separators=(",", ":")) + pending.append( + PendingContextRecord( + source="forge:ci_outcomes", + adapter="gitlab", + tenant="project", + canonical_object=hashlib.sha256( + f"ci-outcome:{record.check}:{record.digest}".encode() + ).hexdigest(), + resource_class="ci_outcome", + descriptor="ci_outcome", + projections={ + "model": {"descriptor": "ci_outcome", "ci_outcome": model}, + "publish": {"descriptor": "ci_outcome"}, + "retain": { + "state": record.status, + "digest": hashlib.sha256(canonical.encode()).hexdigest(), + "version": record.version, + "expiry": now + 3_600, + }, + }, + version=record.version, + digest=hashlib.sha256(canonical.encode()).hexdigest(), + mutable=False, + expiry=now + 3_600, + ) + ) + return tuple(pending) diff --git a/src/ocr_toolkit/context/contracts.py b/src/ocr_toolkit/context/contracts.py index 3dcf3eb..9592b38 100644 --- a/src/ocr_toolkit/context/contracts.py +++ b/src/ocr_toolkit/context/contracts.py @@ -6,14 +6,17 @@ POLICY_SCHEMA_V1 = "ocr.review-context-policy/v1" POLICY_SCHEMA_V2 = "ocr.review-context-policy/v2" -POLICY_SCHEMAS = frozenset({POLICY_SCHEMA_V1, POLICY_SCHEMA_V2}) -POLICY_SCHEMA = POLICY_SCHEMA_V2 +POLICY_SCHEMA_V3 = "ocr.review-context-policy/v3" +POLICY_SCHEMAS = frozenset({POLICY_SCHEMA_V1, POLICY_SCHEMA_V2, POLICY_SCHEMA_V3}) +POLICY_SCHEMA = POLICY_SCHEMA_V3 STORE_SCHEMA = "ocr.context-store/v2" REQUEST_SCHEMA = "ocr.context-adapter-request/v1" RESPONSE_SCHEMA = "ocr.context-adapter-response/v1" ACCOUNT_CLASSES = frozenset({"user", "automation", "system", "toolkit_bot"}) REFERENCE_RESOURCE_CLASSES = frozenset({"issue", "document"}) -STORE_RESOURCE_CLASSES = frozenset({*REFERENCE_RESOURCE_CLASSES, "remediation_thread"}) +STORE_RESOURCE_CLASSES = frozenset( + {*REFERENCE_RESOURCE_CLASSES, "remediation_thread", "ci_outcome"} +) PROJECTION_FIELDS = frozenset( { "descriptor", @@ -33,7 +36,10 @@ } ) REMEDIATION_MODEL_FIELD = "remediation_thread" -STORE_PROJECTION_FIELDS = frozenset({*PROJECTION_FIELDS, REMEDIATION_MODEL_FIELD}) +CI_OUTCOME_MODEL_FIELD = "ci_outcome" +STORE_PROJECTION_FIELDS = frozenset( + {*PROJECTION_FIELDS, REMEDIATION_MODEL_FIELD, CI_OUTCOME_MODEL_FIELD} +) RETENTION_FIELDS = frozenset({"state", "count", "digest", "version", "expiry"}) @@ -99,6 +105,23 @@ class RemediationThreadPolicy: budgets: TextBudgets +@dataclass(frozen=True, slots=True) +class CIOutcomeCheckPolicy: + """Authorize one exact forge check and its protected path scope.""" + + name: str + path_prefixes: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class CIOutcomePolicy: + """Select bounded same-revision forge outcomes under protected policy.""" + + required: bool + max_age_seconds: int + checks: tuple[CIOutcomeCheckPolicy, ...] + + @dataclass(frozen=True, slots=True) class RecognizerPolicy: """Hold one fixed toolkit-authored candidate grammar.""" @@ -132,5 +155,6 @@ class ContextPolicy: budgets: AggregateBudgets forge_discussions: DiscussionPolicy | None remediation_threads: RemediationThreadPolicy | None + ci_outcomes: CIOutcomePolicy | None references: tuple[ReferencePolicy, ...] digest: str diff --git a/src/ocr_toolkit/context/policy.py b/src/ocr_toolkit/context/policy.py index b5015cc..082f1c3 100644 --- a/src/ocr_toolkit/context/policy.py +++ b/src/ocr_toolkit/context/policy.py @@ -5,18 +5,23 @@ import hashlib import json import re +import unicodedata from collections.abc import Callable, Mapping +from pathlib import PurePosixPath from typing import Any from urllib.parse import urlsplit from ocr_toolkit.context.contracts import ( ACCOUNT_CLASSES, POLICY_SCHEMA_V1, + POLICY_SCHEMA_V2, POLICY_SCHEMAS, PROJECTION_FIELDS, REFERENCE_RESOURCE_CLASSES, RETENTION_FIELDS, AggregateBudgets, + CIOutcomeCheckPolicy, + CIOutcomePolicy, ContextContractError, ContextPolicy, ContextProjections, @@ -32,6 +37,7 @@ MAX_REFERENCES = 16 NAME_RE = re.compile(r"[a-z][a-z0-9_-]{0,63}\Z") ISSUE_PREFIX_RE = re.compile(r"[A-Z][A-Z0-9]{0,15}\Z") +MAX_CI_CHECKS = 32 def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: @@ -208,6 +214,79 @@ def _remediation_threads(value: object) -> RemediationThreadPolicy: ) +def _ci_path_prefix(value: object) -> str: + """Validate one protected repository-relative POSIX path prefix.""" + + if ( + not isinstance(value, str) + or not 1 <= len(value) <= 256 + or len(value.encode("utf-8")) > 1_024 + or "\\" in value + or "://" in value + or any(unicodedata.category(character) in {"Cc", "Cf"} for character in value) + ): + raise ContextContractError("ci_outcomes path prefix is invalid") + trailing = value.endswith("/") + path = PurePosixPath(value) + parts = value.rstrip("/").split("/") + if path.is_absolute() or any(part in {"", ".", ".."} for part in parts): + raise ContextContractError("ci_outcomes path prefix is invalid") + normalized = path.as_posix() + return f"{normalized}/" if trailing else normalized + + +def _ci_outcomes(value: object) -> CIOutcomePolicy: + """Parse exact check names and path scopes from protected policy v3.""" + + item = _object( + value, + keys=frozenset({"required", "max_age_seconds", "checks"}), + label="ci_outcomes", + ) + checks = item.get("checks") + if not isinstance(checks, list) or not checks or len(checks) > MAX_CI_CHECKS: + raise ContextContractError("ci_outcomes.checks is invalid") + parsed: list[CIOutcomeCheckPolicy] = [] + for raw in checks: + check = _object( + raw, + keys=frozenset({"name", "path_prefixes"}), + label="ci_outcomes check", + ) + name = check.get("name") + prefixes = check.get("path_prefixes") + if ( + not isinstance(name, str) + or not 1 <= len(name) <= 128 + or len(name.encode("utf-8")) > 512 + or "://" in name + or any(unicodedata.category(character) in {"Cc", "Cf"} for character in name) + or not isinstance(prefixes, list) + or not prefixes + or len(prefixes) > 32 + ): + raise ContextContractError("ci_outcomes check is invalid") + normalized = tuple(_ci_path_prefix(prefix) for prefix in prefixes) + if list(normalized) != sorted(set(normalized)): + raise ContextContractError("ci_outcomes path prefixes must be sorted and unique") + parsed.append(CIOutcomeCheckPolicy(name=name, path_prefixes=normalized)) + names = [check.name for check in parsed] + if names != sorted(set(names)): + raise ContextContractError("ci_outcomes check names must be sorted and unique") + required = item.get("required", False) + max_age_seconds = item.get("max_age_seconds", 86_400) + return CIOutcomePolicy( + required=_boolean(required, label="ci_outcomes.required"), + max_age_seconds=_integer( + max_age_seconds, + minimum=60, + maximum=604_800, + label="ci_outcomes.max_age_seconds", + ), + checks=tuple(parsed), + ) + + def _recognizer(value: object, *, resource_class: str) -> RecognizerPolicy: item = _object( value, @@ -328,6 +407,7 @@ def parse_policy(raw: bytes) -> ContextPolicy: "budgets", "forge_discussions", "remediation_threads", + "ci_outcomes", "references", } ), @@ -338,6 +418,8 @@ def parse_policy(raw: bytes) -> ContextPolicy: raise ContextContractError("context policy schema version is unsupported") if schema_version == POLICY_SCHEMA_V1 and "remediation_threads" in root: raise ContextContractError("context policy v1 cannot select remediation threads") + if schema_version in {POLICY_SCHEMA_V1, POLICY_SCHEMA_V2} and "ci_outcomes" in root: + raise ContextContractError("context policy v1/v2 cannot select CI outcomes") budgets_value = _object( root.get("budgets"), keys=frozenset({"max_records", "max_chars", "max_bytes", "max_lines", "timeout_ms"}), @@ -364,6 +446,7 @@ def parse_policy(raw: bytes) -> ContextPolicy: remediation = ( _remediation_threads(root["remediation_threads"]) if "remediation_threads" in root else None ) + ci_outcomes = _ci_outcomes(root["ci_outcomes"]) if "ci_outcomes" in root else None references_value = root.get("references", []) if ( not isinstance(references_value, list) @@ -375,7 +458,7 @@ def parse_policy(raw: bytes) -> ContextPolicy: identities = [(item.adapter, item.tenant, item.resource_class) for item in references] if len(identities) != len(set(identities)): raise ContextContractError("context policy references collide") - if discussion is None and remediation is None and not references: + if discussion is None and remediation is None and ci_outcomes is None and not references: raise ContextContractError("context policy must select at least one source") canonical = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False @@ -385,6 +468,7 @@ def parse_policy(raw: bytes) -> ContextPolicy: budgets=budgets, forge_discussions=discussion, remediation_threads=remediation, + ci_outcomes=ci_outcomes, references=references, digest=hashlib.sha256(canonical).hexdigest(), ) diff --git a/src/ocr_toolkit/context/store.py b/src/ocr_toolkit/context/store.py index 0ccbea6..b865b77 100644 --- a/src/ocr_toolkit/context/store.py +++ b/src/ocr_toolkit/context/store.py @@ -17,6 +17,7 @@ from ocr_toolkit.context.contracts import ( ACCOUNT_CLASSES, + CI_OUTCOME_MODEL_FIELD, REMEDIATION_MODEL_FIELD, RETENTION_FIELDS, STORE_PROJECTION_FIELDS, @@ -42,6 +43,9 @@ REMEDIATION_ANCHOR_STATES = frozenset({"current", "outdated", "unpositioned"}) REMEDIATION_COMPLETENESS = frozenset({"complete", "partial"}) MAX_REMEDIATION_REPLIES = 100 +CI_OUTCOME_STATUSES = frozenset({"passed", "failed", "skipped", "canceled", "unknown"}) +CI_OUTCOME_REQUIREMENTS = frozenset({"required", "advisory"}) +CI_OUTCOME_ORIGINS = frozenset({"current_pipeline", "same_revision_pipeline"}) class ContextStoreError(ValueError): @@ -252,11 +256,79 @@ def _remediation_projection(value: object) -> Mapping[str, object]: } +def _ci_outcome_projection(value: object) -> Mapping[str, object]: + """Hostile-read the fixed model-only same-revision CI projection.""" + + item = _mapping(value, "CI outcome projection") + if set(item) != { + "check", + "revision", + "status", + "requirement", + "scope", + "origin", + "completed_at", + }: + raise ContextStoreError("CI outcome projection fields are invalid") + check = normalize_text(item.get("check")) + if ( + check != item.get("check") + or not isinstance(check, str) + or not 1 <= len(check) <= 128 + or not check_text(check, budgets=TextBudgets(128, 512, 1)).admitted + ): + raise ContextStoreError("CI outcome check is invalid") + if item.get("revision") != "reviewed_head": + raise ContextStoreError("CI outcome revision is invalid") + status = item.get("status") + requirement = item.get("requirement") + origin = item.get("origin") + if ( + status not in CI_OUTCOME_STATUSES + or requirement not in CI_OUTCOME_REQUIREMENTS + or origin not in CI_OUTCOME_ORIGINS + ): + raise ContextStoreError("CI outcome state is invalid") + completed_at = item.get("completed_at") + if not isinstance(completed_at, int) or isinstance(completed_at, bool) or completed_at < 0: + raise ContextStoreError("CI outcome completion time is invalid") + scope = _mapping(item.get("scope"), "CI outcome scope") + if set(scope) != {"mode", "path_prefixes"} or scope.get("mode") != "declared": + raise ContextStoreError("CI outcome scope is invalid") + prefixes = scope.get("path_prefixes") + if not isinstance(prefixes, list) or not prefixes or len(prefixes) > 32: + raise ContextStoreError("CI outcome path prefixes are invalid") + normalized: list[str] = [] + for prefix in prefixes: + value = normalize_text(prefix) + if ( + value != prefix + or not isinstance(value, str) + or not 1 <= len(value) <= 256 + or not check_text(value, budgets=TextBudgets(256, 1_024, 1)).admitted + ): + raise ContextStoreError("CI outcome path prefix is invalid") + normalized.append(value) + if normalized != sorted(set(normalized)): + raise ContextStoreError("CI outcome path prefixes are not canonical") + return { + "check": check, + "revision": "reviewed_head", + "status": status, + "requirement": requirement, + "scope": {"mode": "declared", "path_prefixes": normalized}, + "origin": origin, + "completed_at": completed_at, + } + + def _projection_value(field: str, value: object) -> object: """Hostile-read one generic projection value without policy reinterpretation.""" if field == REMEDIATION_MODEL_FIELD: return _remediation_projection(value) + if field == CI_OUTCOME_MODEL_FIELD: + return _ci_outcome_projection(value) if field == "text": checked = check_text(value, budgets=STORE_TEXT_BUDGETS) if not checked.admitted or checked.text != value: @@ -417,6 +489,7 @@ def _record(value: object) -> ContextRecord: field: _projection_value(field, value) for field, value in mapped.items() } remediation_model = REMEDIATION_MODEL_FIELD in normalized_projections["model"] + ci_outcome_model = CI_OUTCOME_MODEL_FIELD in normalized_projections["model"] if ( REMEDIATION_MODEL_FIELD in normalized_projections["publish"] or REMEDIATION_MODEL_FIELD in normalized_projections["retain"] @@ -429,8 +502,23 @@ def _record(value: object) -> ContextRecord: ) ) or (strings["resource_class"] != "remediation_thread" and remediation_model) + or CI_OUTCOME_MODEL_FIELD in normalized_projections["publish"] + or CI_OUTCOME_MODEL_FIELD in normalized_projections["retain"] + or ( + strings["resource_class"] == "ci_outcome" + and ( + strings["descriptor"] != "ci_outcome" + or not ci_outcome_model + or set(normalized_projections["model"]) != {"descriptor", CI_OUTCOME_MODEL_FIELD} + or mutable is not False + or normalized_projections["model"][CI_OUTCOME_MODEL_FIELD]["completed_at"] > expiry + or strings["version"] + != str(normalized_projections["model"][CI_OUTCOME_MODEL_FIELD]["completed_at"]) + ) + ) + or (strings["resource_class"] != "ci_outcome" and ci_outcome_model) ): - raise ContextStoreError("remediation projection placement is invalid") + raise ContextStoreError("special context projection placement is invalid") for projection in normalized_projections.values(): if ( ("descriptor" in projection and projection["descriptor"] != strings["descriptor"]) diff --git a/src/ocr_toolkit/evidence/project.py b/src/ocr_toolkit/evidence/project.py index 3ad3787..f85f7cd 100644 --- a/src/ocr_toolkit/evidence/project.py +++ b/src/ocr_toolkit/evidence/project.py @@ -79,6 +79,7 @@ def render_bootstrap( store: EvidenceStore, *, capabilities: Sequence[CapabilityView] = (), + context_hints: Mapping[str, int] | None = None, max_chars: int = DEFAULT_BOOTSTRAP_MAX_CHARS, max_bytes: int = DEFAULT_BOOTSTRAP_MAX_BYTES, ) -> str: @@ -180,6 +181,15 @@ def render_bootstrap( ), ) ) + if context_hints: + hints = ", ".join( + f"{name}={count}" for name, count in sorted(context_hints.items()) if count > 0 + ) + if hints: + lines.append( + "Protected same-revision CI outcomes: " + f"{hints}; use `context_list(resource_class=ci_outcome)` for records." + ) lines.append("Only applicable `complete` coverage proves absence; otherwise it is unknown.") lines.extend( ( diff --git a/src/ocr_toolkit/providers/gitlab_ci.py b/src/ocr_toolkit/providers/gitlab_ci.py new file mode 100644 index 0000000..6e85b97 --- /dev/null +++ b/src/ocr_toolkit/providers/gitlab_ci.py @@ -0,0 +1,286 @@ +"""Acquire stable bounded same-revision GitLab CI outcomes without logs.""" + +from __future__ import annotations + +import hashlib +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Mapping +from typing import Any + +from ocr_toolkit.context.ci_outcomes import CIOutcome, CIOutcomeSnapshot +from ocr_toolkit.context.contracts import CIOutcomePolicy +from ocr_toolkit.providers.gitlab import GitLabProviderError, _api_root, _numeric_identifier +from ocr_toolkit.providers.gitlab_context import SHA_RE, timestamp + +MAX_PAGE_BYTES = 512 * 1024 +MAX_PIPELINES = 20 +MAX_JOBS_PER_PIPELINE = 100 + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Prevent provider credentials from crossing redirects.""" + + def redirect_request(self, *_args: Any, **_kwargs: Any) -> None: + return None + + +def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise GitLabProviderError("GitLab CI response contains a duplicate JSON key") + result[key] = value + return result + + +def _read_page(url: str, token: str, *, deadline: float) -> tuple[object, bool]: + """Read one bounded JSON page and report whether another page exists.""" + + if not token or "\r" in token or "\n" in token or len(token) > 16_384: + raise GitLabProviderError("GITLAB_API_TOKEN is missing or malformed") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise GitLabProviderError("GitLab CI outcome acquisition timed out") + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "PRIVATE-TOKEN": token, + "User-Agent": "open-code-review-toolkit-ci-outcomes/1", + }, + method="GET", + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirectHandler) + try: + with opener.open(request, timeout=remaining) as response: + if response.headers.get_content_type() != "application/json": + raise GitLabProviderError("GitLab CI outcome content type is invalid") + raw = response.read(MAX_PAGE_BYTES + 1) + if len(raw) > MAX_PAGE_BYTES: + raise GitLabProviderError("GitLab CI outcome page exceeds its byte limit") + next_page = response.headers.get("X-Next-Page", "") + except urllib.error.HTTPError as exc: + raise GitLabProviderError("GitLab CI outcomes are unavailable") from exc + except (urllib.error.URLError, OSError, TimeoutError) as exc: + raise GitLabProviderError("GitLab CI outcome request failed") from exc + if next_page and (not next_page.isascii() or not next_page.isdecimal() or len(next_page) > 4): + raise GitLabProviderError("GitLab CI outcome pagination is invalid") + try: + return ( + json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=_pairs), + bool(next_page), + ) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: + raise GitLabProviderError("GitLab CI outcomes are not valid bounded JSON") from exc + + +def _positive_id(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise GitLabProviderError("GitLab CI outcome identity is invalid") + return value + + +def _bounded_name(value: object) -> str: + if ( + not isinstance(value, str) + or not 1 <= len(value) <= 128 + or len(value.encode("utf-8")) > 512 + or any(character == "\x7f" or ord(character) < 32 for character in value) + ): + raise GitLabProviderError("GitLab CI check name is invalid") + return value + + +def _raw_snapshot( + environment: Mapping[str, str], + *, + project_id: str, + source_sha: str, + policy: CIOutcomePolicy, + now: int, + deadline: float, +) -> tuple[CIOutcomeSnapshot, str]: + """Normalize one bounded provider read without retaining raw identities.""" + + token = environment.get("GITLAB_API_TOKEN", "").strip() + api_root = _api_root(environment) + project = urllib.parse.quote(project_id, safe="") + query = urllib.parse.urlencode( + { + "sha": source_sha, + "order_by": "updated_at", + "sort": "desc", + "per_page": str(MAX_PIPELINES), + "page": "1", + } + ) + pipelines, pipeline_more = _read_page( + f"{api_root}/projects/{project}/pipelines?{query}", token, deadline=deadline + ) + if not isinstance(pipelines, list) or len(pipelines) > MAX_PIPELINES: + raise GitLabProviderError("GitLab CI pipeline list is invalid") + requested = {check.name: check.path_prefixes for check in policy.checks} + candidates: dict[str, list[tuple[int, int, str, bool]]] = {name: [] for name in requested} + invalid_names: set[str] = set() + structural: list[object] = [] + current_pipeline = int(_numeric_identifier(environment, "CI_PIPELINE_ID")) + omitted = int(pipeline_more) + invalid = 0 + for pipeline in pipelines: + if not isinstance(pipeline, Mapping): + raise GitLabProviderError("GitLab CI pipeline is invalid") + pipeline_id = _positive_id(pipeline.get("id")) + pipeline_sha = pipeline.get("sha") + if not isinstance(pipeline_sha, str) or SHA_RE.fullmatch(pipeline_sha) is None: + raise GitLabProviderError("GitLab CI pipeline revision is invalid") + if pipeline_sha != source_sha: + raise GitLabProviderError("GitLab CI pipeline revision does not match reviewed head") + jobs, jobs_more = _read_page( + f"{api_root}/projects/{project}/pipelines/{pipeline_id}/jobs" + f"?include_retried=true&per_page={MAX_JOBS_PER_PIPELINE}&page=1", + token, + deadline=deadline, + ) + if not isinstance(jobs, list) or len(jobs) > MAX_JOBS_PER_PIPELINE: + raise GitLabProviderError("GitLab CI job list is invalid") + omitted += int(jobs_more) + per_pipeline: set[str] = set() + for job in jobs: + if not isinstance(job, Mapping): + raise GitLabProviderError("GitLab CI job is invalid") + name = _bounded_name(job.get("name")) + if name not in requested: + continue + nested = job.get("pipeline") + if ( + not isinstance(nested, Mapping) + or _positive_id(nested.get("id")) != pipeline_id + or nested.get("sha") != source_sha + ): + invalid_names.add(name) + continue + if name in per_pipeline: + invalid_names.add(name) + continue + per_pipeline.add(name) + status = job.get("status") + allow_failure = job.get("allow_failure") + completed_at = timestamp(job.get("finished_at")) + if ( + not isinstance(status, str) + or not status + or len(status) > 64 + or not status.isascii() + or not isinstance(allow_failure, bool) + or completed_at is None + or completed_at < 0 + or completed_at > now + 300 + ): + invalid_names.add(name) + continue + candidates[name].append((completed_at, pipeline_id, status, allow_failure)) + structural.append([pipeline_id, name, status, allow_failure, completed_at]) + records: list[CIOutcome] = [] + for name, path_prefixes in requested.items(): + selected = sorted(candidates[name], reverse=True) + if name in invalid_names or not selected: + invalid += 1 + continue + newest = selected[0] + if len(selected) > 1 and selected[1][0] == newest[0]: + invalid += 1 + continue + completed_at, pipeline_id, raw_status, allow_failure = newest + if now - completed_at > policy.max_age_seconds: + invalid += 1 + continue + status = { + "success": "passed", + "failed": "failed", + "canceled": "canceled", + "skipped": "skipped", + }.get(raw_status, "unknown") + normalized = { + "check": name, + "status": status, + "requirement": "advisory" if allow_failure else "required", + "path_prefixes": list(path_prefixes), + "origin": ( + "current_pipeline" if pipeline_id == current_pipeline else "same_revision_pipeline" + ), + "completed_at": completed_at, + } + digest = hashlib.sha256( + json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + records.append( + CIOutcome( + version=str(completed_at), + digest=digest, + **normalized, # type: ignore[arg-type] + ) + ) + omitted += len(requested) - len(records) - invalid + state = "complete" if not omitted and not invalid else "partial" + snapshot_digest = hashlib.sha256( + json.dumps( + { + "pipelines": structural, + "records": [record.digest for record in records], + "state": state, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + return ( + CIOutcomeSnapshot( + state=state, + records=tuple(sorted(records, key=lambda record: record.check)), + omitted=omitted, + invalid=invalid, + ), + snapshot_digest, + ) + + +def acquire_gitlab_ci_outcomes( + environment: Mapping[str, str], + *, + project_id: str, + source_sha: str, + policy: CIOutcomePolicy, + now: int, + deadline: float | None = None, +) -> CIOutcomeSnapshot: + """Return one twice-read exact-revision snapshot or a mutated state.""" + + if ( + project_id != _numeric_identifier(environment, "CI_PROJECT_ID") + or SHA_RE.fullmatch(source_sha) is None + ): + raise GitLabProviderError("GitLab CI outcome identity is invalid") + acquisition_deadline = time.monotonic() + 30.0 if deadline is None else deadline + _first, first_digest = _raw_snapshot( + environment, + project_id=project_id, + source_sha=source_sha, + policy=policy, + now=now, + deadline=acquisition_deadline, + ) + second, second_digest = _raw_snapshot( + environment, + project_id=project_id, + source_sha=source_sha, + policy=policy, + now=now, + deadline=acquisition_deadline, + ) + if first_digest != second_digest: + return CIOutcomeSnapshot(state="mutated", records=(), omitted=0, invalid=1) + return second diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index bebb4f9..ac807f7 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -38,7 +38,13 @@ prepare_discussion_records, prepare_remediation_records, ) -from ocr_toolkit.context.contracts import ContextContractError, ContextPolicy, TextBudgets +from ocr_toolkit.context.ci_outcomes import prepare_ci_outcome_records +from ocr_toolkit.context.contracts import ( + CI_OUTCOME_MODEL_FIELD, + ContextContractError, + ContextPolicy, + TextBudgets, +) from ocr_toolkit.context.dlp import ForbiddenMatcher, check_text from ocr_toolkit.context.policy import load_protected_policy from ocr_toolkit.context.recognizers import recognize @@ -116,6 +122,7 @@ invocation_identifiers, is_merge_request_environment, ) +from ocr_toolkit.providers.gitlab_ci import acquire_gitlab_ci_outcomes from ocr_toolkit.providers.gitlab_discussions import acquire_gitlab_context from ocr_toolkit.result_contract import OcrResultContractError, parse_result_outcome from ocr_toolkit.result_usage import normalize_token_usage, token_usage_mapping @@ -287,6 +294,7 @@ class EnrichmentReceipt: required_degraded: bool mutable_admitted: bool forbidden_publication: tuple[str, ...] + bootstrap_hints: dict[str, int] def _write_isolated_runtime_config() -> None: @@ -1589,6 +1597,17 @@ def _bounded_combined_records( reply_text = reply.get("text") if isinstance(reply, dict) else None if isinstance(reply_text, str): texts.append(reply_text) + ci_outcome = model.get(CI_OUTCOME_MODEL_FIELD) + if isinstance(ci_outcome, dict): + stack: list[object] = [ci_outcome] + while stack: + nested = stack.pop() + if isinstance(nested, dict): + stack.extend(nested.values()) + elif isinstance(nested, list): + stack.extend(nested) + elif isinstance(nested, str): + texts.append(nested) record_chars = sum(len(value) for value in texts) record_bytes = sum(len(value.encode()) for value in texts) record_lines = sum(value.count("\n") + 1 for value in texts) @@ -1668,6 +1687,7 @@ def _prepare_enrichment( adapter_secrets = configured_secret_values(adapters, os.environ) discussion_policy = policy.forge_discussions remediation_policy = policy.remediation_threads + ci_policy = policy.ci_outcomes discussion_origin = ContextOrigin( source="forge:gitlab_discussions", adapter="gitlab", tenant="project" ) @@ -1757,6 +1777,38 @@ def _prepare_enrichment( ) required_degraded = True pending.extend(remediation_records) + ci_origin = "forge:ci_outcomes" + if ci_policy is not None: + try: + ci_snapshot = acquire_gitlab_ci_outcomes( + os.environ, + project_id=identity.context.project_id, + source_sha=identity.source_sha, + policy=ci_policy, + now=now, + deadline=acquisition_deadline, + ) + except GitLabProviderError: + completeness[ci_origin] = "unavailable" + degradation["unavailable"] += 1 + required_degraded = required_degraded or ci_policy.required + else: + completeness[ci_origin] = ci_snapshot.state + if ci_snapshot.state != "complete": + degradation["invalid"] += max(1, ci_snapshot.invalid) + degradation["limit"] += int(ci_snapshot.omitted > 0) + required_degraded = required_degraded or ci_policy.required + ci_records = prepare_ci_outcome_records( + ci_snapshot, + policy=ci_policy, + now=now, + forbidden=adapter_secrets, + ) + if len(ci_records) != len(ci_snapshot.records): + completeness[ci_origin] = "partial" + degradation["invalid"] += len(ci_snapshot.records) - len(ci_records) + required_degraded = True + pending.extend(ci_records) selections = _select_reference_candidates(policy, candidate_texts) external: BrokerResult = acquire_external_records( policy=policy, @@ -1785,6 +1837,8 @@ def _prepare_enrichment( required_sources.add("forge:gitlab_discussions") if policy.remediation_threads is not None and policy.remediation_threads.required: required_sources.add("forge:gitlab_remediation_threads") + if policy.ci_outcomes is not None and policy.ci_outcomes.required: + required_sources.add("forge:ci_outcomes") required_degraded = required_degraded or bool(limited_sources & required_sources) for source in limited_sources: completeness[source] = "partial" @@ -1814,6 +1868,16 @@ def _prepare_enrichment( elif isinstance(nested, str) and nested: forbidden_values.append(nested) forbidden = (*adapter_secrets, *forbidden_values) + ci_hints: dict[str, int] = {} + for record in context_store.records: + model = record.projections["model"].get(CI_OUTCOME_MODEL_FIELD) + if not isinstance(model, dict): + continue + status = model.get("status") + requirement = model.get("requirement") + if isinstance(status, str) and isinstance(requirement, str): + key = f"{requirement}_{status}" + ci_hints[key] = ci_hints.get(key, 0) + 1 receipt = EnrichmentReceipt( policy_digest=policy.digest, completeness=dict(sorted(completeness.items())), @@ -1821,6 +1885,7 @@ def _prepare_enrichment( required_degraded=required_degraded, mutable_admitted=_remediation_mutable_admitted(context_store.records), forbidden_publication=forbidden, + bootstrap_hints=dict(sorted(ci_hints.items())), ) return ( mcp_config.MCPContextConfig( @@ -1925,7 +1990,11 @@ def run_evidence_review( profile="gitlab_mr" if identity.mr_author_id is not None else "local", context=context_config, ) - bootstrap = render_bootstrap(store, capabilities=composition.capabilities) + bootstrap = render_bootstrap( + store, + capabilities=composition.capabilities, + context_hints=enrichment.bootstrap_hints if enrichment is not None else None, + ) write_private_text(artifacts.bootstrap, bootstrap) mcp_config.apply_mcp_composition(composition) mcp_config.verify_mcp_composition(composition) diff --git a/tests/test_context_broker.py b/tests/test_context_broker.py index bb9edfd..3802032 100644 --- a/tests/test_context_broker.py +++ b/tests/test_context_broker.py @@ -3,6 +3,8 @@ from __future__ import annotations import ast +import hashlib +import json from collections.abc import Mapping from dataclasses import dataclass, field, replace from pathlib import Path @@ -14,8 +16,18 @@ prepare_discussion_records, prepare_remediation_records, ) +from ocr_toolkit.context.ci_outcomes import ( + CIOutcome, + CIOutcomeSnapshot, + prepare_ci_outcome_records, +) from ocr_toolkit.context.policy import parse_policy -from tests.test_context_policy import encoded_policy, policy_value, remediation_policy_value +from tests.test_context_policy import ( + ci_policy_value, + encoded_policy, + policy_value, + remediation_policy_value, +) @dataclass(frozen=True) @@ -59,6 +71,25 @@ class RemediationThread: digest: str = "b" * 64 +def ci_outcome(**changes: object) -> CIOutcome: + """Build one digest-bound provider-neutral CI outcome.""" + + value: dict[str, object] = { + "check": "functional-tests", + "status": "passed", + "requirement": "required", + "path_prefixes": ("src/", "tests/"), + "origin": "same_revision_pipeline", + "completed_at": 120, + } + value.update(changes) + digest_value = {**value, "path_prefixes": list(value["path_prefixes"])} + digest = hashlib.sha256( + json.dumps(digest_value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return CIOutcome(version=str(value["completed_at"]), digest=digest, **value) # type: ignore[arg-type] + + def test_context_core_does_not_import_a_forge_provider() -> None: context_root = Path(__file__).parents[1] / "src" / "ocr_toolkit" / "context" provider_imports: list[tuple[str, str]] = [] @@ -159,6 +190,41 @@ def test_common_remediation_projection_is_fixed_model_only_and_rechecks_every_te assert "reviewer@example.invalid" not in repr(records) +def test_common_ci_projection_is_provider_neutral_scoped_and_dlp_checked() -> None: + """Admit a fake-provider outcome without forge IDs or unsafe protected text.""" + + policy_value = ci_policy_value() + policy_value["ci_outcomes"]["checks"].append( # type: ignore[index,union-attr] + {"name": "private-check", "path_prefixes": ["private/"]} + ) + policy = parse_policy(encoded_policy(policy_value)).ci_outcomes + assert policy is not None + safe = ci_outcome() + secret = ci_outcome(check="private-check", path_prefixes=("private/",), completed_at=121) + + records = prepare_ci_outcome_records( + CIOutcomeSnapshot("complete", (safe, secret), 0, 0), + policy=policy, + now=150, + forbidden=("private-check",), + ) + + assert len(records) == 1 + record = records[0] + assert record.resource_class == "ci_outcome" + assert record.mutable is False + assert record.projections["model"]["ci_outcome"] == { + "check": "functional-tests", + "revision": "reviewed_head", + "status": "passed", + "requirement": "required", + "scope": {"mode": "declared", "path_prefixes": ["src/", "tests/"]}, + "origin": "same_revision_pipeline", + "completed_at": 120, + } + assert "gitlab" not in repr(record.projections) + + @pytest.mark.parametrize( "record", [ diff --git a/tests/test_context_mcp.py b/tests/test_context_mcp.py index 2c6ed65..b440d5c 100644 --- a/tests/test_context_mcp.py +++ b/tests/test_context_mcp.py @@ -12,7 +12,14 @@ from ocr_toolkit import mcp_config from ocr_toolkit.context.mcp import ContextMCPError, call_context_tool, tool_definitions from ocr_toolkit.evidence.mcp import TOOL_NAME, handle_request -from tests.test_context_store import POLICY_DIGEST, RUN_ID, commit, pending, remediation_pending +from tests.test_context_store import ( + POLICY_DIGEST, + RUN_ID, + ci_pending, + commit, + pending, + remediation_pending, +) from tests.test_evidence_mcp import _store @@ -90,7 +97,42 @@ def test_context_tools_expose_fixed_remediation_resource_without_provider_identi assert "thread-" not in serialized assert "provider_id" not in serialized resource_schema = tool_definitions()[0]["inputSchema"]["properties"]["resource_class"] # type: ignore[index] - assert resource_schema["enum"] == ["document", "issue", "remediation_thread"] + assert resource_schema["enum"] == ["ci_outcome", "document", "issue", "remediation_thread"] + + +def test_context_tools_expose_scoped_ci_outcome_without_provider_identity(tmp_path: Path) -> None: + """Return only the protected scope and closed same-revision outcome projection.""" + + store = commit( + tmp_path / "context.json", + completeness={"forge:ci_outcomes": "complete"}, + records=[ci_pending()], + ) + listed = payload( + call_context_tool( + store, + "context_list", + {"resource_class": "ci_outcome"}, + now=150, + ) + ) + fetched = payload( + call_context_tool( + store, + "context_get", + {"handle": listed["records"][0]["handle"]}, + now=150, + ) + ) + serialized = json.dumps((listed, fetched), sort_keys=True) + + assert fetched["record"]["ci_outcome"]["status"] == "passed" + assert fetched["record"]["ci_outcome"]["scope"] == { + "mode": "declared", + "path_prefixes": ["src/", "tests/"], + } + for forbidden in ("pipeline_id", "job_id", "web_url", "runner", "user", "trace"): + assert forbidden not in serialized def test_context_tools_enforce_live_expiry_and_colon_source_cursor(tmp_path: Path) -> None: diff --git a/tests/test_context_policy.py b/tests/test_context_policy.py index 2a248d4..465c378 100644 --- a/tests/test_context_policy.py +++ b/tests/test_context_policy.py @@ -80,6 +80,20 @@ def remediation_policy_value() -> dict[str, object]: return value +def ci_policy_value() -> dict[str, object]: + """Build one protected policy v3 with exact CI check scopes.""" + + value = remediation_policy_value() + value["schema_version"] = "ocr.review-context-policy/v3" + value["ci_outcomes"] = { + "checks": [ + {"name": "functional-tests", "path_prefixes": ["src/", "tests/"]}, + {"name": "package", "path_prefixes": ["pyproject.toml"]}, + ] + } + return value + + def git(root: Path, *args: str) -> str: completed = subprocess.run( ["git", "-C", str(root), *args], @@ -118,6 +132,66 @@ def test_policy_v2_adds_fixed_remediation_threads_without_changing_v1() -> None: assert current.remediation_threads.max_replies_per_thread == 8 +def test_policy_v3_adds_exact_ci_scopes_with_conservative_defaults() -> None: + """Keep CI authority protected, exact, bounded, and opt-in.""" + + parsed = parse_policy(encoded_policy(ci_policy_value())) + + assert parsed.schema_version == "ocr.review-context-policy/v3" + assert parsed.ci_outcomes is not None + assert parsed.ci_outcomes.required is False + assert parsed.ci_outcomes.max_age_seconds == 86_400 + assert parsed.ci_outcomes.checks[0].name == "functional-tests" + assert parsed.ci_outcomes.checks[0].path_prefixes == ("src/", "tests/") + + +@pytest.mark.parametrize( + "mutation", + [ + lambda value: value.update({"schema_version": "ocr.review-context-policy/v2"}), + lambda value: value["ci_outcomes"].update({"unknown": True}), + lambda value: value["ci_outcomes"].update({"required": "yes"}), + lambda value: value["ci_outcomes"].update({"max_age_seconds": 59}), + lambda value: value["ci_outcomes"].update( + {"checks": [{"name": "functional-tests", "path_prefixes": ["../tests/"]}]} + ), + lambda value: value["ci_outcomes"].update( + {"checks": [{"name": "functional-tests", "path_prefixes": []}]} + ), + lambda value: value["ci_outcomes"].update( + { + "checks": [ + { + "name": "functional-tests", + "path_prefixes": ["https://private.invalid/tests/"], + } + ] + } + ), + lambda value: value["ci_outcomes"].update( + { + "checks": [ + {"name": "duplicate", "path_prefixes": ["src/"]}, + {"name": "duplicate", "path_prefixes": ["tests/"]}, + ] + } + ), + lambda value: value["ci_outcomes"].update( + {"checks": [{"name": "unsafe\u202e", "path_prefixes": ["src/"]}]} + ), + ], +) +def test_policy_v3_rejects_ci_authority_expansion(mutation: object) -> None: + """Reject legacy, ambiguous, unsafe, or unbounded CI policy forms.""" + + value = ci_policy_value() + assert callable(mutation) + mutation(value) + + with pytest.raises(ContextContractError): + parse_policy(encoded_policy(value)) + + @pytest.mark.parametrize( "value", [ diff --git a/tests/test_context_store.py b/tests/test_context_store.py index d48c454..332a48d 100644 --- a/tests/test_context_store.py +++ b/tests/test_context_store.py @@ -91,6 +91,45 @@ def remediation_pending(*, expiry: int = 200) -> PendingContextRecord: ) +def ci_pending(*, expiry: int = 200) -> PendingContextRecord: + """Return one exact provider-neutral CI outcome projection.""" + + outcome = { + "check": "functional-tests", + "revision": "reviewed_head", + "status": "passed", + "requirement": "required", + "scope": {"mode": "declared", "path_prefixes": ["src/", "tests/"]}, + "origin": "same_revision_pipeline", + "completed_at": 120, + } + digest = hashlib.sha256( + json.dumps(outcome, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return PendingContextRecord( + source="forge:ci_outcomes", + adapter="gitlab", + tenant="project", + canonical_object="ci-outcome-safe", + resource_class="ci_outcome", + descriptor="ci_outcome", + projections={ + "model": {"descriptor": "ci_outcome", "ci_outcome": outcome}, + "publish": {"descriptor": "ci_outcome"}, + "retain": { + "digest": digest, + "expiry": expiry, + "state": "passed", + "version": "120", + }, + }, + version="120", + digest=digest, + mutable=False, + expiry=expiry, + ) + + def commit(path: Path, **kwargs: object) -> ContextStore: parameters = { "run_id": RUN_ID, @@ -202,6 +241,56 @@ def test_context_store_keeps_remediation_projection_model_only(tmp_path: Path) - commit(path, records=[generic]) +def test_context_store_admits_only_closed_immutable_ci_outcome(tmp_path: Path) -> None: + """Persist scoped status without provider IDs, URLs, users, logs, or artifacts.""" + + path = tmp_path / "context-store.json" + store = commit( + path, + completeness={"forge:ci_outcomes": "complete"}, + records=[ci_pending()], + ) + record = store.records[0] + serialized = path.read_text(encoding="utf-8") + + assert record.resource_class == "ci_outcome" + assert record.mutable is False + assert record.projections["model"]["ci_outcome"]["revision"] == "reviewed_head" # type: ignore[index] + for forbidden in ("web_url", "runner", "user", "trace", "pipeline_id", "job_id"): + assert forbidden not in serialized + + +@pytest.mark.parametrize( + "mutation", + [ + lambda outcome: outcome.update({"provider_url": "https://private.invalid"}), + lambda outcome: outcome.update({"check": "x" * 129}), + lambda outcome: outcome.update({"revision": "a" * 40}), + lambda outcome: outcome.update({"status": "green"}), + lambda outcome: outcome.update({"requirement": "optional"}), + lambda outcome: outcome.update({"origin": "pipeline-7"}), + lambda outcome: outcome["scope"].update({"path_prefixes": ["tests/", "src/"]}), + lambda outcome: outcome.update({"completed_at": 201}), + ], +) +def test_context_store_rejects_hostile_ci_outcome_projection( + tmp_path: Path, mutation: object +) -> None: + """Fail closed when persisted CI context widens or loses its revision binding.""" + + candidate = ci_pending() + outcome = candidate.projections["model"]["ci_outcome"] + assert callable(mutation) and isinstance(outcome, dict) + mutation(outcome) + + with pytest.raises(ContextStoreError): + commit( + tmp_path / "context-store.json", + completeness={"forge:ci_outcomes": "complete"}, + records=[candidate], + ) + + def test_context_store_rejects_wrong_run_policy_expiry_and_replay(tmp_path: Path) -> None: path = tmp_path / "context-store.json" store = commit(path) diff --git a/tests/test_gitlab_ci.py b/tests/test_gitlab_ci.py new file mode 100644 index 0000000..91ebb03 --- /dev/null +++ b/tests/test_gitlab_ci.py @@ -0,0 +1,226 @@ +"""Bounded GitLab acquisition for provider-neutral same-revision CI evidence.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from ocr_toolkit.context.ci_outcomes import CIOutcomeSnapshot +from ocr_toolkit.context.policy import parse_policy +from ocr_toolkit.providers import gitlab_ci +from ocr_toolkit.providers.gitlab import GitLabProviderError +from tests.test_context_policy import ci_policy_value, encoded_policy + +HEAD = "a" * 40 +NOW = 1_800_000_000 +ENVIRONMENT = { + "CI_API_V4_URL": "https://gitlab.example.invalid/api/v4", + "CI_PROJECT_ID": "7", + "CI_PIPELINE_ID": "10", + "GITLAB_API_TOKEN": "not-a-real-token", +} + + +def _job( + name: str, + *, + pipeline_id: int, + status: str = "success", + allow_failure: bool = False, + finished_at: str = "2027-01-15T08:00:00Z", +) -> dict[str, object]: + """Build one minimal GitLab-style job response.""" + + return { + "id": pipeline_id * 100, + "name": name, + "status": status, + "allow_failure": allow_failure, + "finished_at": finished_at, + "pipeline": {"id": pipeline_id, "sha": HEAD}, + "web_url": "https://gitlab.example.invalid/private/job", + "runner": {"description": "private-runner"}, + "user": {"username": "private-user"}, + } + + +def _reader( + pipelines: list[dict[str, object]], jobs: dict[int, list[dict[str, object]]] +) -> Callable[..., tuple[object, bool]]: + """Return only controlled provider pages for a unit boundary test.""" + + def read(url: str, _token: str, *, deadline: float) -> tuple[object, bool]: + assert deadline > 0 + if "/jobs?" not in url: + return pipelines, False + pipeline_id = int(url.split("/pipelines/", 1)[1].split("/", 1)[0]) + return jobs[pipeline_id], False + + return read + + +def test_gitlab_ci_admits_only_exact_scoped_current_and_same_revision_outcomes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normalize safe status/provenance while dropping raw provider identities.""" + + pipelines = [{"id": 10, "sha": HEAD}, {"id": 11, "sha": HEAD}] + jobs = { + 10: [_job("package", pipeline_id=10)], + 11: [_job("functional-tests", pipeline_id=11)], + } + monkeypatch.setattr(gitlab_ci, "_read_page", _reader(pipelines, jobs)) + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + + snapshot, _digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + + assert snapshot.state == "complete" + assert [(record.check, record.origin, record.status) for record in snapshot.records] == [ + ("functional-tests", "same_revision_pipeline", "passed"), + ("package", "current_pipeline", "passed"), + ] + assert all("private" not in record.digest for record in snapshot.records) + + +def test_gitlab_ci_rejects_wrong_revision_and_ambiguous_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail closed for a provider SHA mismatch and repeated exact check identity.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + monkeypatch.setattr( + gitlab_ci, + "_read_page", + _reader([{"id": 10, "sha": "b" * 40}], {10: []}), + ) + with pytest.raises(GitLabProviderError, match="reviewed head"): + gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + + duplicate = _job("functional-tests", pipeline_id=10) + monkeypatch.setattr( + gitlab_ci, + "_read_page", + _reader([{"id": 10, "sha": HEAD}], {10: [duplicate, dict(duplicate)]}), + ) + snapshot, _digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + assert snapshot.state == "partial" + assert all(record.check != "functional-tests" for record in snapshot.records) + assert snapshot.invalid >= 1 + + +def test_gitlab_ci_preserves_advisory_and_unknown_without_claiming_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep provider uncertainty closed and distinct from a required pass.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + jobs = { + 10: [ + _job("functional-tests", pipeline_id=10, status="new-provider-state"), + _job("package", pipeline_id=10, allow_failure=True), + ] + } + monkeypatch.setattr( + gitlab_ci, + "_read_page", + _reader([{"id": 10, "sha": HEAD}], jobs), + ) + snapshot, _digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + records = {record.check: record for record in snapshot.records} + assert records["functional-tests"].status == "unknown" + assert records["package"].requirement == "advisory" + + +def test_gitlab_ci_marks_pagination_and_stale_results_partial( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Never present truncated discovery or an expired check as complete evidence.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + pipelines = [{"id": 10, "sha": HEAD}] + jobs = { + 10: [ + _job("functional-tests", pipeline_id=10), + _job("package", pipeline_id=10, finished_at="2026-01-01T00:00:00Z"), + ] + } + + def paginated_reader(url: str, _token: str, *, deadline: float) -> tuple[object, bool]: + assert deadline > 0 + if "/jobs?" not in url: + return pipelines, True + return jobs[10], False + + monkeypatch.setattr(gitlab_ci, "_read_page", paginated_reader) + snapshot, _digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + + assert snapshot.state == "partial" + assert [record.check for record in snapshot.records] == ["functional-tests"] + assert snapshot.omitted == 1 + assert snapshot.invalid == 1 + + +def test_gitlab_ci_twice_read_mutation_returns_no_records( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Discard the complete candidate when the bounded provider snapshot changes.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + snapshots = iter( + ( + (CIOutcomeSnapshot("complete", (), 0, 0), "a" * 64), + (CIOutcomeSnapshot("complete", (), 0, 0), "b" * 64), + ) + ) + monkeypatch.setattr(gitlab_ci, "_raw_snapshot", lambda *_args, **_kwargs: next(snapshots)) + + result = gitlab_ci.acquire_gitlab_ci_outcomes( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + ) + + assert result == CIOutcomeSnapshot(state="mutated", records=(), omitted=0, invalid=1) diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index 14028bb..fbdd5a1 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -222,6 +222,7 @@ def test_public_bounded_context_recipes_match_runtime_schemas( example_root = PROJECT_ROOT / "examples" / "gitlab" / "context" adapter_policy = parse_policy((example_root / "policy-adapters.json").read_bytes()) discussion_policy = parse_policy((example_root / "policy-discussions.json").read_bytes()) + ci_policy = parse_policy((example_root / "policy-ci-outcomes.json").read_bytes()) stdio = parse_adapter_config((example_root / "adapters-stdio.json").read_text(encoding="utf-8")) remote = parse_adapter_config( (example_root / "adapters-remote.json").read_text(encoding="utf-8") @@ -234,6 +235,14 @@ def test_public_bounded_context_recipes_match_runtime_schemas( assert discussion_policy.schema_version == "ocr.review-context-policy/v2" assert discussion_policy.remediation_threads is not None assert discussion_policy.references == () + assert ci_policy.schema_version == "ocr.review-context-policy/v3" + assert ci_policy.ci_outcomes is not None + assert ci_policy.ci_outcomes.required is False + assert ci_policy.ci_outcomes.max_age_seconds == 86_400 + assert [check.name for check in ci_policy.ci_outcomes.checks] == [ + "functional-tests", + "package", + ] assert stdio[0].name == "tracker" and stdio[0].type == "stdio" assert remote[0].name == "tracker" and remote[0].type == "remote" assert remote[0].url == ("https://context-proxy.example.invalid/v1/authorize-and-resolve") @@ -288,6 +297,7 @@ def test_public_docs_describe_the_established_m5_boundary() -> None: for contract in ( "ocr.review-context-policy/v1", "ocr.review-context-policy/v2", + "ocr.review-context-policy/v3", "ocr.context-store/v2", "ocr.context-adapter-request/v1", "ocr.context-adapter-response/v1", diff --git a/tests/test_review_context.py b/tests/test_review_context.py index 3bd0f1c..9e6e703 100644 --- a/tests/test_review_context.py +++ b/tests/test_review_context.py @@ -114,10 +114,17 @@ def test_context_bootstrap_limits_remediation_to_current_evidence_hypotheses() - capabilities=( SimpleNamespace( server="ocr_toolkit_evidence", - tools=("ocr_toolkit_evidence", "context_list", "context_get"), + tools=( + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + "context_list", + "context_get", + ), builtin=True, ), ), + context_hints={"required_passed": 1, "advisory_failed": 0}, max_chars=3_000, ) @@ -125,6 +132,9 @@ def test_context_bootstrap_limits_remediation_to_current_evidence_hypotheses() - assert "re-checked against current code and test evidence" in bootstrap assert "cannot change severity" in bootstrap assert "authorize approval" in bootstrap + assert "required_passed=1" in bootstrap + assert "advisory_failed" not in bootstrap + assert "context_list(resource_class=ci_outcome)" in bootstrap def test_normalizer_applies_complete_field_multibyte_line_control_and_label_bounds( diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 851754d..0171531 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -19,6 +19,7 @@ from ocr_toolkit import ocr_result, review_runner from ocr_toolkit.context.broker import BrokerResult +from ocr_toolkit.context.ci_outcomes import CIOutcomeSnapshot from ocr_toolkit.context.contracts import RecognizerPolicy from ocr_toolkit.context.policy import parse_policy from ocr_toolkit.context.store import ContextStore @@ -29,7 +30,8 @@ from ocr_toolkit.posting import approval, settings from ocr_toolkit.result_contract import parse_result_outcome from tests.support import patched_attr, patched_env -from tests.test_context_policy import encoded_policy, remediation_policy_value +from tests.test_context_broker import ci_outcome +from tests.test_context_policy import ci_policy_value, encoded_policy, remediation_policy_value DEFAULT_IDENTITY = review_runner.ReviewIdentity( source_sha="a" * 40, @@ -80,19 +82,66 @@ def configure_enrichment_test( *, provider_acquire: object, external_acquire: object, + policy_value: dict[str, object] | None = None, + ci_acquire: object | None = None, ) -> EvidenceArtifacts: """Install only the composition-edge fakes shared by enrichment tests.""" - policy = parse_policy(encoded_policy(remediation_policy_value())) + policy = parse_policy(encoded_policy(policy_value or remediation_policy_value())) artifacts = repository_artifacts(tmp_path) artifacts.directory.mkdir(mode=0o700) monkeypatch.setattr(review_runner, "load_protected_policy", lambda *_args, **_kwargs: policy) monkeypatch.setattr(review_runner, "acquire_gitlab_context", provider_acquire) monkeypatch.setattr(review_runner, "acquire_external_records", external_acquire) + if ci_acquire is not None: + monkeypatch.setattr(review_runner, "acquire_gitlab_ci_outcomes", ci_acquire) monkeypatch.delenv("OCR_REVIEW_CONTEXT_ADAPTERS_JSON", raising=False) return artifacts +def test_enrichment_admits_provider_neutral_ci_without_approval_authority( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Bind a protected same-revision pass without making it suppress or approve findings.""" + + policy = ci_policy_value() + policy.pop("forge_discussions") + policy.pop("remediation_threads") + policy["references"] = [] + snapshot = CIOutcomeSnapshot("complete", (ci_outcome(),), 0, 0) + monkeypatch.setattr(review_runner.time, "time", lambda: 150) + artifacts = configure_enrichment_test( + tmp_path, + monkeypatch, + provider_acquire=lambda *_args, **_kwargs: None, + external_acquire=lambda **_kwargs: BrokerResult( + (), {}, {"invalid": 0, "limit": 0, "unavailable": 0}, False + ), + policy_value=policy, + ci_acquire=lambda *_args, **_kwargs: snapshot, + ) + + context_config, receipt = review_runner._prepare_enrichment( + enriched_identity(), + artifacts, + SimpleNamespace(read_blob=lambda *_args: b""), # type: ignore[arg-type] + ) + + assert context_config is not None and receipt is not None + assert receipt.completeness == {"forge:ci_outcomes": "complete"} + assert receipt.required_degraded is False + assert receipt.mutable_admitted is False + assert receipt.bootstrap_hints == {"required_passed": 1} + restored = ContextStore.read( + artifacts.context_store, + expected_run_id=context_config.run_id, + expected_policy_digest=context_config.policy_digest, + now=0, + ) + assert [record.resource_class for record in restored.records] == ["ci_outcome"] + assert "pipeline_id" not in artifacts.context_store.read_text(encoding="utf-8") + + def test_default_termination_signal_is_translated_for_cleanup() -> None: previous = review_runner._install_termination_handlers() try: @@ -362,6 +411,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does safe_enrichment = review_runner.EnrichmentReceipt( policy_digest="c" * 64, completeness={ + "forge:ci_outcomes": "complete", "forge:gitlab_discussions": "complete", "reference:tracker:engineering:issue": "complete", }, @@ -372,6 +422,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does "Validate current behavior", "Review the implementation and its tests.", ), + bootstrap_hints={"required_passed": 1}, ) metadata = review_runner._review_receipt( @@ -1983,6 +2034,7 @@ def test_context_tool_calls_never_satisfy_mandatory_evidence_summary(tmp_path: P required_degraded=False, mutable_admitted=False, forbidden_publication=(), + bootstrap_hints={}, ) with pytest.raises(review_runner.ReviewRunnerError, match="mandatory"): From 13c0b3ab61e68f1b560967f4f991376c4e84b0b1 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:04:59 +0200 Subject: [PATCH 07/12] Document stable distribution metadata --- PLANS.md | 14 ++++++++++---- README.md | 3 +++ changelog.d/157.doc.md | 1 + changelog.d/161.feature.md | 2 +- docs/codex/TASKS_BACKLOG.md | 6 +++--- tests/test_operations_docs.py | 18 +++++++++++++++++- 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 changelog.d/157.doc.md diff --git a/PLANS.md b/PLANS.md index 45bb3f7..bc30780 100644 --- a/PLANS.md +++ b/PLANS.md @@ -128,7 +128,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-05 | done | #162 guidance, exact OCR 1.11.1 pins/rules/evidence, and local binary are complete | | WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | -| WQ-08 | pending | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | +| WQ-08 | done | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | | WQ-09 | pending | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | #### Locked Decisions @@ -207,6 +207,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. bootstrap, while raw provider identities/payloads and CI status stay outside receipts, public summaries, and approval authority. Ruff format/check, `git diff --check`, and the focused policy/provider/store/MCP/review/publication suite passed with 350 tests and 83 subtests. +- 2026-08-31: #157 adds dynamic stable PyPI version, supported-Python, and Apache-2.0 + product badges ahead of the unchanged supply-chain/CI badges. Live Shields readback reported + stable `v0.8.6`, Python `3.12 | 3.13 | 3.14`, and Apache-2.0; no TestPyPI or duplicate GitHub + version badge was added. Current strategy, roadmap, M5 evidence matrix, and conditional + forge/fuzzing backlog now include the policy-v3 CI-outcome boundary without rewriting release + history or marking those future backlog items complete. Documentation tests passed 56 tests; + Towncrier draft renders every v0.8.7 category once without duplicate issue links. #### Risks And Recovery @@ -226,9 +233,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Begin WQ-08 with #157's stable-distribution badges, then reconcile current public docs, -strategy/roadmap/backlog, changelog, and final Draft-only plan truth without rewriting historical -release records. +Begin WQ-09 with one exact-head local quality/privacy/release gate, then make the single final +push, reconcile hosted PR and OCR-compatibility results, and leave the green PR in Draft. #### Plan Fidelity Check diff --git a/README.md b/README.md index ec02b28..b159135 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Open Code Review Toolkit +[![Version](https://img.shields.io/pypi/v/open-code-review-toolkit?label=version&color=0A66C2)](https://pypi.org/project/open-code-review-toolkit/) +[![Python](https://img.shields.io/pypi/pyversions/open-code-review-toolkit?logo=python&logoColor=white&label=python)](https://pypi.org/project/open-code-review-toolkit/) +[![License](https://img.shields.io/pypi/l/open-code-review-toolkit?color=0A66C2)](https://github.com/xeonvs/open-code-review-toolkit/blob/main/LICENSE) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13906/badge)](https://www.bestpractices.dev/projects/13906) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/xeonvs/open-code-review-toolkit/badge)](https://securityscorecards.dev/viewer/?uri=github.com/xeonvs/open-code-review-toolkit) [![CodeQL](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml) diff --git a/changelog.d/157.doc.md b/changelog.d/157.doc.md new file mode 100644 index 0000000..decd865 --- /dev/null +++ b/changelog.d/157.doc.md @@ -0,0 +1 @@ +Add dynamic stable PyPI version, supported-Python, and Apache-2.0 license badges to the root README. The badges derive product metadata from the supported distribution instead of a hardcoded release or the development-only TestPyPI index. diff --git a/changelog.d/161.feature.md b/changelog.d/161.feature.md index 4ed57cc..5bd0c21 100644 --- a/changelog.d/161.feature.md +++ b/changelog.d/161.feature.md @@ -1 +1 @@ -Add protected review-context policy v3 for bounded same-revision CI outcomes. Operators may map exact GitLab job names to protected path prefixes; the toolkit twice reads exact-head pipeline/job metadata and exposes only closed status, required/advisory classification, current-or-same-revision provenance, scope, and completion time through the existing private context MCP. Logs, artifacts, URLs, provider IDs, identities, variables, and raw payloads remain private and unpersisted. CI outcomes provide review context only: they cannot suppress findings, prove unrelated absence, change severity/lifecycle, or authorize approval. Policies v1/v2 remain valid without `ci_outcomes`. ([#161](https://github.com/xeonvs/open-code-review-toolkit/issues/161)) +Add protected review-context policy v3 for bounded same-revision CI outcomes. Operators may map exact GitLab job names to protected path prefixes; the toolkit twice reads exact-head pipeline/job metadata and exposes only closed status, required/advisory classification, current-or-same-revision provenance, scope, and completion time through the existing private context MCP. Logs, artifacts, URLs, provider IDs, identities, variables, and raw payloads remain private and unpersisted. CI outcomes provide review context only: they cannot suppress findings, prove unrelated absence, change severity/lifecycle, or authorize approval. Policies v1/v2 remain valid without `ci_outcomes`. diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md index aedb4d8..e134b81 100644 --- a/docs/codex/TASKS_BACKLOG.md +++ b/docs/codex/TASKS_BACKLOG.md @@ -20,7 +20,7 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel | OpenSSF Best Practices publication (BL-022) | Completed historically and not reused | The stable execution history records the passing badge publication and closure; the next identifier is BL-023. | | Native fuzzing campaign | Retained and revised | BL-019 keeps its activation requirements and includes the established M5 parsers, handles, schemas, and hostile adapter responses in its candidate inventory. | | File-based user configuration | Retained and clarified | BL-020 remains parked; M5 owns only its narrow protected-target context/DLP policy, not a general configuration framework. | -| Additional provider adapters | Retained and clarified | BL-021 remains conditional; future forge parity includes discussion and snapshot capabilities without blocking GitLab-first M5. | +| Additional provider adapters | Retained and clarified | BL-021 remains conditional; future forge parity includes discussion, snapshot, and protected same-revision CI-outcome capabilities without blocking GitLab-first M5. | | M5 bounded review-context enrichment (BL-023) | Completed and removed | The v0.7.0 release establishes the protected policy, GitLab discussion, broker/store/handle, fixed context-tool, containment, publication-DLP, receipt, setup-diagnostic, and CI-uncertainty boundaries tracked by #107-#111. The complete plan and release checkpoint are preserved in the execution-history archive. | | Review measurement gaps (BL-017) | Completed and removed | The toolkit 0.8.2 source-to-signal audit concludes `no-new-layer`: OCR retains provider/review telemetry ownership, while toolkit receipts and count-only DLP events retain deterministic lifecycle ownership. Group labels and path-derived keys are explicitly classified as untrusted, high-cardinality upstream telemetry. | @@ -100,7 +100,7 @@ Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budg - **Dependencies:** Stable evidence/MCP parser interfaces from M1; M5 targets enter the inventory only after their contracts exist. - **Activation trigger:** Not met: named targets, bounded CI resources, corpus ownership, and backend criteria across Python 3.12-3.14 are not agreed. - **Goal:** Find crashes and invariant violations at untrusted evidence, MCP, result, GitLab payload, registry-metadata, and M5 parser/protocol boundaries. -- **Scoped deliverables:** Candidate targets include current evidence/MCP/result/GitLab/registry parsers plus M5 policy parsers, recognizers, handle codec, broker schema, and hostile adapter responses. Select a bounded backend, synthetic seeds, corpus ownership, minimization, and regression policy before activation. +- **Scoped deliverables:** Candidate targets include current evidence/MCP/result/GitLab/registry parsers plus M5 policy parsers, recognizers, handle codec, broker schema, CI-outcome adapter/store readback, and hostile adapter responses. Select a bounded backend, synthetic seeds, corpus ownership, minimization, and regression policy before activation. - **Acceptance criteria:** Targets are deterministic and bounded, minimized failures become tests, corpora contain no repository/provider secrets, and ownership is explicit. - **Exclusions:** Unbounded CI, production data, blanket fuzzing, or a runtime dependency. - **Validation:** Reproducible smoke campaign and minimized-corpus replay. @@ -129,7 +129,7 @@ Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budg - **Activation trigger:** A named forge has an owner, synthetic fixtures, and explicit parity requirements for CI orchestration, positioning, deduplication, discussion ownership, and safe publication. - **Upstream overlap:** OCR 1.10.2 reusable GitHub Action checkpoint ranges and OCR 1.11.0 Action/plugin changes cover only upstream execution surfaces. They do not provide toolkit forge acquisition, discussion, publication, or lifecycle parity, so the trigger and acceptance criteria remain unmet. - **Goal:** Add one coherent host adapter without leaking forge semantics into evidence or core result handling. -- **Scoped deliverables:** The capability matrix covers authentication, diff positions, drafts, discussion acquisition, provider-declared account classification, thread/reply structure, edit/version identity, anchors, resolved/stale state, pagination/snapshot mutation, ambiguous writes, permissions, and idempotency. +- **Scoped deliverables:** The capability matrix covers authentication, diff positions, drafts, discussion acquisition, protected same-revision CI outcomes, provider-declared account classification, thread/reply structure, edit/version identity, anchors, resolved/stale state, pagination/snapshot mutation, ambiguous writes, permissions, and idempotency. - **Acceptance criteria:** Core remains provider-neutral, GitLab behavior does not regress, unsupported host capabilities fail or degrade explicitly rather than emulate unsafe parity, and the new host meets the approved lifecycle and security matrix. - **Exclusions:** Repository ecosystem/framework detection, partial adapters, legacy namespace shims, or multi-host abstractions without a real second provider. - **Validation:** Shared adapter contract suite, provider-specific synthetic integration tests, redaction/write-bound tests, and documentation validation. diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index d7d132b..2a5e767 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -14,9 +14,20 @@ SIGNAL_OWNERSHIP = PROJECT_ROOT / "docs" / "engineering" / "review_signal_ownership.md" -def test_readme_security_badges_link_to_repository_specific_results() -> None: +def test_readme_product_and_security_badges_link_to_authoritative_results() -> None: readme = README.read_text(encoding="utf-8") + product_badges = ( + "[![Version](https://img.shields.io/pypi/v/open-code-review-toolkit?" + "label=version&color=0A66C2)](https://pypi.org/project/open-code-review-toolkit/)", + "[![Python](https://img.shields.io/pypi/pyversions/open-code-review-toolkit?" + "logo=python&logoColor=white&label=python)]" + "(https://pypi.org/project/open-code-review-toolkit/)", + "[![License](https://img.shields.io/pypi/l/open-code-review-toolkit?color=0A66C2)]" + "(https://github.com/xeonvs/open-code-review-toolkit/blob/main/LICENSE)", + ) + for badge in product_badges: + assert badge in readme assert ( "[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/" "github.com/xeonvs/open-code-review-toolkit/badge)]" @@ -29,6 +40,11 @@ def test_readme_security_badges_link_to_repository_specific_results() -> None: "(https://github.com/xeonvs/open-code-review-toolkit/actions/" "workflows/codeql.yml)" ) in readme + positions = [readme.index(badge) for badge in product_badges] + assert positions == sorted(positions) + assert positions[-1] < readme.index("[![OpenSSF Best Practices]") + assert "img.shields.io/pypi/v/open-code-review-toolkit" in readme + assert "test.pypi.org" not in readme.split("## Install", 1)[0] def test_readme_and_gitlab_guide_link_to_operations() -> None: From 2a3815d029975914aa774ff9b63fe6cc037d0119 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:09:21 +0200 Subject: [PATCH 08/12] Align bootstrap tests with safe deltas --- PLANS.md | 5 +++++ tests/test_evidence_framework_plugins.py | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/PLANS.md b/PLANS.md index bc30780..7b76833 100644 --- a/PLANS.md +++ b/PLANS.md @@ -214,6 +214,11 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. forge/fuzzing backlog now include the policy-v3 CI-outcome boundary without rewriting release history or marking those future backlog items complete. Documentation tests passed 56 tests; Towncrier draft renders every v0.8.7 category once without duplicate issue links. +- 2026-08-31: the first exact-head quality run reached 86.18% combined coverage and exposed one + stale cross-provider assertion that still expected the pre-#159 one-sided-delta diagnostic in + the compact bootstrap. Production behavior and budgets were unchanged; the test continues to + prove actual framework/template deltas, summary counts, MCP list/get projection, and bootstrap + routing without requiring a removed diagnostic. Its focused owner suite passed 41 tests. #### Risks And Recovery diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py index 5acbf4a..a2aa912 100644 --- a/tests/test_evidence_framework_plugins.py +++ b/tests/test_evidence_framework_plugins.py @@ -557,8 +557,6 @@ def test_cross_provider_evidence_projects_through_deltas_bootstrap_and_one_mcp( assert f"template.file={template_count}" in bootstrap assert f"framework.detected={len(framework_deltas)}" in bootstrap assert f"template.file={len(template_deltas)}" in bootstrap - assert "kind=repository.evidence_delta" in bootstrap - assert "delta_kind" in bootstrap assert "automation/roles/worker" in cast(dict[str, int], summary["components"]) assert "action=summary" in bootstrap assert "action=list" in bootstrap From 9b211e0e622d2d21081347effb1bf24e9389fc9b Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:17:27 +0200 Subject: [PATCH 09/12] Record deferred 0.8.7 handoff --- PLANS.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/PLANS.md b/PLANS.md index 7b76833..23a7e4c 100644 --- a/PLANS.md +++ b/PLANS.md @@ -129,7 +129,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | done | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | -| WQ-09 | pending | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | +| WQ-09 | in_progress | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | #### Locked Decisions @@ -219,6 +219,19 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. the compact bootstrap. Production behavior and budgets were unchanged; the test continues to prove actual framework/template deltas, summary counts, MCP list/get projection, and bootstrap routing without requiring a removed diagnostic. Its focused owner suite passed 41 tests. +- 2026-08-31: the corrected exact-head local gate passed 1,367 tests plus 366 subtests at 86.18% + combined branch coverage; the four risk groups passed at 85%, 82%, 86%, and 87%. Ruff format + and lint, strict MyPy, Bandit, lock, OCR manifest/evidence, rendered Towncrier, and + `git diff --check` passed. Repository-pinned Gitleaks 8.24.3 passed the complete first-parent + branch history from `origin/main`; its official Darwin arm64 archive verified SHA-256 + `b90f13bb8c90ab72083d9b0c842e39dafb82c0e5c3f872f407366b7a58909013`, the temporary files + were removed, and the global 8.30.1 installation was not changed. +- 2026-08-31: engineering-workflow 0.9.0 plan lifecycle check passed. Its value-free public-tree + privacy scanner reported 339 established baseline matches and 346 on the final tree; the seven + added match counts are limited to controlled OCR qualification probes, the new GitLab token + parser owner, and explicit DLP/evidence test owners. No new hard-category appeared. This + repository-owned synthetic/false-positive provenance agrees with the clean redacted Gitleaks + history scan; no candidate value was opened or copied into the review. #### Risks And Recovery @@ -238,8 +251,10 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Begin WQ-09 with one exact-head local quality/privacy/release gate, then make the single final -push, reconcile hosted PR and OCR-compatibility results, and leave the green PR in Draft. +Push this reviewed local history once to Draft PR #164, reconcile hosted PR and exact-head +OCR-compatibility results, update only external PR/issue checklists, and leave the branch in Draft. +The next product action after green hosted checks is external OCR 1.11.1 qualification of this +exact head with a configured LLM endpoint; do not merge or publish first. #### Plan Fidelity Check @@ -257,7 +272,8 @@ push, reconcile hosted PR and OCR-compatibility results, and leave the green PR - [x] No pre-existing user changes are present in the worktree. - [x] Milestone and issue relationships agree with this plan. - [ ] Draft PR and compatibility artifact agree with this plan. -- [ ] Final implementation, docs, changelog, backlog, tests, and remote state agree. +- [x] Final implementation, docs, changelog, backlog, and local tests agree. +- [ ] Remote Draft head, hosted checks, and compatibility artifact agree. #### Closure Gate @@ -265,7 +281,8 @@ push, reconcile hosted PR and OCR-compatibility results, and leave the green PR - [ ] Final local and hosted checks are current for the exact Draft head. - [ ] Draft remains open, mergeable, and without unresolved review threads. - [ ] External qualification remains a truthful post-close/deferred delivery item. -- [ ] `scripts/plan_lifecycle.py check` accepts the final state before closure or archival. +- [x] Engineering-workflow 0.9.0 `plan_lifecycle.py check` accepts the active deferred state; + closure/archive remains out of scope until the ordinary stable release lifecycle. #### Post-Close Delivery From 3ab81ad695d8661acb4fbf1b3934f2efe3d0fb98 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:43:55 +0200 Subject: [PATCH 10/12] Plan complete v0.8.7 delivery --- PLANS.md | 94 +++++++++++++++++++++++----------- changelog.d/165.maintenance.md | 1 + 2 files changed, 64 insertions(+), 31 deletions(-) create mode 100644 changelog.d/165.maintenance.md diff --git a/PLANS.md b/PLANS.md index 23a7e4c..992d810 100644 --- a/PLANS.md +++ b/PLANS.md @@ -10,12 +10,11 @@ before handoff or commit. Completed stable plans are indexed in - **Status:** active - **Plan Origin:** plan_mode_approved -- **Release classification:** release-required; stable delivery is release-deferred +- **Release classification:** release-required - **Target stable version:** 0.8.7 - **Branch:** `codex/v0.8.7-ocr-1.11.1-evidence-trust` -- **Delivery boundary:** a fully implemented, green Draft pull request. Merge, release PR, - tags, TestPyPI/PyPI publication, stable release, issue closure, and milestone closure are - outside this cycle until an external configured environment qualifies the exact Draft head. +- **Delivery boundary:** published stable toolkit 0.8.7 with independently verified registry, + provenance, attestation, immutable Release, issue, milestone, and synchronized-main receipts. #### Goal @@ -38,6 +37,12 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - Complete #157 with stable PyPI, supported-Python, and license badges. - Reconcile backlog, public contracts, test-evidence ownership, changelog, Draft PR, issue checklists, and milestone state. +- Incorporate merged maintenance PR #165 from protected `main` before final qualification so + the reviewed tree uses the updated CI, CodeQL, TestPyPI, release, and provenance actions. +- Run one configured semantic qualification of the new MCP/context contracts and one final + repository review with project rules; remediate validated findings before the final push. +- Merge the protected feature and release pull requests, publish stable 0.8.7, independently + read back every external artifact, close tracking, and clean task-owned scratch state. #### Requirement Traceability @@ -53,12 +58,12 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | REQ-008 | New tools have exact count-only receipt and GitLab-summary attribution without content leakage | WQ-06 | receipt v6, action receipt v2, reconciliation/DLP/approval tests | | REQ-009 | Protected same-revision GitLab CI outcomes become bounded provider-neutral evidence only | WQ-07 | policy v3, stable API snapshot, store/MCP and negative provider tests | | REQ-010 | Public docs, badges, backlog, roadmap, strategy, and changelog describe added, changed, removed, and unchanged contracts | WQ-08 | documentation contracts and Towncrier draft | -| REQ-011 | Final Draft is clean and green but remains deferred for exact-head external LLM qualification | WQ-09 | final local gate, hosted CI, Draft/remote/worktree readback | +| REQ-011 | Final feature head includes protected-main PR #165 and passes configured semantic qualification plus project-rule OCR review | WQ-09, WQ-10 | exact base/head receipts, complete manifests, remediation and self-review | +| REQ-012 | Stable 0.8.7 is published and independently reconciled across every release surface | WQ-11, WQ-12 | feature/release PR receipts, registry bytes, provenance, attestations, tag, Release, installs, issue and milestone closure | #### Explicit Non-Goals - No support or runtime fallback for OCR 1.11.0 or older releases. -- No real LLM/provider calls in this local environment. - No automatic cap, tool choice, model, or protocol inference from provider metadata. - No arbitrary repository grep, model-loop network, second evidence/context store, second OCR pass, or new DDL/testcontainer parser for evidence search. @@ -66,7 +71,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. prompt, tool arguments, or tool results in public output or receipts. - No approval, suppression, lifecycle, or severity authority from remediation text, search results, coverage hints, CI outcomes, OCR survivor wording, or tool-call counts. -- No merge, Ready transition, issue/milestone closure, release branch, tag, or publication. +- No downstream consumer or shared-template integration. +- No public retention of sensitive external-system data, model-session data, private fixtures, + credentials, or local environment details. #### Constraints @@ -84,8 +91,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Inputs And Sources -- Approved conversation plan and user decisions: green Draft only; protected policy v3; - include #157; add dedicated search and coverage MCP tools; preserve efficient OCR routing. +- Approved conversation plan and subsequent delivery reset: protected policy v3; include #157; + add dedicated search and coverage MCP tools; preserve efficient OCR routing; complete stable + delivery after configured exact-head validation. - GitHub issues #157–#163 and OCR dependency tracker #158. - OCR v1.11.1 release/source diff and compatibility workflow run `33391721404`. - OCR v1.11.1 pins: Linux amd64 @@ -98,20 +106,31 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### User Decisions And Answers -- Delivery ends at a green Draft and external exact-head qualification blocker. +- Delivery continues from a green Draft through configured exact-head qualification and the + complete protected stable-release lifecycle. - CI outcome authority is owned only by protected-target policy v3. - Documentation issue #157 is part of v0.8.7. - Built-in MCP gains two separate tools rather than another union action. - OCR must receive explicit routing guidance and qualification proving correct and efficient use; optional tools are not forced on every review. - Current GitLab technical-summary format stays stable and emits only non-zero tool names. +- Full delivery includes publication, independent external readback, Actions-owned issue + receipts, milestone closure, synchronized `main`, and cleanup; merge or green CI is not final. +- Final repository OCR review uses project rules, `medium` effort, concurrency one, OCR-owned + completion limits, and max-tools sentinel zero. Confirmed findings are fixed before push and + followed by deterministic validation plus a complete self-review. +- If a newer stable OCR appears before the final repository review, qualify and adopt it as the + sole runtime when compatible. Freeze the qualified OCR version once that review begins. #### Completed Baseline State -- `main` is released v0.8.6 and `.next-version` selects 0.8.7. +- Protected `main` contains released v0.8.6 plus verified maintenance squash #165 at + `4fd4eda3fb10ff3ae7c40b099ab791ba4797c134`; its development publication and Scorecard pass. + `.next-version` selects 0.8.7. - Existing evidence store v4 exposes one built-in MCP with summary/list/get; context uses fixed context_list/context_get; receipt v5 reconciles count-only evidence usage. -- OCR 1.11.1 is the exact current runtime and local `/opt/homebrew/bin/ocr` version. +- OCR 1.11.1 is the exact target runtime; the PATH-effective local binary still requires an + atomic checksum-verified update before configured qualification. - Workflow audit with engineering-workflow 0.9.0 found canonical files and navigation indexes; no target workflow migration is required. - OCR v1.11.1 assets verify, while current qualification fails because its probes assume @@ -129,7 +148,10 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | done | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | -| WQ-09 | in_progress | Run final local gate, final push, hosted CI reconciliation, and Draft-only handoff | +| WQ-09 | in_progress | Merge protected `main` #165 into the Draft branch, update maintenance notes, and revalidate the combined tree | +| WQ-10 | pending | Run configured semantic qualification and final project-rule OCR review; remediate findings and self-review before push | +| WQ-11 | pending | Push the final feature head, reconcile hosted checks, merge #164, and verify its development publication | +| WQ-12 | pending | Prepare and merge `Release v0.8.7`, publish stable artifacts, independently read back every release surface, close tracking, and clean scratch state | #### Locked Decisions @@ -166,6 +188,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - Hosted PR workflows own OS/Python matrix, packages, dependencies, Security, and CodeQL. - External configured qualification owns real OCR 1.11.1 LLM behavior, multi-round correction, search/get and no-match/coverage routing, same-revision CI evidence, and leakage audit. +- Final repository OCR review owns the complete `v0.8.6..feature-head` diff with + `examples/gitlab/rules.json`; it runs only after the protected-main merge and latest-stable OCR + check. Full output remains ignored and owner-only. +- Stable closure requires byte equality across workflow artifact, TestPyPI, PyPI, and immutable + GitHub Release; PEP 740 provenance; GitHub attestations; annotated tag target; receipt + validation; Python 3.12-3.14 wheel/sdist installs; Actions-owned issue receipts; closed + milestone; and clean `main == origin/main == v0.8.7^{}`. #### Latest Validation Results @@ -232,6 +261,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. parser owner, and explicit DLP/evidence test owners. No new hard-category appeared. This repository-owned synthetic/false-positive provenance agrees with the clean redacted Gitleaks history scan; no candidate value was opened or copied into the review. +- 2026-09-01: protected-main PR #165 was squash-merged as verified commit `4fd4eda`; its seven + workflow-only pin changes have no path overlap or merge conflict with #164. Development run + 33482577560 and Scorecard run 33482577475 completed successfully on that exact commit. #### Risks And Recovery @@ -251,15 +283,14 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Push this reviewed local history once to Draft PR #164, reconcile hosted PR and exact-head -OCR-compatibility results, update only external PR/issue checklists, and leave the branch in Draft. -The next product action after green hosted checks is external OCR 1.11.1 qualification of this -exact head with a configured LLM endpoint; do not merge or publish first. +Merge protected `origin/main` at `4fd4eda` into the local #164 branch without pushing, validate +the combined trusted workflow/runtime tree, atomically update OCR, and run the two configured OCR +gates. Push only after validated findings are remediated and the final self-review is complete. #### Plan Fidelity Check - [x] Every approved outcome has a stable requirement ID and queue owner. -- [x] Release/Draft boundary, exact OCR target, checksums, and external qualification are explicit. +- [x] Stable-release boundary, exact OCR target, checksums, and external qualification are explicit. - [x] Data-flow, DLP, provider-neutral ownership, approval, and failure semantics are explicit. - [x] Rejected alternatives and non-goals prevent compatibility fallbacks and extra infrastructure. - [x] Validation maps production owners, installed boundaries, hosted ownership, and local limits. @@ -271,7 +302,7 @@ exact head with a configured LLM endpoint; do not merge or publish first. were inspected before implementation. - [x] No pre-existing user changes are present in the worktree. - [x] Milestone and issue relationships agree with this plan. -- [ ] Draft PR and compatibility artifact agree with this plan. +- [ ] Draft PR, protected-main #165, and compatibility artifact agree with this plan. - [x] Final implementation, docs, changelog, backlog, and local tests agree. - [ ] Remote Draft head, hosted checks, and compatibility artifact agree. @@ -279,19 +310,19 @@ exact head with a configured LLM endpoint; do not merge or publish first. - [ ] All REQ and WQ items are done or explicitly out of scope. - [ ] Final local and hosted checks are current for the exact Draft head. -- [ ] Draft remains open, mergeable, and without unresolved review threads. -- [ ] External qualification remains a truthful post-close/deferred delivery item. -- [x] Engineering-workflow 0.9.0 `plan_lifecycle.py check` accepts the active deferred state; - closure/archive remains out of scope until the ordinary stable release lifecycle. +- [ ] Feature and release PR exact heads pass their owned checks and have no unresolved threads. +- [ ] Configured qualification and project-rule review have complete exact-head receipts. +- [ ] Stable publication and independent readback satisfy every external closure invariant. +- [ ] #157-#163 and any added release tracker are closed by Actions receipts; milestone is closed. +- [ ] Task-owned scratch state is removed and local/remote `main` equals the peeled stable tag. -#### Post-Close Delivery +#### Stable Delivery -An external agent must start from the exact green Draft head, checksum-verified OCR 1.11.1, -an isolated HOME, and a configured LLM endpoint. It runs real `ocr review`, verifies small and -threshold-crossing grouping, multi-round contradiction handling, search/get and no-match/coverage -routing, same-revision CI evidence, cleanup, and absence of raw prompts/reasoning/provider/MCP -content in public artifacts. Only after that evidence may the owner move the Draft toward the -ordinary protected merge and stable v0.8.7 release process. +The feature PR becomes Ready only after the combined exact head passes configured qualification, +project-rule review, remediation, self-review, and hosted checks. Its verified squash merge is +followed by development publication readback and a separate protected `Release v0.8.7` PR. The +release merge authorizes stable publication; independent registry/Release/provenance/install and +tracking readback completes delivery without another repository PR. #### Handoff Notes @@ -299,4 +330,5 @@ ordinary protected merge and stable v0.8.7 release process. - OCR v1.11.0 may appear only as historical comparison evidence. - Do not publish external qualification prompts, reasoning, tool arguments/results, provider bodies, session files, credentials, or private fixture content. -- Keep #157–#163 and milestone `v0.8.7` open in the Draft-only state. +- Keep #157-#163 and milestone `v0.8.7` open until the protected stable workflow records its + Actions-owned receipts. PR #165 is included maintenance history, not a release-closure issue. diff --git a/changelog.d/165.maintenance.md b/changelog.d/165.maintenance.md new file mode 100644 index 0000000..41c7921 --- /dev/null +++ b/changelog.d/165.maintenance.md @@ -0,0 +1 @@ +Update the pinned CI, CodeQL, TestPyPI, release, and build-provenance actions used by protected workflows. From b75431ac1057f752d08f349f4160658bba065429 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:52:38 +0200 Subject: [PATCH 11/12] Integrate protected workflow updates --- PLANS.md | 17 ++++++++++++----- changelog.d/158.maintenance.md | 2 +- compatibility/ocr-support.json | 2 +- docs/compatibility.md | 2 +- tests/test_operations_docs.py | 4 +++- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/PLANS.md b/PLANS.md index 992d810..f572e8b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -148,8 +148,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-06 | done | Implement #160 search/coverage tools, OCR routing, action receipt v2, and toolkit receipt v6 | | WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | done | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | -| WQ-09 | in_progress | Merge protected `main` #165 into the Draft branch, update maintenance notes, and revalidate the combined tree | -| WQ-10 | pending | Run configured semantic qualification and final project-rule OCR review; remediate findings and self-review before push | +| WQ-09 | done | Merge protected `main` #165 into the Draft branch, update maintenance notes, and revalidate the combined tree | +| WQ-10 | in_progress | Run configured semantic qualification and final project-rule OCR review; remediate findings and self-review before push | | WQ-11 | pending | Push the final feature head, reconcile hosted checks, merge #164, and verify its development publication | | WQ-12 | pending | Prepare and merge `Release v0.8.7`, publish stable artifacts, independently read back every release surface, close tracking, and clean scratch state | @@ -264,6 +264,13 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - 2026-09-01: protected-main PR #165 was squash-merged as verified commit `4fd4eda`; its seven workflow-only pin changes have no path overlap or merge conflict with #164. Development run 33482577560 and Scorecard run 33482577475 completed successfully on that exact commit. +- 2026-09-01: signed local merge `0ca7b93` incorporates exact protected-main #165 into the Draft + branch. The combined tree passes the complete local gate with 1,367 tests plus 366 subtests at + 86.18% branch coverage and risk groups at 85%, 82%, 86%, and 87%; manifest validation, + Towncrier rendering, Gitleaks, and diff hygiene also pass. Checksum-verified OCR 1.11.1 is now + PATH-effective, and its configured LLM connectivity test passes. Current compatibility wording + preserves only explicit positive completion-cap transport: the toolkit default remains unset + and no provider-specific cap is recommended. #### Risks And Recovery @@ -283,9 +290,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Merge protected `origin/main` at `4fd4eda` into the local #164 branch without pushing, validate -the combined trusted workflow/runtime tree, atomically update OCR, and run the two configured OCR -gates. Push only after validated findings are remediated and the final self-review is complete. +Run the configured semantic qualification and final project-rule OCR review on the combined +exact head. Push only after validated findings are remediated and the final self-review is +complete. #### Plan Fidelity Check diff --git a/changelog.d/158.maintenance.md b/changelog.d/158.maintenance.md index 2714bcb..c8833a9 100644 --- a/changelog.d/158.maintenance.md +++ b/changelog.d/158.maintenance.md @@ -2,6 +2,6 @@ Target checksum-verified Open Code Review 1.11.1 as the sole supported runtime f Changed: small change sets use OCR-owned local grouping below four files, while threshold-crossing changes retain semantic grouping; project `rule.json` file references are confined to the canonical repository root; selected session files follow manifest coverage. -Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit toolkit cap override `4096`, default `medium` effort, effective max-tools, private reasoning/session data, DLP, receipts, telemetry ownership, and approval authority. +Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session data, DLP, receipts, telemetry ownership, and approval authority. Deployment: install OCR 1.11.1 directly and verify the platform checksum from the compatibility manifest; OCR 1.11.0 is historical comparison evidence, not a runtime fallback. diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index 3ecdf54..74fc324 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1394,7 +1394,7 @@ ], "evidence": "compatibility/evidence/ocr-1.11.1.json", "evidence_sha256": "516d78050006e216118adcc4c1fb73d1be5318e3b7b152968061bf0a34b4aecf", - "human_conclusion": "Compatible after source and behavioral review. Toolkit consumes bounded small-change grouping, project-rule confinement, selected-file session correction, and Pug/Verilog/SystemVerilog/VHDL review coverage; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit 4096 override, medium effort, effective max-tools, private reasoning/session boundaries, receipts, DLP, and approval authority remain unchanged. Filter survivors are still labeled confirmed upstream, so toolkit treats them as unverified and requires current code, test, and trusted evidence re-checking.", + "human_conclusion": "Compatible after source and behavioral review. Toolkit consumes bounded small-change grouping, project-rule confinement, selected-file session correction, and Pug/Verilog/SystemVerilog/VHDL review coverage; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit positive completion-cap transport, medium effort, effective max-tools, private reasoning/session boundaries, receipts, DLP, and approval authority remain unchanged. The toolkit default remains unset and qualification does not recommend a provider-specific cap. Filter survivors are still labeled confirmed upstream, so toolkit treats them as unverified and requires current code, test, and trusted evidence re-checking.", "published_at": "2026-08-31T10:06:30Z", "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.1", "status": "tested", diff --git a/docs/compatibility.md b/docs/compatibility.md index 6a01ce4..b6f26af 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -89,7 +89,7 @@ Deploy toolkit 0.8.6 directly with OCR 1.11.0; do not install 1.10.2 as an inter ### OCR 1.11.1 — toolkit 0.8.7 target -OCR 1.11.1 is the only runtime version accepted by toolkit 0.8.7 preflight and pinned by its GitLab example. OCR 1.11.0 is an adjacent historical comparison, not a supported fallback. Hosted run `33400677367`, issues #158/#163, strict schema-v3 evidence, source review, and an independent checksum-verified Darwin no-LLM probe preserve the OCR result and manifest, inherited completion cap `16384`, explicit toolkit override `4096`, default `medium` effort, effective max-tools, private reasoning/session boundary, DLP, receipts, summary, and approval authority. +OCR 1.11.1 is the only runtime version accepted by toolkit 0.8.7 preflight and pinned by its GitLab example. OCR 1.11.0 is an adjacent historical comparison, not a supported fallback. Hosted run `33400677367`, issues #158/#163, strict schema-v3 evidence, source review, and an independent checksum-verified Darwin no-LLM probe preserve the OCR result and manifest, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session boundary, DLP, receipts, summary, and approval authority. The toolkit default remains unset; qualification does not recommend a provider-specific cap. Small change sets now skip the grouping LLM. A single file stays per-file; two below-threshold low-churn files use one local `small change set` group; two files above the 200-line aggregate threshold stay per-file. Four files cross the default file threshold and retain the status-first grouping inventory and semantic grouping request. Qualification proves these paths separately so a local bundle cannot silently weaken the three-item partial-budget test. The local grouping decision is OCR telemetry only; path-derived group keys and model labels do not become toolkit telemetry or public data. diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index 2a5e767..b613d84 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -250,7 +250,9 @@ def test_completion_cap_and_provider_failure_boundaries_are_public() -> None: for document in (configuration, operations, gitlab): assert "OCR_LLM_MAX_COMPLETION_TOKENS=4096" not in document current_compatibility = compatibility.split("### OCR 1.11.1 — toolkit 0.8.7 target", 1)[1] - assert "explicit toolkit override `4096`" in current_compatibility + assert "explicit positive completion-cap transport" in current_compatibility + assert "default remains unset" in current_compatibility + assert "provider-specific cap" in current_compatibility assert "historical OCR 1.10.0 through 1.10.2" in compatibility for field in ("max_completion_tokens", "max_output_tokens", "max_tokens"): assert field in configuration From 410194895f85be37e1801b3b92e959177aabf04f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:22:41 +0200 Subject: [PATCH 12/12] Fix final OCR review findings --- PLANS.md | 82 ++++++-- changelog.d/159.bugfix.md | 2 +- changelog.d/160.feature.md | 2 +- changelog.d/161.feature.md | 2 +- compatibility/evidence/ocr-1.11.1.json | 17 ++ compatibility/ocr-support.json | 2 +- docs/configuration.md | 2 +- docs/security.md | 2 +- scripts/ocr_compat.py | 9 + src/ocr_toolkit/context/policy.py | 12 +- src/ocr_toolkit/context/store.py | 13 +- .../evidence/collectors/projections.py | 15 +- src/ocr_toolkit/evidence/mcp.py | 107 +++++++--- src/ocr_toolkit/evidence/store/values.py | 9 +- src/ocr_toolkit/posting/formatting.py | 1 + src/ocr_toolkit/providers/gitlab_ci.py | 36 ++-- src/ocr_toolkit/review_runner.py | 16 +- tests/test_context_policy.py | 6 + tests/test_context_store.py | 4 + tests/test_evidence_collectors.py | 6 +- tests/test_evidence_mcp.py | 187 ++++++++++++++++++ tests/test_evidence_model.py | 20 ++ tests/test_gitlab_ci.py | 99 +++++++++- tests/test_posting_helpers.py | 11 +- tests/test_review_runner.py | 114 +++++++++++ 25 files changed, 684 insertions(+), 92 deletions(-) diff --git a/PLANS.md b/PLANS.md index f572e8b..9edded1 100644 --- a/PLANS.md +++ b/PLANS.md @@ -41,6 +41,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. the reviewed tree uses the updated CI, CodeQL, TestPyPI, release, and provenance actions. - Run one configured semantic qualification of the new MCP/context contracts and one final repository review with project rules; remediate validated findings before the final push. +- Use the owner-authorized second complete review to re-check the corrected built-in evidence + MCP as one coherent product slice: runtime schemas and routing, user documentation, changelog, + and regression examples. Record selected/completed coverage and reconciled MCP use privately. - Merge the protected feature and release pull requests, publish stable 0.8.7, independently read back every external artifact, close tracking, and clean task-owned scratch state. @@ -116,7 +119,7 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - Current GitLab technical-summary format stays stable and emits only non-zero tool names. - Full delivery includes publication, independent external readback, Actions-owned issue receipts, milestone closure, synchronized `main`, and cleanup; merge or green CI is not final. -- Final repository OCR review uses project rules, `medium` effort, concurrency one, OCR-owned +- Final repository OCR review uses project rules, `medium` effort, concurrency two, OCR-owned completion limits, and max-tools sentinel zero. Confirmed findings are fixed before push and followed by deterministic validation plus a complete self-review. - If a newer stable OCR appears before the final repository review, qualify and adopt it as the @@ -149,8 +152,8 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. | WQ-07 | done | Implement #161 protected policy v3 and same-revision GitLab CI evidence | | WQ-08 | done | Implement #157, public docs, backlog/strategy/roadmap, changelog, and final plan truth | | WQ-09 | done | Merge protected `main` #165 into the Draft branch, update maintenance notes, and revalidate the combined tree | -| WQ-10 | in_progress | Run configured semantic qualification and final project-rule OCR review; remediate findings and self-review before push | -| WQ-11 | pending | Push the final feature head, reconcile hosted checks, merge #164, and verify its development publication | +| WQ-10 | done | Reconcile all three project-rule OCR reviews, close repeated findings, and prove the complete legacy/new built-in MCP route through one bounded controlled OCR qualification before push | +| WQ-11 | in_progress | Push the final feature head, reconcile hosted checks, merge #164, and verify its development publication | | WQ-12 | pending | Prepare and merge `Release v0.8.7`, publish stable artifacts, independently read back every release surface, close tracking, and clean scratch state | #### Locked Decisions @@ -158,9 +161,9 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. - Search tool: NFKC/casefold query of 1–128 characters and at most eight tokens; no regex, wildcard, operators, controls, bidi/format characters, or broad empty search. Search only DLP-admitted source paths, identities, and per-kind allowlisted scalar values. -- Coverage tool: exact kind/ref plus optional exact component/path. Missing mappings and any - incomplete scope yield unknown; `absence_authoritative` requires complete scope, zero - matches, and no truncation. +- Coverage tool: exact kind/ref supports broad discovery, while authoritative absence also + requires exact component/path. Missing mappings, broad or incomplete scope, and admission + truncation yield unknown; `absence_authoritative` requires complete scope and zero matches. - Routing: summary once, list for known kinds/deltas, search for unknown location/identity, get selected records, coverage only before a negative claim, context_list before context_get, and stop when evidence is sufficient. No forced `tool_choice`. @@ -271,6 +274,53 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. PATH-effective, and its configured LLM connectivity test passes. Current compatibility wording preserves only explicit positive completion-cap transport: the toolkit default remains unset and no provider-specific cap is recommended. +- 2026-09-01: the one owner-authorized final repository review used OCR 1.11.1 through the + production wrapper with project rules, `medium` effort, concurrency `2`, OCR-owned completion + limits, and context mode `off`. It completed all 29 selected files with no failed, reused, or + waived work; cleanup, publication DLP, receipt v6, and all 139 attributed tool calls reconciled. + Nine candidates were statically re-checked. Confirmed compatibility-evidence, optional-CI, + retry-selection, hostile-scope, evidence-search, and authoritative-coverage defects were fixed + with focused regressions. The corrected tree passes 1,377 tests plus 366 subtests at 86.24% + branch coverage; risk groups pass at 85%, 82%, 86%, and 87%, with Ruff, strict MyPy, Bandit, + OCR manifest validation, Towncrier, Gitleaks, and diff/privacy hygiene also green. The owner + subsequently authorized one second complete project-rule review on the committed corrected + head, with explicit attention to the updated built-in evidence MCP code and user material; + no separate enriched-context qualification is claimed. +- 2026-09-01: the second owner-authorized repository review completed the same immutable 29-file + range with no failed, reused, or waived work. Its bootstrap advertised the primary evidence, + search, and coverage tools; OCR recorded eight primary-tool calls and one search-tool call. + It did not call coverage, and the private completed-action receipt did not reconcile to the + OCR by-tool attempt counts, so action attribution correctly remained unavailable. The run + therefore proves real primary/search MCP reachability but does not prove summary/list/get or + coverage routing and cannot close REQ-007/REQ-008 by itself. Its nine candidates repeat the + first run's review surfaces. Current-code revalidation confirmed seven boundary defects in CI + outcome typing, hostile Unicode handling, incomplete-kind deltas and coverage, mandatory-summary + enforcement, action-summary rendering, and CI outcome DLP inputs. The fixes and expanded negative + regressions pass 1,382 tests plus 367 subtests at 86.25% branch coverage; risk groups pass at + 85%, 82%, 86%, and 87%, with Ruff, strict MyPy, manifest validation, Towncrier, Gitleaks, and + diff hygiene green. Self-review additionally removed schema-owned CI enums from forbidden-value + matching and reconciled the affected changelog contracts. +- 2026-09-01: the third owner-authorized OCR 1.11.1 review completed all 29 selected files on + signed head `66bc208` with no failed, reused, waived, publication, cleanup, or warning state. + Its four candidates were revalidated against the current implementation: provider-order-sensitive + GitLab CI mutation hashing and compatibility-normalized search operators were confirmed and fixed; + the context-contract exception and unused evidence-receipt claims were rejected because the former + is already caught through `ValueError` and the latter is returned as `evidence.used`. Reversed + pipeline/job order and fullwidth operator regressions pass in a 305-test, 8-subtest focused suite. + Production-composed stdio MCP profiling found repeated live environment scans inside one evidence + redaction pass. Removing only that duplicate call, without caching secrets or changing their live + lifecycle, reduced three unprofiled synthetic runs to 1.51-1.63 seconds from the prior 1.95-second + baseline while preserving the 2,244-character bootstrap and private, read-only, clean-repository + assertions. A regression now locks one complete redaction pass per string leaf. The review used + the mandatory summary action but did not exercise all five routes, so the controlled exact-OCR + qualification and final full gate remain required before push. +- 2026-09-01: the bounded controlled OCR 1.11.1 qualification completed its one selected file + without findings, warnings, failed, reused, or waived work. The actual model selected every + documented built-in evidence route exactly once: summary, list, get, literal search, and scoped + coverage. The primary action subtotal of three reconciles exactly to three + `ocr_toolkit_evidence` calls, while search and coverage each reconcile exactly to their one + dedicated OCR by-tool call. Receipt v6, publication DLP, and cleanup passed; context mode stayed + off, so this proves the complete legacy/new evidence MCP route without claiming enriched context. #### Risks And Recovery @@ -290,9 +340,10 @@ weakening DLP, privacy, approval, or immutable-ref boundaries. #### Resume Point -Run the configured semantic qualification and final project-rule OCR review on the combined -exact head. Push only after validated findings are remediated and the final self-review is -complete. +Finalize the signed plan truth, repeat the bounded five-action qualification on that immutable +plan-only head, then push the exact feature head to Draft PR #164. Reconcile hosted checks and +threads before Ready and merge; verify its development publication before preparing the separate +stable release PR. #### Plan Fidelity Check @@ -309,24 +360,27 @@ complete. were inspected before implementation. - [x] No pre-existing user changes are present in the worktree. - [x] Milestone and issue relationships agree with this plan. -- [ ] Draft PR, protected-main #165, and compatibility artifact agree with this plan. +- [x] Draft PR, protected-main #165, and compatibility artifact agree with this plan. - [x] Final implementation, docs, changelog, backlog, and local tests agree. -- [ ] Remote Draft head, hosted checks, and compatibility artifact agree. +- [x] Local exact-OCR qualification, compatibility artifact, implementation, and plan agree. +- [ ] Remote Draft head and hosted checks agree. #### Closure Gate - [ ] All REQ and WQ items are done or explicitly out of scope. - [ ] Final local and hosted checks are current for the exact Draft head. - [ ] Feature and release PR exact heads pass their owned checks and have no unresolved threads. -- [ ] Configured qualification and project-rule review have complete exact-head receipts. +- [x] All three owner-authorized project-rule reviews completed; their confirmed findings were + remediated and revalidated, and the current implementation passed the separate five-action + controlled receipt. No enriched-context qualification is claimed. - [ ] Stable publication and independent readback satisfy every external closure invariant. - [ ] #157-#163 and any added release tracker are closed by Actions receipts; milestone is closed. - [ ] Task-owned scratch state is removed and local/remote `main` equals the peeled stable tag. #### Stable Delivery -The feature PR becomes Ready only after the combined exact head passes configured qualification, -project-rule review, remediation, self-review, and hosted checks. Its verified squash merge is +The feature PR becomes Ready only after the combined exact head passes project-rule review, +remediation, self-review, and hosted checks. Its verified squash merge is followed by development publication readback and a separate protected `Release v0.8.7` PR. The release merge authorizes stable publication; independent registry/Release/provenance/install and tracking readback completes delivery without another repository PR. diff --git a/changelog.d/159.bugfix.md b/changelog.d/159.bugfix.md index 33e6c38..17f63d4 100644 --- a/changelog.d/159.bugfix.md +++ b/changelog.d/159.bugfix.md @@ -1 +1 @@ -Prevent incomplete base/head evidence admission from manufacturing dependency add/remove deltas. Singular same-source pairs remain comparable, while unsafe one-sided or ambiguous changes are omitted with an explicit diagnostic. +Prevent incomplete base/head evidence admission from manufacturing dependency deltas. Once admission for a fact kind is incomplete, all semantic deltas for that kind are omitted with an explicit diagnostic because the missing value cannot be attributed safely to one identity. diff --git a/changelog.d/160.feature.md b/changelog.d/160.feature.md index 8229ead..3e5c3e6 100644 --- a/changelog.d/160.feature.md +++ b/changelog.d/160.feature.md @@ -1 +1 @@ -Add two fixed read-only built-in MCP tools for bounded literal evidence search and exact scoped coverage checks. OCR now receives concise summary/list/search/get/coverage routing, while action receipt v2 and toolkit receipt v6 reconcile all five actions to OCR's per-tool counts. Only non-zero numeric counts may reach GitLab; queries, scopes, matched values, IDs, and results remain private, and incomplete coverage can never prove absence or authorize automatic approval. +Add two fixed read-only built-in MCP tools for bounded literal evidence search and exact scoped coverage checks. OCR now receives concise summary/list/search/get/coverage routing, while action receipt v2 and toolkit receipt v6 reconcile all five actions to OCR's per-tool counts. Search applies the same public delta-kind semantics as list, rejects compatibility-normalized operators, and authoritative absence requires an exact component/path scope with no evidence-store admission limit, ambiguous or oversized omission, or incomplete semantic comparison. Evidence admission performs one live secret-value scan per scalar instead of repeating the same scan within a redaction pass. Only non-zero numeric counts may reach GitLab; queries, scopes, matched values, IDs, and results remain private, and incomplete coverage can never prove absence or authorize automatic approval. diff --git a/changelog.d/161.feature.md b/changelog.d/161.feature.md index 5bd0c21..dd9b6e4 100644 --- a/changelog.d/161.feature.md +++ b/changelog.d/161.feature.md @@ -1 +1 @@ -Add protected review-context policy v3 for bounded same-revision CI outcomes. Operators may map exact GitLab job names to protected path prefixes; the toolkit twice reads exact-head pipeline/job metadata and exposes only closed status, required/advisory classification, current-or-same-revision provenance, scope, and completion time through the existing private context MCP. Logs, artifacts, URLs, provider IDs, identities, variables, and raw payloads remain private and unpersisted. CI outcomes provide review context only: they cannot suppress findings, prove unrelated absence, change severity/lifecycle, or authorize approval. Policies v1/v2 remain valid without `ci_outcomes`. +Add protected review-context policy v3 for bounded same-revision CI outcomes. Operators may map exact GitLab job names to protected path prefixes; the toolkit twice reads exact-head pipeline/job metadata, ignores unrelated jobs, treats provider response ordering as non-semantic, selects the unambiguously newest requested retry, and exposes only closed status, required/advisory classification, current-or-same-revision provenance, scope, and completion time through the existing private context MCP. Hostile persisted scopes are revalidated against the protected repository-relative path grammar, and optional record rejection does not become required-source degradation. Logs, artifacts, URLs, provider IDs, identities, variables, and raw payloads remain private and unpersisted. CI outcomes provide review context only: they cannot suppress findings, prove unrelated absence, change severity/lifecycle, or authorize approval. Policies v1/v2 remain valid without `ci_outcomes`. diff --git a/compatibility/evidence/ocr-1.11.1.json b/compatibility/evidence/ocr-1.11.1.json index 3a92884..d325405 100644 --- a/compatibility/evidence/ocr-1.11.1.json +++ b/compatibility/evidence/ocr-1.11.1.json @@ -184,9 +184,26 @@ "grouping_requests": 1, "main_requests": 3, "prior_finding_semantics": "filter_survivors_as_confirmed", + "recheck_instruction_requests": 3, "result": "passed", "review_rounds": 2 }, + "language_rule_probe": { + "excluded_extensions": [ + ".svh" + ], + "extensions": [ + ".pug", + ".sv", + ".v", + ".vhd", + ".vhdl", + ".vh" + ], + "result": "passed", + "rule_source": "system_builtin", + "selected": 6 + }, "small_change_grouping_probe": { "grouping_requests": 0, "high_churn": "per_file", diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index 74fc324..18e8c41 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1393,7 +1393,7 @@ "semantic_grouping" ], "evidence": "compatibility/evidence/ocr-1.11.1.json", - "evidence_sha256": "516d78050006e216118adcc4c1fb73d1be5318e3b7b152968061bf0a34b4aecf", + "evidence_sha256": "24fc4c6850503c1dc1b613fd9792e36f4e405ca97e919ef9bfc1d1918efe5ef2", "human_conclusion": "Compatible after source and behavioral review. Toolkit consumes bounded small-change grouping, project-rule confinement, selected-file session correction, and Pug/Verilog/SystemVerilog/VHDL review coverage; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit positive completion-cap transport, medium effort, effective max-tools, private reasoning/session boundaries, receipts, DLP, and approval authority remain unchanged. The toolkit default remains unset and qualification does not recommend a provider-specific cap. Filter survivors are still labeled confirmed upstream, so toolkit treats them as unverified and requires current code, test, and trusted evidence re-checking.", "published_at": "2026-08-31T10:06:30Z", "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.1", diff --git a/docs/configuration.md b/docs/configuration.md index c84e850..f2255e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -220,7 +220,7 @@ environment variables for policy thresholds or category lists in this release. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, and count-only evidence-action receipt/lock are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt targets are rejected. In GitLab MR pipelines, the provider adapter captures the current protected target SHA, fetches that exact immutable object when needed, and materializes only an in-repository `--rule` blob from it; explicit absolute rules outside the repository remain operator-owned. OCR still reviews the original forge diff-base-to-source-head range. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. -The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. Start with `ocr_toolkit_evidence(action=summary)` once. Use its paginated/filterable `list` for a known kind or delta and stable-ID `get` only for selected records. Use `ocr_toolkit_evidence_search` only when a location or identity is unknown: its NFKC/case-folded query accepts 1–128 characters and at most eight literal tokens, rejects regex/wildcards/operators/control and bidi/format characters, searches only DLP-admitted paths, identities, and per-kind allowlisted scalars, and returns stable IDs plus closed metadata without echoing the query or matched value. Before a negative claim, call `ocr_toolkit_evidence_coverage` with an exact kind/domain and base/head ref plus optional exact component/path. Only applicable complete coverage, zero matches, and no truncation produces `absence_authoritative=true`; missing, partial, runtime-dependent, unavailable, or mismatched scope is `unknown`. Stop once the required evidence is sufficient. An explicit `kind=repository.evidence_delta` list query returns redacted base/head changes; `delta_kind` narrows them by their original fact kind, and their stable IDs can be passed to `get`. A unique semantic fact retains the established compact before/after value. If one semantic identity has multiple sources, or moves between sources, the value becomes a deterministic list of `source_path` and `fact` objects so no accepted record is overwritten. The ordinary unfiltered list remains facts and scoped coverage only. All three tools read the same committed store and have no mutation, network, arbitrary file, or shell path. Optional MCP entries can coexist with but cannot remove or shadow the mandatory server. +The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. Start with `ocr_toolkit_evidence(action=summary)` once. Use its paginated/filterable `list` for a known kind or delta and stable-ID `get` only for selected records. Use `ocr_toolkit_evidence_search` only when a location or identity is unknown: its NFKC/case-folded query accepts 1–128 characters and at most eight literal tokens, rejects regex/wildcards/operators/control and bidi/format characters, searches only DLP-admitted paths, identities, and per-kind allowlisted scalars, and returns stable IDs plus closed metadata without echoing the query or matched value. Search and list use the same delta contract: `kind=repository.evidence_delta` selects base/head deltas, optional `delta_kind` narrows their original fact kind, and a ref filter is not accepted for those cross-ref records. Before a negative claim, call `ocr_toolkit_evidence_coverage` with an exact kind/domain and base/head ref. Exact component and path are required for `absence_authoritative=true`; an omitted scope is a broad discovery query and remains `unknown`. Only applicable complete coverage, zero matches, and no store-admission or response truncation produces authoritative absence; missing, partial, runtime-dependent, unavailable, broad, mismatched, or truncated scope is `unknown`. Stop once the required evidence is sufficient. Stable IDs returned by list or search can be passed to `get`. A unique semantic fact retains the established compact before/after value. If one semantic identity has multiple sources, or moves between sources, the value becomes a deterministic list of `source_path` and `fact` objects so no accepted record is overwritten. The ordinary unfiltered list remains facts and scoped coverage only. All three tools read the same committed store and have no mutation, network, arbitrary file, or shell path. Optional MCP entries can coexist with but cannot remove or shadow the mandatory server. Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. The exact component `.` denotes the repository root, while names such as `repository` are ordinary top-level paths; the same identities filter facts, coverage, and deltas through `ocr_toolkit_evidence`. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through its summary/list/get actions. diff --git a/docs/security.md b/docs/security.md index 4dbebdd..a7773f5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -59,7 +59,7 @@ These observations establish the current safe-use envelope, not the safety of ar Server command, endpoint, setup, schemas, descriptions, arguments, and responses cross separate executable or untrusted boundaries. GitLab-MR external entries are remote HTTPS only and reject stdio/setup fields; local-profile `setup` remains operator-owned shell configuration executed in the analyzed repository. Existing OCR MCP configuration is hostile persisted input and is revalidated against the active profile. Raw endpoint, setup, and credential values are not safe diagnostic material. Toolkit diagnostics avoid them, but OCR may emit operator-owned transport details; keep those values non-sensitive and retain OCR stderr privately. Managed OAuth authenticates a client but does not authorize an object and remains conditional. -Receipt v6 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt can further attribute completed `summary`, `list`, and `get` actions after exact reconciliation with OCR's tool total; absence, malformed content, races, or mismatch remains unavailable and does not weaken the mandatory-summary gate or add an approval blocker. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes the review comment-only, independent of use; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. +Receipt v6 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt can further attribute completed `summary`, `list`, `get`, `search`, and `coverage` actions after exact reconciliation with OCR's three built-in evidence-tool totals; absence, malformed content, races, failed tool attempts, or mismatch remains unavailable and does not weaken the mandatory-summary gate or add an approval blocker. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes the review comment-only, independent of use; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. ## Preserved current safety properties diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index 267355c..cd0112e 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -415,6 +415,7 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: { "files": 4, "prior_finding_semantics": "filter_survivors_as_confirmed", + "recheck_instruction_requests": 3, } ) if contracts.get("semantic_grouping_probe") != expected_grouping_probe: @@ -428,6 +429,14 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: "threshold_files": 4, }: _fail(f"evidence does not qualify small-change grouping behavior for {version}") + if _version(version) >= (1, 11, 1) and contracts.get("language_rule_probe") != { + "excluded_extensions": [".svh"], + "extensions": [".pug", ".sv", ".v", ".vhd", ".vhdl", ".vh"], + "result": "passed", + "rule_source": "system_builtin", + "selected": 6, + }: + _fail(f"evidence does not qualify built-in language rules for {version}") if contracts.get("completion_cap_probe") != { "explicit": 4_096, "inherited": 16_384, diff --git a/src/ocr_toolkit/context/policy.py b/src/ocr_toolkit/context/policy.py index 082f1c3..d84a964 100644 --- a/src/ocr_toolkit/context/policy.py +++ b/src/ocr_toolkit/context/policy.py @@ -214,16 +214,16 @@ def _remediation_threads(value: object) -> RemediationThreadPolicy: ) -def _ci_path_prefix(value: object) -> str: +def normalize_ci_path_prefix(value: object) -> str: """Validate one protected repository-relative POSIX path prefix.""" if ( not isinstance(value, str) or not 1 <= len(value) <= 256 - or len(value.encode("utf-8")) > 1_024 or "\\" in value or "://" in value - or any(unicodedata.category(character) in {"Cc", "Cf"} for character in value) + or any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in value) + or len(value.encode("utf-8")) > 1_024 ): raise ContextContractError("ci_outcomes path prefix is invalid") trailing = value.endswith("/") @@ -258,15 +258,15 @@ def _ci_outcomes(value: object) -> CIOutcomePolicy: if ( not isinstance(name, str) or not 1 <= len(name) <= 128 - or len(name.encode("utf-8")) > 512 or "://" in name - or any(unicodedata.category(character) in {"Cc", "Cf"} for character in name) + or any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in name) + or len(name.encode("utf-8")) > 512 or not isinstance(prefixes, list) or not prefixes or len(prefixes) > 32 ): raise ContextContractError("ci_outcomes check is invalid") - normalized = tuple(_ci_path_prefix(prefix) for prefix in prefixes) + normalized = tuple(normalize_ci_path_prefix(prefix) for prefix in prefixes) if list(normalized) != sorted(set(normalized)): raise ContextContractError("ci_outcomes path prefixes must be sorted and unique") parsed.append(CIOutcomeCheckPolicy(name=name, path_prefixes=normalized)) diff --git a/src/ocr_toolkit/context/store.py b/src/ocr_toolkit/context/store.py index b865b77..f94fa39 100644 --- a/src/ocr_toolkit/context/store.py +++ b/src/ocr_toolkit/context/store.py @@ -26,6 +26,7 @@ TextBudgets, ) from ocr_toolkit.context.dlp import check_text, normalize_text +from ocr_toolkit.context.policy import normalize_ci_path_prefix MAX_STORE_BYTES = 4_000_000 MAX_STORE_RECORDS = 128 @@ -301,14 +302,16 @@ def _ci_outcome_projection(value: object) -> Mapping[str, object]: normalized: list[str] = [] for prefix in prefixes: value = normalize_text(prefix) + try: + canonical = normalize_ci_path_prefix(value) + except (ContextStoreError, ValueError): + raise ContextStoreError("CI outcome path prefix is invalid") from None if ( - value != prefix - or not isinstance(value, str) - or not 1 <= len(value) <= 256 - or not check_text(value, budgets=TextBudgets(256, 1_024, 1)).admitted + canonical != prefix + or not check_text(canonical, budgets=TextBudgets(256, 1_024, 1)).admitted ): raise ContextStoreError("CI outcome path prefix is invalid") - normalized.append(value) + normalized.append(canonical) if normalized != sorted(set(normalized)): raise ContextStoreError("CI outcome path prefixes are not canonical") return { diff --git a/src/ocr_toolkit/evidence/collectors/projections.py b/src/ocr_toolkit/evidence/collectors/projections.py index 68fdfee..1b49ee9 100644 --- a/src/ocr_toolkit/evidence/collectors/projections.py +++ b/src/ocr_toolkit/evidence/collectors/projections.py @@ -110,17 +110,10 @@ def projected_values( for key in sorted(set(base) | set(head)): before = base.get(key) after = head.get(key) - if key[0] in incomplete_kinds and ( - before is None - or after is None - or len(before) != 1 - or len(after) != 1 - or before[0].source_path != after[0].source_path - ): - # Once admission is incomplete, a one-sided or ambiguous identity may - # be an omitted peer rather than a real add/remove/move. A singular - # identity admitted from the same source on both refs remains directly - # comparable and can still expose a real value change. + if key[0] in incomplete_kinds: + # Kind-level admission failure cannot identify which semantic key was + # omitted. Even a remaining same-source pair may have lost a third + # value for this identity, so no delta for that kind is authoritative. continue before_value = projected_values(before, after) after_value = projected_values(after, before) diff --git a/src/ocr_toolkit/evidence/mcp.py b/src/ocr_toolkit/evidence/mcp.py index bcd0d99..9cd1bfd 100644 --- a/src/ocr_toolkit/evidence/mcp.py +++ b/src/ocr_toolkit/evidence/mcp.py @@ -311,6 +311,11 @@ def _search_query(value: object) -> tuple[str, ...]: normalized = unicodedata.normalize("NFKC", value).casefold().strip() if len(normalized) > MAX_SEARCH_QUERY_CHARS: raise EvidenceMCPError("normalized query exceeds the character limit") + if any( + character in _SEARCH_OPERATOR_CHARACTERS or unicodedata.category(character) in {"Cc", "Cf"} + for character in normalized + ): + raise EvidenceMCPError("query must be literal text without operators or controls") tokens = tuple(normalized.split()) if not tokens or len(tokens) > MAX_SEARCH_QUERY_TOKENS: raise EvidenceMCPError( @@ -363,8 +368,13 @@ def _search_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[ tokens = _search_query(arguments.get("query")) kind = _optional_filter(arguments, "kind") + delta_kind = _optional_filter(arguments, "delta_kind") component = _optional_filter(arguments, "component") ref = _optional_filter(arguments, "ref") + if delta_kind is not None and kind != "repository.evidence_delta": + raise EvidenceMCPError("delta_kind requires kind=repository.evidence_delta") + if kind == "repository.evidence_delta" and ref is not None: + raise EvidenceMCPError("evidence deltas span base and head and do not accept ref") if ref not in {None, "base", "head", "policy", "shared"}: raise EvidenceMCPError("ref must be base, head, policy, or shared") max_results = arguments.get("max_results", DEFAULT_SEARCH_RESULTS) @@ -376,13 +386,18 @@ def _search_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[ matches: list[dict[str, object]] = [] total_matches = 0 for record in candidates: - record_kind = record.kind - record_ref = None if isinstance(record, EvidenceDelta) else record.ref.value - if ( - (kind is not None and record_kind != kind) - or (component is not None and record.component != component) - or (ref is not None and record_ref != ref) - ): + if isinstance(record, EvidenceDelta): + if kind not in {None, "repository.evidence_delta"}: + continue + if delta_kind is not None and record.kind != delta_kind: + continue + if ref is not None: + continue + elif kind is not None and record.kind != kind: + continue + if component is not None and record.component != component: + continue + if not isinstance(record, EvidenceDelta) and ref is not None and record.ref.value != ref: continue fields = _normalized_search_fields(record) if not all(any(token in field for field in fields) for token in tokens): @@ -430,25 +445,47 @@ def _coverage_query(store: EvidenceStore, arguments: dict[str, object]) -> dict[ and (component is None or record.component == component) and (source_path is None or record.source_path == source_path) ] - applicable = [ - record - for record in store.coverage - if record.domain == kind - and record.ref.value == ref - and (component is None or record.component == component) - and (source_path is None or record.scope == source_path) - ] + exact_scope = component is not None and source_path is not None + applicable = ( + [ + record + for record in store.coverage + if record.domain == kind + and record.ref.value == ref + and record.component == component + and record.scope == source_path + ] + if exact_scope + else [] + ) state_counts: dict[str, int] = {} for record in applicable: state_counts[record.state.value] = state_counts.get(record.state.value, 0) + 1 - complete = bool(applicable) and all(record.state.value == "complete" for record in applicable) + diagnostics = set(store.diagnostics) + incomplete = any( + diagnostic + in { + "global evidence record limit reached", + f"per-kind evidence record limit reached for {kind}", + f"omitted ambiguous {kind} evidence value", + f"omitted oversized {kind} evidence value", + f"typed {kind} comparison incomplete; unsafe semantic deltas omitted", + } + for diagnostic in diagnostics + ) + complete = ( + exact_scope + and bool(applicable) + and not incomplete + and all(record.state.value == "complete" for record in applicable) + ) return { "schema_version": "ocr.evidence-coverage-query/v1", "state": "complete" if complete else "unknown", "matches": len(matching_records), "coverage_records": len(applicable), "coverage_states": dict(sorted(state_counts.items())), - "truncated": False, + "truncated": incomplete, "absence_authoritative": complete and not matching_records, } @@ -498,7 +535,14 @@ def call_named_tool(store: EvidenceStore, name: str, arguments: object) -> dict[ raise EvidenceMCPError("tool arguments must be an object") typed = cast(dict[str, object], arguments) if name == SEARCH_TOOL_NAME: - unknown = set(typed) - {"query", "kind", "component", "ref", "max_results"} + unknown = set(typed) - { + "query", + "kind", + "delta_kind", + "component", + "ref", + "max_results", + } if unknown: raise EvidenceMCPError(f"unsupported tool argument: {sorted(unknown)[0]}") return _text_result(_search_records(store, typed)) @@ -590,8 +634,9 @@ def _search_tool_definition() -> dict[str, object]: "description": ( "Locate unknown evidence records by bounded literal text after using the summary. " "Search covers admitted paths, identities, and per-kind allowlisted scalar fields; " - "results contain stable IDs but never matched values. Use the primary evidence tool " - "with action=get for a selected ID." + "results contain stable IDs but never matched values. Use " + "kind=repository.evidence_delta with optional delta_kind for base/head changes. " + "Use the primary evidence tool with action=get for a selected ID." ), "inputSchema": { "type": "object", @@ -605,6 +650,14 @@ def _search_tool_definition() -> dict[str, object]: "description": "One to eight literal NFKC/case-insensitive tokens; no operators.", }, "kind": {"type": "string", "maxLength": 256}, + "delta_kind": { + "type": "string", + "maxLength": 256, + "description": ( + "Optional original fact-kind filter with " + "kind=repository.evidence_delta only." + ), + }, "component": {"type": "string", "maxLength": 256}, "ref": {"type": "string", "enum": ["base", "head", "policy", "shared"]}, "max_results": { @@ -625,7 +678,8 @@ def _coverage_tool_definition() -> dict[str, object]: "name": COVERAGE_TOOL_NAME, "description": ( "Check exact scoped evidence completeness before making a negative claim. " - "absence_authoritative is true only for applicable complete coverage with zero " + "absence_authoritative is true only for an exact component/path scope with " + "applicable complete coverage and zero " "matching records and no truncation; every missing, partial, runtime-dependent, " "or unavailable scope returns unknown." ), @@ -640,11 +694,18 @@ def _coverage_tool_definition() -> dict[str, object]: "description": "Exact evidence kind and coverage domain.", }, "ref": {"type": "string", "enum": ["base", "head"]}, - "component": {"type": "string", "maxLength": 256}, + "component": { + "type": "string", + "maxLength": 256, + "description": "Exact evidence component required for authoritative absence.", + }, "path": { "type": "string", "maxLength": 256, - "description": "Optional exact record source path and coverage scope.", + "description": ( + "Exact record source path and coverage scope required for " + "authoritative absence." + ), }, }, }, diff --git a/src/ocr_toolkit/evidence/store/values.py b/src/ocr_toolkit/evidence/store/values.py index 8c1f56a..e3af303 100644 --- a/src/ocr_toolkit/evidence/store/values.py +++ b/src/ocr_toolkit/evidence/store/values.py @@ -8,7 +8,6 @@ from ocr_toolkit.common.redaction import ( SENSITIVE_NAMED_KEY_PATTERN, - redact_env_secret_values, redact_sensitive, ) from ocr_toolkit.evidence.model import EvidenceValue @@ -23,13 +22,13 @@ def redact_value(value: EvidenceValue) -> EvidenceValue: """Recursively redact string leaves before evidence reaches persistent storage.""" if isinstance(value, str): - return redact_env_secret_values(redact_sensitive(value)) + return redact_sensitive(value) if isinstance(value, (list, tuple)): return [redact_value(item) for item in value] if isinstance(value, Mapping): redacted_mapping: dict[str, EvidenceValue] = {} for key, item in value.items(): - redacted_key = redact_env_secret_values(redact_sensitive(key)) + redacted_key = redact_sensitive(key) if not redacted_key: raise EvidenceValueRedactionError("evidence object key is empty after redaction") if redacted_key in redacted_mapping: @@ -61,13 +60,13 @@ def safe_diagnostic(message: object) -> str: if not isinstance(message, str) or not message or len(message) > 1024: raise EvidenceStoreError("evidence diagnostic must contain between 1 and 1024 characters") - return redact_env_secret_values(redact_sensitive(message)) + return redact_sensitive(message) def safe_delta_metadata(value: str, *, name: str, max_chars: int) -> str: """Redact and bound one repository-derived delta metadata field.""" - redacted = redact_env_secret_values(redact_sensitive(value)) + redacted = redact_sensitive(value) if not redacted or len(redacted) > max_chars: raise EvidenceStoreError(f"evidence delta {name} exceeds its metadata budget") return redacted diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index cdf5b22..df97cd7 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -531,6 +531,7 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: and not isinstance(evidence_calls, bool) and 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT and sum(action_counts) == evidence_calls + and (evidence_calls == 0 or actions["summary"] >= 1) ): return "\n".join(lines) positive = [action for action in EVIDENCE_ACTIONS if actions[action] > 0] diff --git a/src/ocr_toolkit/providers/gitlab_ci.py b/src/ocr_toolkit/providers/gitlab_ci.py index 6e85b97..d45f4db 100644 --- a/src/ocr_toolkit/providers/gitlab_ci.py +++ b/src/ocr_toolkit/providers/gitlab_ci.py @@ -124,7 +124,7 @@ def _raw_snapshot( if not isinstance(pipelines, list) or len(pipelines) > MAX_PIPELINES: raise GitLabProviderError("GitLab CI pipeline list is invalid") requested = {check.name: check.path_prefixes for check in policy.checks} - candidates: dict[str, list[tuple[int, int, str, bool]]] = {name: [] for name in requested} + candidates: dict[str, list[tuple[int, int, int, str, bool]]] = {name: [] for name in requested} invalid_names: set[str] = set() structural: list[object] = [] current_pipeline = int(_numeric_identifier(environment, "CI_PIPELINE_ID")) @@ -148,13 +148,13 @@ def _raw_snapshot( if not isinstance(jobs, list) or len(jobs) > MAX_JOBS_PER_PIPELINE: raise GitLabProviderError("GitLab CI job list is invalid") omitted += int(jobs_more) - per_pipeline: set[str] = set() for job in jobs: if not isinstance(job, Mapping): raise GitLabProviderError("GitLab CI job is invalid") - name = _bounded_name(job.get("name")) - if name not in requested: + raw_name = job.get("name") + if not isinstance(raw_name, str) or raw_name not in requested: continue + name = _bounded_name(raw_name) nested = job.get("pipeline") if ( not isinstance(nested, Mapping) @@ -163,10 +163,7 @@ def _raw_snapshot( ): invalid_names.add(name) continue - if name in per_pipeline: - invalid_names.add(name) - continue - per_pipeline.add(name) + job_id = _positive_id(job.get("id")) status = job.get("status") allow_failure = job.get("allow_failure") completed_at = timestamp(job.get("finished_at")) @@ -182,8 +179,8 @@ def _raw_snapshot( ): invalid_names.add(name) continue - candidates[name].append((completed_at, pipeline_id, status, allow_failure)) - structural.append([pipeline_id, name, status, allow_failure, completed_at]) + candidates[name].append((completed_at, job_id, pipeline_id, status, allow_failure)) + structural.append([pipeline_id, job_id, name, status, allow_failure, completed_at]) records: list[CIOutcome] = [] for name, path_prefixes in requested.items(): selected = sorted(candidates[name], reverse=True) @@ -191,10 +188,10 @@ def _raw_snapshot( invalid += 1 continue newest = selected[0] - if len(selected) > 1 and selected[1][0] == newest[0]: + if len(selected) > 1 and selected[1][:2] == newest[:2]: invalid += 1 continue - completed_at, pipeline_id, raw_status, allow_failure = newest + completed_at, _job_id, pipeline_id, raw_status, allow_failure = newest if now - completed_at > policy.max_age_seconds: invalid += 1 continue @@ -219,9 +216,18 @@ def _raw_snapshot( ).hexdigest() records.append( CIOutcome( + check=name, + status=status, + requirement="advisory" if allow_failure else "required", + path_prefixes=tuple(path_prefixes), + origin=( + "current_pipeline" + if pipeline_id == current_pipeline + else "same_revision_pipeline" + ), + completed_at=completed_at, version=str(completed_at), digest=digest, - **normalized, # type: ignore[arg-type] ) ) omitted += len(requested) - len(records) - invalid @@ -229,8 +235,8 @@ def _raw_snapshot( snapshot_digest = hashlib.sha256( json.dumps( { - "pipelines": structural, - "records": [record.digest for record in records], + "pipelines": sorted(structural), + "records": sorted(record.digest for record in records), "state": state, }, sort_keys=True, diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index ac807f7..af28921 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -402,6 +402,12 @@ def _review_receipt( evidence_used = isinstance(evidence_calls, int) and evidence_calls > 0 if outcome.requires_evidence_mcp and not evidence_by_tool[TOOL_NAME]: raise ReviewRunnerError(f"OCR review did not call the mandatory {TOOL_NAME} tool") + if ( + outcome.requires_evidence_mcp + and evidence_action_counts is not None + and evidence_action_counts.get("summary", 0) < 1 + ): + raise ReviewRunnerError("OCR review did not call the mandatory evidence summary action") action_attribution: dict[str, object] if evidence_calls == 0 and evidence_action_counts is None: action_attribution = { @@ -1807,7 +1813,7 @@ def _prepare_enrichment( if len(ci_records) != len(ci_snapshot.records): completeness[ci_origin] = "partial" degradation["invalid"] += len(ci_snapshot.records) - len(ci_records) - required_degraded = True + required_degraded = required_degraded or ci_policy.required pending.extend(ci_records) selections = _select_reference_candidates(policy, candidate_texts) external: BrokerResult = acquire_external_records( @@ -1858,6 +1864,14 @@ def _prepare_enrichment( for field, value in record.projections["model"].items(): if field in published: continue + if field == CI_OUTCOME_MODEL_FIELD and isinstance(value, dict): + scope = value.get("scope") + value = { + "check": value.get("check"), + "path_prefixes": ( + scope.get("path_prefixes") if isinstance(scope, dict) else None + ), + } stack = [value] while stack: nested = stack.pop() diff --git a/tests/test_context_policy.py b/tests/test_context_policy.py index 465c378..6bac91f 100644 --- a/tests/test_context_policy.py +++ b/tests/test_context_policy.py @@ -179,6 +179,12 @@ def test_policy_v3_adds_exact_ci_scopes_with_conservative_defaults() -> None: lambda value: value["ci_outcomes"].update( {"checks": [{"name": "unsafe\u202e", "path_prefixes": ["src/"]}]} ), + lambda value: value["ci_outcomes"].update( + {"checks": [{"name": "unsafe\ud800", "path_prefixes": ["src/"]}]} + ), + lambda value: value["ci_outcomes"].update( + {"checks": [{"name": "functional-tests", "path_prefixes": ["src/\ud800"]}]} + ), ], ) def test_policy_v3_rejects_ci_authority_expansion(mutation: object) -> None: diff --git a/tests/test_context_store.py b/tests/test_context_store.py index 332a48d..67ba137 100644 --- a/tests/test_context_store.py +++ b/tests/test_context_store.py @@ -270,6 +270,10 @@ def test_context_store_admits_only_closed_immutable_ci_outcome(tmp_path: Path) - lambda outcome: outcome.update({"requirement": "optional"}), lambda outcome: outcome.update({"origin": "pipeline-7"}), lambda outcome: outcome["scope"].update({"path_prefixes": ["tests/", "src/"]}), + lambda outcome: outcome["scope"].update({"path_prefixes": ["/src/"]}), + lambda outcome: outcome["scope"].update({"path_prefixes": ["src/../tests/"]}), + lambda outcome: outcome["scope"].update({"path_prefixes": ["src//tests/"]}), + lambda outcome: outcome["scope"].update({"path_prefixes": ["src\\tests/"]}), lambda outcome: outcome.update({"completed_at": 201}), ], ) diff --git a/tests/test_evidence_collectors.py b/tests/test_evidence_collectors.py index c7a2a92..b0c1bdb 100644 --- a/tests/test_evidence_collectors.py +++ b/tests/test_evidence_collectors.py @@ -959,7 +959,7 @@ def fact(ref: RefRole, version: str = "1.0.0") -> EvidenceRecord: (fact(RefRole.HEAD),), incomplete_kinds={"dependency.locked"}, ) - comparable_change = fact_deltas( + suppressed_change = fact_deltas( (fact(RefRole.BASE), fact(RefRole.HEAD, "2.0.0")), incomplete_kinds={"dependency.locked"}, ) @@ -971,9 +971,7 @@ def fact(ref: RefRole, version: str = "1.0.0") -> EvidenceRecord: ("removed", {"version": "1.0.0"}, None) ] assert suppressed_add == () - assert [(delta.change, delta.before, delta.after) for delta in comparable_change] == [ - ("changed", {"version": "1.0.0"}, {"version": "2.0.0"}) - ] + assert suppressed_change == () def test_image_facts_accept_yaml_sequence_items(tmp_path: Path) -> None: diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py index 6e10f3d..112d072 100644 --- a/tests/test_evidence_mcp.py +++ b/tests/test_evidence_mcp.py @@ -17,6 +17,7 @@ EvidenceDelta, EvidenceRecord, EvidenceStore, + EvidenceStoreLimits, RefRole, TrustClass, ) @@ -28,6 +29,7 @@ SEARCH_TOOL_NAME, SUPPORTED_PROTOCOL_VERSIONS, TOOL_NAME, + call_named_tool, call_tool, handle_request, serve, @@ -324,6 +326,8 @@ def test_literal_search_returns_only_stable_metadata_and_normalizes_unicode() -> [ "", "name:*", + "name\uff1apackage", + "package\uff0a", "a OR b", "one two three four five six seven eight nine", "safe\u202eunsafe", @@ -367,6 +371,71 @@ def test_search_truncation_is_explicit_and_deterministic() -> None: assert payload["truncated"] is True +def test_search_filters_deltas_by_public_kind_and_original_delta_kind() -> None: + """Keep delta search filters aligned with the primary list contract.""" + + store = _store(0) + store.deltas = ( + EvidenceDelta( + kind="dependency.declared", + component="python", + identity="requirements.txt:package", + change="changed", + before={"name": "package", "version": "1.0"}, + after={"name": "package", "version": "2.0"}, + ), + ) + + payload = _payload( + handle_request( + store, + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": SEARCH_TOOL_NAME, + "arguments": { + "query": "package", + "kind": "repository.evidence_delta", + "delta_kind": "dependency.declared", + }, + }, + }, + )["result"] # type: ignore[index] + ) + + assert payload["total_matches"] == 1 + assert payload["matches"][0]["kind"] == "repository.evidence_delta" # type: ignore[index] + assert payload["matches"][0]["delta_kind"] == "dependency.declared" # type: ignore[index] + + +@pytest.mark.parametrize( + "arguments", + [ + {"query": "package", "delta_kind": "dependency.declared"}, + { + "query": "package", + "kind": "repository.evidence_delta", + "ref": "head", + }, + ], +) +def test_search_rejects_incoherent_delta_filters(arguments: dict[str, object]) -> None: + """Reject delta-only filters that cannot identify a coherent search domain.""" + + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": SEARCH_TOOL_NAME, "arguments": arguments}, + }, + ) + assert result is not None and result["result"]["isError"] is True # type: ignore[index] + + def test_coverage_tool_proves_only_complete_exact_zero_match_scope() -> None: """Distinguish authoritative absence from missing, partial, and positive evidence.""" @@ -454,6 +523,124 @@ def test_coverage_tool_proves_only_complete_exact_zero_match_scope() -> None: assert unknown_payload["absence_authoritative"] is False +def test_coverage_broad_or_admission_truncated_queries_cannot_prove_absence() -> None: + """Require an exact untruncated scope before publishing authoritative absence.""" + + coverage = CoverageRecord( + component="python", + domain="dependency.declared", + scope="requirements.txt", + state=CoverageState.COMPLETE, + reasons=("bounded-source-complete",), + ref=RefRole.HEAD, + commit_sha=SHA, + ) + broad_store = _store(0) + assert broad_store.add_coverage(coverage) + broad = _payload( + handle_request( + broad_store, + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": COVERAGE_TOOL_NAME, + "arguments": {"kind": "dependency.declared", "ref": "head"}, + }, + }, + )["result"] # type: ignore[index] + ) + assert broad["state"] == "unknown" + assert broad["coverage_records"] == 0 + assert broad["absence_authoritative"] is False + + limited = EvidenceStore(limits=EvidenceStoreLimits(max_records=2, max_records_per_kind=1)) + assert limited.add_coverage(coverage) + assert limited.add( + EvidenceRecord( + kind="dependency.declared", + value={"name": "other"}, + source_path="other.txt", + ref=RefRole.HEAD, + commit_sha=SHA, + component="python", + provenance="synthetic parser", + trust=TrustClass.SOURCE_REPOSITORY, + ) + ) + assert not limited.add( + EvidenceRecord( + kind="dependency.declared", + value={"name": "dropped"}, + source_path="requirements.txt", + ref=RefRole.HEAD, + commit_sha=SHA, + component="python", + provenance="synthetic parser", + trust=TrustClass.SOURCE_REPOSITORY, + ) + ) + truncated = _payload( + handle_request( + limited, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": COVERAGE_TOOL_NAME, + "arguments": { + "kind": "dependency.declared", + "ref": "head", + "component": "python", + "path": "requirements.txt", + }, + }, + }, + )["result"] # type: ignore[index] + ) + assert truncated["state"] == "unknown" + assert truncated["truncated"] is True + assert truncated["absence_authoritative"] is False + + +@pytest.mark.parametrize("reason", ["ambiguous", "oversized"]) +def test_coverage_rejects_authoritative_absence_after_value_omission(reason: str) -> None: + """Treat every same-kind admission failure as incomplete scoped coverage.""" + + store = _store(0) + assert store.add_coverage( + CoverageRecord( + component="python", + domain="dependency.declared", + scope="requirements.txt", + state=CoverageState.COMPLETE, + reasons=("bounded-source-complete",), + ref=RefRole.HEAD, + commit_sha=SHA, + ) + ) + store.add_diagnostic(f"omitted {reason} dependency.declared evidence value") + + payload = _payload( + call_named_tool( + store, + COVERAGE_TOOL_NAME, + { + "kind": "dependency.declared", + "ref": "head", + "component": "python", + "path": "requirements.txt", + }, + ) + ) + + assert payload["state"] == "unknown" + assert payload["truncated"] is True + assert payload["absence_authoritative"] is False + + @pytest.mark.parametrize( "arguments", [ diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py index def327b..386f34d 100644 --- a/tests/test_evidence_model.py +++ b/tests/test_evidence_model.py @@ -10,6 +10,7 @@ import pytest +from ocr_toolkit.common.redaction import redact_sensitive from ocr_toolkit.evidence import ( CoverageRecord, CoverageState, @@ -318,6 +319,25 @@ def test_store_redacts_before_persistence_and_round_trips( assert restored.records[0].sensitivity.value == "redacted" +def test_store_scans_live_secret_values_once_per_string_leaf( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Avoid repeating the same live environment scan inside one redaction pass.""" + + calls = 0 + + def counted_redaction(value: str) -> str: + nonlocal calls + calls += 1 + return redact_sensitive(value) + + monkeypatch.setattr("ocr_toolkit.evidence.store.values.redact_sensitive", counted_redaction) + store = EvidenceStore() + + assert store.add(record("safe public value")) + assert calls == 1 + + def test_store_redacts_sensitive_mapping_keys() -> None: """Remove credentials whose key declares sensitivity even when the value is novel.""" diff --git a/tests/test_gitlab_ci.py b/tests/test_gitlab_ci.py index 91ebb03..5cce08c 100644 --- a/tests/test_gitlab_ci.py +++ b/tests/test_gitlab_ci.py @@ -29,11 +29,12 @@ def _job( status: str = "success", allow_failure: bool = False, finished_at: str = "2027-01-15T08:00:00Z", + job_id: int | None = None, ) -> dict[str, object]: """Build one minimal GitLab-style job response.""" return { - "id": pipeline_id * 100, + "id": pipeline_id * 100 if job_id is None else job_id, "name": name, "status": status, "allow_failure": allow_failure, @@ -88,6 +89,7 @@ def test_gitlab_ci_admits_only_exact_scoped_current_and_same_revision_outcomes( ("functional-tests", "same_revision_pipeline", "passed"), ("package", "current_pipeline", "passed"), ] + assert all(isinstance(record.path_prefixes, tuple) for record in snapshot.records) assert all("private" not in record.digest for record in snapshot.records) @@ -132,6 +134,101 @@ def test_gitlab_ci_rejects_wrong_revision_and_ambiguous_retries( assert snapshot.invalid >= 1 +def test_gitlab_ci_selects_newest_unambiguous_retry_and_ignores_unrequested_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Use the newest requested retry without letting unrelated names degrade evidence.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + jobs = { + 10: [ + _job("x" * 1024, pipeline_id=10), + _job( + "functional-tests", + pipeline_id=10, + status="failed", + finished_at="2027-01-15T07:59:00Z", + job_id=1001, + ), + _job("functional-tests", pipeline_id=10, job_id=1002), + _job("package", pipeline_id=10), + ] + } + monkeypatch.setattr( + gitlab_ci, + "_read_page", + _reader([{"id": 10, "sha": HEAD}], jobs), + ) + + snapshot, _digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + + assert snapshot.state == "complete" + assert {record.check: record.status for record in snapshot.records} == { + "functional-tests": "passed", + "package": "passed", + } + + +def test_gitlab_ci_snapshot_digest_is_independent_of_provider_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not report mutation when GitLab reorders identical pipelines or jobs.""" + + policy = parse_policy(encoded_policy(ci_policy_value())).ci_outcomes + assert policy is not None + pipelines = [{"id": 10, "sha": HEAD}, {"id": 11, "sha": HEAD}] + jobs = { + 10: [_job("package", pipeline_id=10), _job("functional-tests", pipeline_id=10)], + 11: [ + _job( + "functional-tests", + pipeline_id=11, + finished_at="2027-01-15T07:59:00Z", + ), + _job( + "package", + pipeline_id=11, + finished_at="2027-01-15T07:59:00Z", + ), + ], + } + monkeypatch.setattr(gitlab_ci, "_read_page", _reader(pipelines, jobs)) + first, first_digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + monkeypatch.setattr( + gitlab_ci, + "_read_page", + _reader( + list(reversed(pipelines)), {key: list(reversed(value)) for key, value in jobs.items()} + ), + ) + second, second_digest = gitlab_ci._raw_snapshot( + ENVIRONMENT, + project_id="7", + source_sha=HEAD, + policy=policy, + now=NOW, + deadline=NOW, + ) + + assert second == first + assert second_digest == first_digest + + def test_gitlab_ci_preserves_advisory_and_unknown_without_claiming_success( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index ac92846..138f0ed 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -3183,14 +3183,23 @@ def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> N "search": 0, "coverage": 0, }, + { + "state": "verified", + "summary": 0, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + }, ): with self.subTest(actions=actions): + calls = sum(value for key, value in actions.items() if key != "state") self.assertEqual( posting_formatting.format_mcp_usage_summary( { "schema_version": 6, "mcp": {"usage": {"ocr_toolkit_evidence": 3}}, - "evidence": {"calls": 0, "actions": actions}, + "evidence": {"calls": calls, "actions": actions}, } ), "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)", diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 0171531..6cc4eca 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -132,6 +132,45 @@ def test_enrichment_admits_provider_neutral_ci_without_approval_authority( assert receipt.required_degraded is False assert receipt.mutable_admitted is False assert receipt.bootstrap_hints == {"required_passed": 1} + assert "functional-tests" in receipt.forbidden_publication + assert "src/" in receipt.forbidden_publication + assert "tests/" in receipt.forbidden_publication + assert not { + "passed", + "required", + "reviewed_head", + "declared", + "same_revision_pipeline", + } & set(receipt.forbidden_publication) + payload = { + "status": "complete", + "comments": [{"content": "The required functional check passed."}], + "warnings": [], + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "complete", + "coverage": { + "selected": [{"item_id": "item-1"}], + "completed": [{"item_id": "item-1"}], + "reused": [], + "failed": [], + "waived": [], + }, + }, + "tool_calls": { + "total": 1, + "by_tool": {"ocr_toolkit_evidence": 1}, + }, + } + projected, publication, filtered = review_runner._publication_projection( + payload, + forbidden=receipt.forbidden_publication, + allowed_tools=frozenset({"ocr_toolkit_evidence"}), + ) + assert projected is payload + assert publication == {"state": "passed"} + assert filtered is False restored = ContextStore.read( artifacts.context_store, expected_run_id=context_config.run_id, @@ -142,6 +181,41 @@ def test_enrichment_admits_provider_neutral_ci_without_approval_authority( assert "pipeline_id" not in artifacts.context_store.read_text(encoding="utf-8") +def test_optional_ci_record_rejection_does_not_degrade_required_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep malformed optional CI evidence visible without treating it as required.""" + + policy = ci_policy_value() + policy.pop("forge_discussions") + policy.pop("remediation_threads") + policy["references"] = [] + snapshot = CIOutcomeSnapshot("complete", (ci_outcome(),), 0, 0) + monkeypatch.setattr(review_runner.time, "time", lambda: 150) + monkeypatch.setattr(review_runner, "prepare_ci_outcome_records", lambda *_args, **_kwargs: ()) + artifacts = configure_enrichment_test( + tmp_path, + monkeypatch, + provider_acquire=lambda *_args, **_kwargs: None, + external_acquire=lambda **_kwargs: BrokerResult( + (), {}, {"invalid": 0, "limit": 0, "unavailable": 0}, False + ), + policy_value=policy, + ci_acquire=lambda *_args, **_kwargs: snapshot, + ) + + _context_config, receipt = review_runner._prepare_enrichment( + enriched_identity(), + artifacts, + SimpleNamespace(read_blob=lambda *_args: b""), # type: ignore[arg-type] + ) + + assert receipt is not None + assert receipt.completeness == {"forge:ci_outcomes": "partial"} + assert receipt.degradation_counts["invalid"] == 1 + assert receipt.required_degraded is False + + def test_default_termination_signal_is_translated_for_cleanup() -> None: previous = review_runner._install_termination_handlers() try: @@ -720,6 +794,46 @@ def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( } +def test_mandatory_evidence_rejects_available_receipt_without_summary( + tmp_path: Path, +) -> None: + """Do not let list/get calls impersonate the mandatory summary action.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": { + "total": 2, + "by_tool": {"ocr_toolkit_evidence": 2}, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + with pytest.raises(review_runner.ReviewRunnerError, match="mandatory evidence summary"): + review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts={ + "summary": 0, + "list": 1, + "get": 1, + "search": 0, + "coverage": 0, + }, + ) + + def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( tmp_path: Path, ) -> None: