From 9fddfcc8b8e82b0ba524dfeb7d3125d72d895041 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Thu, 10 Sep 2026 15:44:04 -0400 Subject: [PATCH] feat(OMN-18135): the closer joins readbacks to state-shaped criteria, and names the bar Implements AC3 and the closer half of AC4 from the ruling recorded at docs/tracking/ROLLING_WORK_LEDGER.md:6148 (orchestrator, under the deterministic-truth doctrine; not an operator ruling). omnimarket#2460 carries the proof class. This carries the JOIN, because deciding whether a criterion is state-shaped needs the ticket's acceptance text and node_dod_verify never sees it. It moves in BOTH directions, deliberately. LOOSER: a ticket whose every parsed criterion asserts live state, each discharged by a verified readback, no longer dies on behavior_proving_count <= 0. That is the case the ruling exists to admit. Bounded to ALL criteria: one behaviour-shaped criterion, or a body whose criteria cannot be read, and the behaviour proof is still required. A ticket is judged by its strictest criterion, never on average. STRICTER: a behaviour-shaped criterion whose only verified checks are readbacks now HOLDS. Binding used to key on status alone and never on proof class, so a readback silently discharged a criterion asserting what the code does. That hole is invisible to every test that has no behaviour proof at all, so it has its own test with a real proof beside it. Deliberately untouched: merge-state, surrogate and indeterminate checks bind exactly as before. Narrowing those is a different argument nobody has made, and making it here would retroactively un-close tickets that flipped on that basis. AC3: the gap_no_behavior_proof comment now names the bar -- which command shapes prove behaviour, which prefixes are transparent, that a live probe through ssh/aws/kubectl/curl/a shell loop is not behaviour-proving, and that an asserted readback is the admissible route for live state. A hold that states a verdict without a remedy teaches nobody anything, which is what three lanes spent a night rediscovering by measurement. HONEST LIMIT, in the code and not only in the PR: criterion shape is read from prose and is a heuristic. State-shaped requires a positive state marker AND no behaviour marker; everything else falls to behaviour and holds. The asymmetry is chosen -- reading a criterion as behaviour-shaped costs a comment, reading one as state-shaped RELEASES a flip on a readback -- so the vocabulary earns its way in one phrase at a time and the veto wins ties. node_version 1.11.0 -> 1.12.0, so the gap-comment fingerprint moves with the rule it describes. --- .../contract.yaml | 36 +- .../handler_evidence_autoclose_sweep.py | 194 ++++++- .../test_omn_18135_readback_join.py | 513 ++++++++++++++++++ 3 files changed, 733 insertions(+), 10 deletions(-) create mode 100644 tests/unit/nodes/node_evidence_autoclose_sweep_effect/test_omn_18135_readback_join.py diff --git a/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/contract.yaml b/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/contract.yaml index 86fd86cb9e..0c1539281f 100644 --- a/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/contract.yaml +++ b/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/contract.yaml @@ -300,13 +300,47 @@ # which verdicts the sweep is willing to release, so the gap-comment # fingerprint version moves with the node_version. # +# - OMN-18135 (node_version 1.12.0): AN ASSERTED LIVE READBACK IS ADMISSIBLE +# FOR A CRITERION THAT ASSERTS LIVE STATE. Orchestrator ruling under the +# deterministic-truth doctrine, docs/tracking/ROLLING_WORK_LEDGER.md:6148. +# omnimarket carries the proof class; this node carries the JOIN, because +# the decision needs the ticket's acceptance text and node_dod_verify +# never sees it. +# +# It moves in BOTH directions and both are deliberate. LOOSER: a ticket +# whose every parsed criterion asserts live state, each discharged by a +# verified readback, no longer dies on behavior_proving_count <= 0 -- +# OMN-17771 is the worked example, five shell one-liners that read a live +# surface and assert on it. STRICTER: a BEHAVIOUR-shaped criterion whose +# only verified checks are readbacks now HOLDS. Binding used to key on +# status alone and never on proof class, so a readback silently +# discharged a criterion asserting what the code does. +# +# Deliberately untouched: merge-state, surrogate and indeterminate checks +# bind exactly as before. Narrowing those is a different argument nobody +# has made, and making it here would retroactively un-close tickets that +# flipped on that basis. +# +# The honest limit, recorded rather than implied: criterion shape is read +# from PROSE and is a heuristic. It is tight on purpose -- state-shaped +# requires a positive state marker AND no behaviour marker, everything +# else falls to behaviour and holds. The asymmetry is chosen: reading a +# criterion as behaviour-shaped costs a comment, reading one as +# state-shaped RELEASES a flip on a readback. +# +# AC3 rides here too: the gap_no_behavior_proof comment now NAMES THE BAR +# -- which command shapes prove behaviour, which prefixes are transparent, +# and that a readback is the admissible route for live state. A hold that +# states a verdict without a remedy teaches nobody anything, which is what +# three lanes spent a night rediscovering by measurement. +# name: "node_evidence_autoclose_sweep_effect" node_type: "EFFECT_GENERIC" contract_version: major: 1 minor: 7 patch: 0 -node_version: "1.11.0" +node_version: "1.12.0" description: > Scheduled sweep wiring companion-merge -> dod_verify -> governed Done flip. Enumerates merged onex_change_control evidence companions, extracts their Evidence-Ticket binding, re-runs dod_verify, and flips Done or posts a gap comment via the Linear API. DRY-RUN by default; ONEX_AUTOCLOSE_DISABLED kill switch checked first. diff --git a/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/handler_evidence_autoclose_sweep.py b/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/handler_evidence_autoclose_sweep.py index be9b4fd7d2..7542ecd45c 100644 --- a/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/handler_evidence_autoclose_sweep.py +++ b/src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/handler_evidence_autoclose_sweep.py @@ -299,6 +299,12 @@ def _dod_verify_argv(ticket_id: str) -> list[str]: # receipt in the existing corpus, so its absence is an ERROR (nothing to # decide on), never an inference in either direction. _DOD_VERIFY_BEHAVIOR_KEY = "behavior_proving_count" +#: OMN-18135 AC4. VERIFIED readback-class checks. Read separately and +#: never added to the behavior count: a readback says what the system +#: currently IS, never what the code DOES. An ABSENT key means the +#: verifier predates the class, which `_as_int` renders 0 — the +#: fail-closed reading, since 0 releases nothing. +_DOD_VERIFY_READBACK_KEY = "readback_proving_count" # How many checks executed, exited 0, and could not have exited otherwise for # a product reason — a bare `gh pr view` (green for every PR on GitHub) or a # ticket-independent foreign suite. `ModelDodVerifyState.non_probative_count` @@ -582,6 +588,9 @@ def _dod_verify_argv(ticket_id: str) -> list[str]: #: Per-check statuses, as `EnumEvidenceCheckStatus` spells them. _CHECK_STATUS_VERIFIED = "verified" +#: OMN-18135 AC4. A check that read live state and asserted on it. It +#: discharges a state-shaped criterion and never a behaviour-shaped one. +_CHECK_PROOF_CLASS_READBACK = "readback" _CHECK_STATUS_FAILED = "failed" _CHECK_STATUS_SKIPPED = "skipped" _CHECK_STATUS_SUPERSEDED = "superseded" @@ -596,7 +605,7 @@ def _dod_verify_argv(ticket_id: str) -> list[str]: #: fingerprint (see `_gap_fingerprint_parts`). Pinned against the contract by #: `test_the_pinned_contract_version_is_the_node_contract_version`, so it #: cannot drift into describing a rule the closer no longer applies. -_GAP_FINGERPRINT_CONTRACT_VERSION = "1.11.0" +_GAP_FINGERPRINT_CONTRACT_VERSION = "1.12.0" # OMN-16106. Linear transient-failure retry policy defaults. See # ``_LinearClient``'s class docstring for the live measurement these exist to @@ -1996,6 +2005,93 @@ def _coverage_corpus_counts( return verified, non_probative +# -- OMN-18135 AC4: which criteria a readback may discharge ---------------- +# +# The ruling (docs/tracking/ROLLING_WORK_LEDGER.md:6148, orchestrator, under +# the deterministic-truth doctrine) admits an asserted live readback as proof +# class `readback` for a criterion whose text asserts live STATE, and never +# for one that asserts what code DOES. +# +# omnimarket carries the class. This carries the JOIN, because the decision +# needs the ticket's acceptance text and `node_dod_verify` never sees it. +# +# THE HONEST LIMIT, because this is prose and prose is not a contract. This +# is a heuristic, and it is TIGHT on purpose. A criterion is state-shaped +# only when it carries a positive state marker AND no behaviour marker; +# everything else falls to behaviour and HOLDS. The asymmetry is deliberate: +# a criterion wrongly read as behaviour-shaped costs a comment, while one +# wrongly read as state-shaped RELEASES a flip on a readback. So the marker +# set earns its way in one phrase at a time, and the veto wins ties. + +#: Positive evidence that a criterion asserts LIVE STATE — a condition, a +#: row, a count, a config value read from the running system. Every entry is +#: drawn from a criterion that actually exists in the corpus, not invented. +_STATE_MARKER_RE: re.Pattern[str] = re.compile( + r"""(?xi) + \bread[\s-]?back\b + | \breadback\b + | \b\d+\s+rows?\b + | \brows?\s+carrying\b + | \b\d+\s+occurrences?\b + | \bno\s+occurrences?\b + | \bis\s+(running|ready|healthy|present|absent|enabled|disabled)\b + | \breaches\s+(running|ready)\b + | \b\d+\s+restarts?\b + | \breturns?\b + | \bresponds?\b + | \bserves?\b + | \bcontains?\b + | \bdigest\b + | \bconfig(uration)?\s+value\b + | \bon\s+the\s+(live|running)\b + | \b(live|running)\s+(plane|cluster|lane|realm|database|system|repository)\b + """, +) + +#: Language that asserts what the CODE DOES. Vetoes a state marker, because a +#: criterion carrying both is the ambiguous case and ambiguity must hold. +_BEHAVIOUR_MARKER_RE: re.Pattern[str] = re.compile( + r"""(?xi) + \btests?\b + | \bred[\s-]first\b + | \bfails?\s+today\b + | \brefuses?\b + | \braises?\b + | \bretr(y|ies|ied)\b + | \bstops?\s+after\b + | \bhandler\b + | \bsuite\b + """, +) + + +def _criterion_is_state_shaped(criterion: str) -> bool: + """Whether a readback may discharge this criterion. + + True only on POSITIVE evidence of live state and in the ABSENCE of + behaviour language. Both conditions, never one: see the module comment + above for why the veto wins ties. + """ + if _BEHAVIOUR_MARKER_RE.search(criterion) is not None: + return False + return _STATE_MARKER_RE.search(criterion) is not None + + +def _every_criterion_is_state_shaped(description: str) -> bool: + """True when a body parses at least one criterion and ALL are state-shaped. + + A body with no parseable criteria returns False. The closer cannot say + anything about criteria it cannot read, and this leg must not be the one + that releases such a ticket -- the OMN-18056 binding gate holds it a + conjunct later, and this returning True would have let it past the + behaviour conjunct on a body nobody can check. + """ + items = _acceptance_criteria_items(description) + if not items: + return False + return all(_criterion_is_state_shaped(item) for item in items) + + def _has_ac_heading(description: str) -> bool: """Whether the body carries a recognised acceptance-criteria heading.""" return any(_is_ac_heading(line) for line in description.splitlines()) @@ -2047,11 +2143,37 @@ def _ac_binding_gap( rows: list[ModelAcBindingRow] = [] unbound: list[str] = [] + #: OMN-18135 AC4: criteria whose ONLY verified checks are readbacks and + #: whose text is not state-shaped. Tracked separately from `unbound` so + #: the hold can name the bar rather than say "declared by nothing", which + #: would be false and would send the author to fix the wrong thing. + readback_blocked: list[str] = [] for item in items: text = item[:_MAX_AC_TEXT_CHARS] label = _canonical_ac_label(item) declared = bindings.get(label, ()) if label else () - proving = tuple(row for row in declared if row[1] == _CHECK_STATUS_VERIFIED) + verified_rows = tuple( + row for row in declared if row[1] == _CHECK_STATUS_VERIFIED + ) + # OMN-18135 AC4, and this half is a TIGHTENING. Until now binding + # keyed on status alone and never looked at proof class, so a readback + # silently discharged a criterion asserting what the code DOES. The + # ruling names that: readbacks are admissible for live STATE only. + # + # Scoped deliberately to readbacks. A criterion proved by a + # merge-state, surrogate or indeterminate check binds exactly as it + # did — narrowing those is a different argument nobody has made, and + # making it here would retroactively un-close tickets that flipped on + # that basis. + readback_only = bool(verified_rows) and all( + row[2] == _CHECK_PROOF_CLASS_READBACK for row in verified_rows + ) + proving: tuple[tuple[str, str, str], ...] + if readback_only and not _criterion_is_state_shaped(item): + readback_blocked.append(_canonical_ac_label(item) or text) + proving = () + else: + proving = verified_rows if proving: rows.extend( ModelAcBindingRow( @@ -2101,6 +2223,21 @@ def _ac_binding_gap( "item names it in `binds_ac` AND that check verified -- a " "non-probative or skipped declaration is a claim, not a proof." ) + readback_note = ( + ( + " OMN-18135: " + + ", ".join(readback_blocked[:_MAX_UNCOVERED_LISTED]) + + " IS declared by a verified check, but only by a READBACK — a " + "command that read live state and asserted on it. A readback " + "discharges a criterion asserting live STATE (a condition, a row, " + "a count, a config value); this criterion reads as asserting what " + "the code DOES, which needs a test runner or the ONEX CLI. Either " + "bind a behaviour check, or reword the criterion to state the live " + "fact it actually wants." + ) + if readback_blocked + else "" + ) unlabelled = sum(1 for text in unbound if not _canonical_ac_label(text)) labelling = ( f" {unlabelled} of them carry no `AC`/`DoD` label at all, so " @@ -2129,7 +2266,7 @@ def _ac_binding_gap( return ( f"{len(unbound)} of {len(items)} acceptance criterion(s) in this " f"ticket's description are bound to NO verified probative check in " - f"`{contract}`: {named}. {why}{labelling}{fallback}", + f"`{contract}`: {named}. {why}{readback_note}{labelling}{fallback}", tuple(unbound), tuple(rows), ) @@ -3899,12 +4036,35 @@ async def _behavior_proof_outcome( pass: this one asks what the checks that RAN proved; the AC-coverage one asks what the ticket claimed that no check covers. """ + # OMN-18135 AC3. The hold NAMES THE BAR. Three lanes spent a night + # rediscovering this rule by measurement because the comment stated a + # verdict and not a remedy; a hold that does not say what would clear + # it teaches nobody anything. reason = ( f"dod_verify: {verified_count}/{total_checks} checks verified, 0 failed " "— and not one of them executed the claimed behavior. Every passing " "check binds merge state (a PR is merged) or is a surrogate (a file " "exists, a generic suite ran). That is evidence the code landed, not " "evidence the system does the thing, so the Done flip is withheld." + "\n\nWhat clears this. A check proves BEHAVIOR only when its " + "command runs a test runner or the ONEX CLI — `pytest`, `tox`, " + "`nox`, `unittest`, `vitest`, `jest`, `mocha`, `rspec`, `phpunit`, " + "`python -m `, `make `, " + "`go`/`cargo`/`npm`/`pnpm`/`yarn`/`bun`/`dotnet`/`gradle`/`mvn " + "test`, or `onex`. A prefix that only says where or with what " + "environment it runs is transparent (`env` and its options, " + "`cd `, a bare `NAME=value`), so those do not cost you the " + "class." + "\n\nA live probe through `ssh`, `aws`, `kubectl`, `curl`, `psql` " + "or a hand-written shell loop is NOT behavior-proving, however " + "real the thing it exercised. It can be admissible as a READBACK " + "(OMN-18135): asserted live state — a condition, a row, a count, a " + "config value read from the running system — discharges a " + "criterion whose text asserts live STATE. It never discharges one " + "that asserts what the code DOES, and a criterion the sweep cannot " + "read as state-shaped is treated as behavior-shaped and holds. To " + "take that route the command must ASSERT: its exit status has to " + "be able to go red on what it read." ) base = ModelEvidenceAutocloseOutcome( ticket_id=ticket_id, @@ -4579,7 +4739,24 @@ async def _process_ticket( # leaving one silent hole in it. dod_verify_behavior_proving_count=0, ) - if behavior_proving_count <= 0: + # OMN-18135 AC4. The description is read HERE rather than after + # this conjunct, because the conjunct now needs it: whether a + # readback may stand in for a behaviour proof is a question about + # the ticket's criteria, not about its counters. + description_raw = issue.get("description") + description = description_raw if isinstance(description_raw, str) else "" + readback_proving_count = _as_int(verdict.get(_DOD_VERIFY_READBACK_KEY)) + # The ruling's admission, and its exact bound. A readback releases + # this conjunct only when EVERY parsed criterion asserts live + # state. One behaviour-shaped criterion, or a body whose criteria + # cannot be read at all, and the behaviour proof is still required + # -- a ticket is judged by its strictest criterion, never on + # average. + readback_releases_the_conjunct = ( + readback_proving_count > 0 + and _every_criterion_is_state_shaped(description) + ) + if behavior_proving_count <= 0 and not readback_releases_the_conjunct: return await self._behavior_proof_outcome( ticket_id=ticket_id, companion_pr_number=companion_pr_number, @@ -4593,11 +4770,10 @@ async def _process_ticket( ) # OMN-16736: dod_verify being green is necessary, not sufficient. - # Re-read the ticket body for criteria its checks never covered - # BEFORE any flip path (dry-run included, so a DRY-RUN report is - # an honest preview of what --apply would do). - description_raw = issue.get("description") - description = description_raw if isinstance(description_raw, str) else "" + # The ticket body was read just above, BEFORE any flip path + # (dry-run included, so a DRY-RUN report is an honest preview of + # what --apply would do). OMN-18135 moved that read one conjunct + # earlier; it is the same string, read once. # OMN-18056. THE BINDING LEG, and it is FIRST of the two AC guards # deliberately: the counting rules below can only refute coverage diff --git a/tests/unit/nodes/node_evidence_autoclose_sweep_effect/test_omn_18135_readback_join.py b/tests/unit/nodes/node_evidence_autoclose_sweep_effect/test_omn_18135_readback_join.py new file mode 100644 index 0000000000..e74f5efe51 --- /dev/null +++ b/tests/unit/nodes/node_evidence_autoclose_sweep_effect/test_omn_18135_readback_join.py @@ -0,0 +1,513 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""OMN-18135 AC3 + AC4 — the closer's half of the readback ruling. + +THE RULING, and its provenance +------------------------------ +Orchestrator ruling under the deterministic-truth doctrine, recorded at +``docs/tracking/ROLLING_WORK_LEDGER.md:6148`` (2026-09-10T17:50:03Z). Not an +operator ruling; stated as it is rather than upgraded: + + an asserted live or shell readback is admissible as its own proof class, + readback, for a criterion whose text asserts live STATE ...; it never + proves BEHAVIOUR ..., which stays test-runner/onex-CLI only. The closer + accepts readback-class checks against state-shaped criteria and holds + behaviour-shaped criteria bound only to readbacks, naming the bar. + +omnimarket carries the CLASS. This carries the JOIN, because deciding whether +a criterion is state-shaped needs the ticket's acceptance text and +``node_dod_verify`` never sees it. + +WHAT MOVES IN EACH DIRECTION +---------------------------- +This is not purely a loosening, and both halves are asserted below. + +**Looser**: a ticket whose criteria are all state-shaped and each discharged +by a verified readback no longer dies on ``behavior_proving_count <= 0``. +That is the case the ruling exists to admit — OMN-17771 is the worked +example. + +**Stricter**: a BEHAVIOUR-shaped criterion whose only proving checks are +readbacks now HOLDS. Before this, ``_ac_binding_gap`` bound on check status +alone and never looked at proof class, so a readback silently discharged a +criterion asserting what the code does. That was never right and the ruling +names it. + +Deliberately UNTOUCHED: a criterion proved by a merge-state, surrogate or +indeterminate check binds exactly as it did. Narrowing those is a different +argument that nobody has made, and making it here would break tickets that +have already flipped on that basis. + +THE HONEST LIMIT +---------------- +Criterion shape is read from PROSE. It is a heuristic and it is tight on +purpose: a criterion is state-shaped only when it carries a positive state +marker AND no behaviour marker. Everything else falls to behaviour and holds. +The dangerous direction is a criterion wrongly read as state-shaped, because +that RELEASES; so the vocabulary earns its way in, and the veto wins ties. +""" + +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import pytest + +from omnibase_infra.nodes.node_evidence_autoclose_sweep_effect.handlers.handler_evidence_autoclose_sweep import ( + HandlerEvidenceAutocloseSweep, +) +from omnibase_infra.nodes.node_evidence_autoclose_sweep_effect.models.enum_evidence_autoclose_decision import ( + EnumEvidenceAutocloseDecision, +) +from omnibase_infra.nodes.node_evidence_autoclose_sweep_effect.models.model_evidence_autoclose_sweep_request import ( + ModelEvidenceAutocloseSweepRequest, +) +from tests.unit.nodes.node_evidence_autoclose_sweep_effect._ac_binding_support import ( + redraw_marker_comment, +) + +pytestmark = pytest.mark.unit + +_OCC_REPO = "OmniNode-ai/onex_change_control" +_TICKET = "OMN-17771" +_DOD_VERIFY_STATE_MODEL = ( + "omnimarket.nodes.node_dod_verify.models.model_dod_verify_state.ModelDodVerifyState" +) + +#: Criteria that assert live STATE: a condition, a row, a count, a config +#: value read from the running system. Shaped after OMN-17771's real body. +_STATE_SHAPED_DESCRIPTION = ( + "## Acceptance criteria\n" + "\n" + "- **AC1** the published guides contain 0 occurrences of the internal " + "client ids, read back from the live repository.\n" + "- **AC2** the registration URL in the guide returns the register form " + "when requested against the live realm.\n" + "- **AC3** the same request with an unknown client id does not return " + "the register form.\n" +) + +#: Criteria that assert what the CODE DOES. A readback may never discharge +#: one of these, however green it is. +_BEHAVIOUR_SHAPED_DESCRIPTION = ( + "## Acceptance criteria\n" + "\n" + "- **AC1** a red-first test fails today against the unfixed tree and " + "passes after the change.\n" + "- **AC2** the handler refuses a malformed envelope instead of raising.\n" + "- **AC3** the retry path stops after the declared attempt budget.\n" +) + + +def _check( + evidence_id: str, + status: str, + proof_class: str, + *, + binds_ac: tuple[str, ...] | None = None, +) -> dict[str, Any]: + check: dict[str, Any] = { + "evidence_id": evidence_id, + "description": evidence_id, + "status": status, + "message": "OK (1ms)", + "proof_class": proof_class, + } + if binds_ac is not None: + check["binds_ac"] = list(binds_ac) + return check + + +def _readback_checks(labels: tuple[str, ...]) -> list[dict[str, Any]]: + """One verified readback per criterion, and nothing behaviour-proving.""" + return [ + _check( + f"dod-{label.lower()}-readback", "verified", "readback", binds_ac=(label,) + ) + for label in labels + ] + + +def _skill_result(checks: list[dict[str, Any]]) -> dict[str, Any]: + """A dod_verify receipt with the OMN-18135 readback counter present.""" + verified = sum(1 for c in checks if c["status"] == "verified") + failed = sum(1 for c in checks if c["status"] == "failed") + non_probative = sum(1 for c in checks if c["status"] == "non_probative") + behavior = sum( + 1 + for c in checks + if c["status"] == "verified" and c["proof_class"] == "behavior" + ) + readback = sum( + 1 + for c in checks + if c["status"] == "verified" and c["proof_class"] == "readback" + ) + terminal: dict[str, Any] = { + "correlation_id": str(uuid4()), + "ticket_id": _TICKET, + "status": "verified" if verified and not failed else "skipped", + "dry_run": False, + "checks": checks, + "total_checks": len(checks), + "verified_count": verified, + "failed_count": failed, + "skipped_count": 0, + "superseded_count": 0, + "non_probative_count": non_probative, + "behavior_proving_count": behavior, + "readback_proving_count": readback, + "error_message": None, + } + return { + "skill_name": "dod_verify", + "node_name": "node_dod_verify", + "status": "success", + "exit_code": 0, + "result": terminal, + "result_model": _DOD_VERIFY_STATE_MODEL, + } + + +class _FakeLinear: + def __init__(self, description: str) -> None: + self.description = description + self.state_updates: list[tuple[str, str]] = [] + self.comments: list[tuple[str, str]] = [] + + async def fetch_issue(self, ticket_id: str) -> dict[str, Any]: + return { + "id": "issue-uuid-1", + "identifier": _TICKET, + "state": {"id": "s1", "name": "In Progress", "type": "started"}, + "labels": {"nodes": []}, + "children": {"nodes": []}, + "team": {"id": "team-1"}, + "description": self.description, + } + + async def fetch_done_state_id(self, team_id: str) -> str: + return "state-done-id" + + async def update_issue_state(self, issue_id: str, state_id: str) -> bool: + self.state_updates.append((issue_id, state_id)) + return True + + async def fetch_issue_history( + self, issue_id: str, page_size: int, max_pages: int + ) -> tuple[list[dict[str, Any]] | None, str]: + return [ + { + "id": f"entry-{index}", + "createdAt": f"2026-09-10T00:00:{index:02d}Z", + "actorId": None, + "fromState": {"type": "started"}, + "toState": {"type": "completed"}, + } + for index, (target, _s) in enumerate(self.state_updates, start=1) + if target == issue_id + ], "" + + async def create_comment(self, issue_id: str, body: str) -> bool: + self.comments.append((issue_id, body)) + return True + + async def fetch_comment_bodies(self, issue_id: str) -> tuple[str, ...] | None: + return tuple(body for target, body in self.comments if target == issue_id) + + +def _handler( + skill_result: dict[str, Any], linear: _FakeLinear +) -> HandlerEvidenceAutocloseSweep: + async def fake_gh(args: list[str], timeout: float): + path = args[2] + if "/files" in path: + return [{"filename": f"contracts/{_TICKET}.yaml"}], "" + page = int(path.rsplit("page=", 1)[1]) + recent = "2026-09-10T16:00:00Z" + return ( + ( + [ + { + "number": 9100, + "html_url": f"https://github.com/{_OCC_REPO}/pull/9100", + "title": f"evidence({_TICKET}): OCC companion", + "updated_at": recent, + "merged_at": recent, + } + ], + "", + ) + if page == 1 + else ([], "") + ) + + async def fake_dod_verify(ticket_id: str, cwd: str, timeout: float): + return skill_result, 0, "" + + return HandlerEvidenceAutocloseSweep( + linear_client=linear, # type: ignore[arg-type] + autoclose_disabled=False, + run_gh_command=fake_gh, + run_dod_verify_command=fake_dod_verify, + ) + + +def _request(**overrides: Any) -> ModelEvidenceAutocloseSweepRequest: + defaults: dict[str, Any] = { + "correlation_id": uuid4(), + "occ_repo": _OCC_REPO, + "lookback_hours": 24, + "apply": False, + } + defaults.update(overrides) + return ModelEvidenceAutocloseSweepRequest(**defaults) + + +def _seed_redraw(linear: _FakeLinear, checks: list[dict[str, Any]]) -> None: + body = _skill_result(checks)["result"] + linear.comments.append( + ( + "issue-uuid-1", + redraw_marker_comment( + total_checks=body["total_checks"], + verified_count=body["verified_count"], + failed_count=body["failed_count"], + non_probative_count=body["non_probative_count"], + behavior_proving_count=body["behavior_proving_count"], + ), + ) + ) + + +# -------------------------------------------------------------------------- +# LOOSER: state-shaped criteria, discharged by readbacks, may now release. +# -------------------------------------------------------------------------- + + +async def test_state_shaped_criteria_proven_by_readbacks_release() -> None: + """RED. The OMN-17771 shape: every criterion is live state, each read back. + + Before this, `behavior_proving_count <= 0` refused the ticket outright and + the criteria were never even reached. + """ + checks = _readback_checks(("AC1", "AC2", "AC3")) + linear = _FakeLinear(_STATE_SHAPED_DESCRIPTION) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + outcome = result.outcomes[0] + + assert outcome.decision is not EnumEvidenceAutocloseDecision.GAP_NO_BEHAVIOR_PROOF + assert outcome.decision is not EnumEvidenceAutocloseDecision.GAP_AC_UNBOUND + assert outcome.decision is EnumEvidenceAutocloseDecision.FLIPPED + + +# -------------------------------------------------------------------------- +# STRICTER: a behaviour-shaped criterion is NOT discharged by a readback. +# -------------------------------------------------------------------------- + + +async def test_a_behaviour_shaped_criterion_is_not_discharged_by_a_readback() -> None: + """RED, and this direction is a TIGHTENING. + + Binding used to key on check status alone and never on proof class, so a + readback silently discharged a criterion asserting what the code does. + The hold must name the bar rather than report a ratio. + """ + checks = _readback_checks(("AC1", "AC2", "AC3")) + linear = _FakeLinear(_BEHAVIOUR_SHAPED_DESCRIPTION) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + outcome = result.outcomes[0] + + assert result.tickets_flipped == 0 + assert outcome.decision in ( + EnumEvidenceAutocloseDecision.GAP_NO_BEHAVIOR_PROOF, + EnumEvidenceAutocloseDecision.GAP_AC_UNBOUND, + ) + + +async def test_one_behaviour_shaped_criterion_holds_the_whole_ticket() -> None: + """A mixed body is judged by its strictest criterion, not on average.""" + mixed = ( + "## Acceptance criteria\n" + "\n" + "- **AC1** the mirror table has 4 rows, read back from the live " + "database.\n" + "- **AC2** the handler refuses a malformed envelope instead of " + "raising.\n" + ) + checks = _readback_checks(("AC1", "AC2")) + linear = _FakeLinear(mixed) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + + assert result.tickets_flipped == 0 + + +# -------------------------------------------------------------------------- +# AC3: the hold names the bar. +# -------------------------------------------------------------------------- + + +async def test_the_no_behaviour_proof_hold_names_the_bar() -> None: + """A hold that does not say what would clear it teaches nobody anything. + + Three lanes spent a night discovering this rule by measurement. The + comment must carry it: which command shapes prove behaviour, and that a + readback is admissible for a state-shaped criterion. + """ + checks = _readback_checks(("AC1", "AC2", "AC3")) + linear = _FakeLinear(_BEHAVIOUR_SHAPED_DESCRIPTION) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request(apply=True)) + reason = result.outcomes[0].reason + + # Names what DOES prove behaviour. + assert "pytest" in reason or "test runner" in reason + assert "onex" in reason + # Names the readback route and what it is good for. + assert "readback" in reason.lower() + + +# -------------------------------------------------------------------------- +# UNTOUCHED: every other class binds exactly as it did. +# -------------------------------------------------------------------------- + + +async def test_a_behaviour_check_still_releases_a_behaviour_criterion() -> None: + """The ordinary case is unmoved.""" + checks = [ + _check("dod-ac1-proof", "verified", "behavior", binds_ac=("AC1",)), + _check("dod-ac2-proof", "verified", "behavior", binds_ac=("AC2",)), + _check("dod-ac3-proof", "verified", "behavior", binds_ac=("AC3",)), + ] + linear = _FakeLinear(_BEHAVIOUR_SHAPED_DESCRIPTION) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + assert result.outcomes[0].decision is EnumEvidenceAutocloseDecision.FLIPPED + + +async def test_a_surrogate_check_binds_exactly_as_it_did() -> None: + """Deliberately NOT narrowed. + + A criterion proved by a merge-state or surrogate check binds today, and + tickets have already flipped on that basis. Narrowing it is a different + argument that nobody has made, and making it here would break them. + """ + checks = [ + _check("dod-ac1-proof", "verified", "behavior", binds_ac=("AC1",)), + _check("dod-ac2-artifact", "verified", "surrogate", binds_ac=("AC2",)), + _check("dod-ac3-merge", "verified", "merge-state", binds_ac=("AC3",)), + ] + linear = _FakeLinear(_BEHAVIOUR_SHAPED_DESCRIPTION) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + assert result.outcomes[0].decision is EnumEvidenceAutocloseDecision.FLIPPED + + +# -------------------------------------------------------------------------- +# The shape predicate itself. Tight on purpose; ambiguity holds. +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "criterion", + [ + "AC1 the mirror table has 4 rows, read back from the live database", + "AC1 the deployment is Running with 0 restarts on the new digest", + "AC1 the guide contains 0 occurrences of the internal client id", + "AC1 the endpoint returns the register form when requested live", + "AC1 the config value is enabled on the running cluster", + ], +) +def test_state_markers_are_recognised(criterion: str) -> None: + from omnibase_infra.nodes.node_evidence_autoclose_sweep_effect.handlers.handler_evidence_autoclose_sweep import ( + _criterion_is_state_shaped, + ) + + assert _criterion_is_state_shaped(criterion) is True + + +@pytest.mark.parametrize( + "criterion", + [ + # Behaviour language, plainly. + "AC1 a red-first test fails today and passes after the change", + "AC1 the handler refuses a malformed envelope instead of raising", + "AC1 the retry path stops after the declared attempt budget", + # Ambiguous: no state marker at all. Falls to behaviour and holds. + "AC1 the thing is done properly", + "AC1 ship the fix", + # A state marker VETOED by behaviour language. The veto wins ties, + # because a criterion wrongly read as state-shaped RELEASES. + "AC1 a test asserts the mirror table has 4 rows", + ], +) +def test_anything_else_falls_to_behaviour_and_holds(criterion: str) -> None: + from omnibase_infra.nodes.node_evidence_autoclose_sweep_effect.handlers.handler_evidence_autoclose_sweep import ( + _criterion_is_state_shaped, + ) + + assert _criterion_is_state_shaped(criterion) is False + + +async def test_a_readback_cannot_discharge_a_behaviour_criterion_beside_a_real_proof() -> ( + None +): + """The case the behaviour conjunct does NOT catch, and the ruling does. + + `behavior_proving_count` is 1, so the conjunct releases. AC1 is genuinely + proven. AC2 asserts what the code does and is bound ONLY to a readback. + Before this the binding leg keyed on status alone, never on proof class, + so AC2 bound and the ticket flipped on a readback standing in for a + behaviour proof. That is the hole the ruling closes, and it is invisible + to every test above because they have no behaviour proof at all. + """ + checks = [ + _check("dod-ac1-proof", "verified", "behavior", binds_ac=("AC1",)), + _check("dod-ac2-readback", "verified", "readback", binds_ac=("AC2",)), + ] + description = ( + "## Acceptance criteria\n" + "\n" + "- **AC1** a red-first test fails today and passes after the change.\n" + "- **AC2** the handler refuses a malformed envelope instead of raising.\n" + ) + linear = _FakeLinear(description) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request(apply=True)) + outcome = result.outcomes[0] + + assert result.tickets_flipped == 0 + assert outcome.decision is EnumEvidenceAutocloseDecision.GAP_AC_UNBOUND + # The hold NAMES the criterion and the bar, not a ratio. + assert "AC2" in outcome.reason + assert "readback" in outcome.reason.lower() + + +async def test_a_readback_does_discharge_a_state_criterion_beside_a_real_proof() -> ( + None +): + """The positive control for the case above. + + Same shape, but AC2 asserts live state. The readback discharges it, which + is precisely what the ruling admits. Without this, the test above is also + satisfied by a change that refuses every readback binding. + """ + checks = [ + _check("dod-ac1-proof", "verified", "behavior", binds_ac=("AC1",)), + _check("dod-ac2-readback", "verified", "readback", binds_ac=("AC2",)), + ] + description = ( + "## Acceptance criteria\n" + "\n" + "- **AC1** a red-first test fails today and passes after the change.\n" + "- **AC2** the mirror table has 4 rows, read back from the live " + "database.\n" + ) + linear = _FakeLinear(description) + _seed_redraw(linear, checks) + result = await _handler(_skill_result(checks), linear).handle(_request()) + assert result.outcomes[0].decision is EnumEvidenceAutocloseDecision.FLIPPED