From 7ba5b8809ad849c5b601dc0cbcf91a92c2dd0c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samo=20A=C4=8Dko?= Date: Sun, 13 Sep 2026 20:42:35 +0200 Subject: [PATCH 1/4] Define legacy observation acceptance restriction Prepare Delivery387 Phase A for one pre-emission eligibility boundary. Disclose direct/queued acceptance and observation correction restrictions while preserving capture, immutable history, rejection and contest. Runtime implementation awaits the new same-task semantic decision. --- ...Legacy_Observation_Eligibility_RFC_v0_1.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md diff --git a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md new file mode 100644 index 00000000..45cbf4df --- /dev/null +++ b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md @@ -0,0 +1,226 @@ +# Legacy observation acceptance eligibility + +Status: **Phase A proposal; runtime implementation is not approved**. +Decision: `OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001`, version 1. +Delivery: [#387](https://github.com/samovers/OFARM2/issues/387), under #180; +related promotion-matrix programme: #179. The existing draft PR must be named +in the live task-user decision card before approval. This record is design +evidence, not user approval or OFARM law. + +Design base: `9d7541d96bc708e9270b986927d7f4b8a035454f`, after merged PR #386; +runtime tree: `63d532112ed7c705997d0d71f5f6d0fec12928f7`. +PRs #380, #382, #384 and #386 remain completed historical work. + +## Problem, selected outcome and exact boundary + +An authorized actor with observation-creation and review-accept grants can +self-accept a confirmed legacy `OBSERVATION_ASSERTION` with omitted, null or +self-named reviewer metadata. The same actor's queued acceptance is refused +under D8, while a distinct authorized reviewer can accept it. Audit K04 and +PR #386's executed review already demonstrate that discrepancy. The complete +review is now public as [review 5191268560](https://github.com/samovers/OFARM2/pull/386#pullrequestreview-5191268560), +whose body names design head `0472668`; its API-associated commit is not its +executed candidate. Its CPython 3.11/PostgreSQL 16/unpinned measurements are +supplemental, not pinned verification of this proposal. No audit is restarted. + +D8 permits routine-operation self-review; D17 adds only bounded farm-owned +structure. Neither grants observation self-review. [Issue #179](https://github.com/samovers/OFARM2/issues/179) +also requires observation promotion to remain disabled until typed semantics +exist. Merely sending observations to a distinct reviewer would leave that +recorded requirement unmet. The proposed independently useful outcome is: +**retain otherwise-valid observation captures, but permit no new observation +acceptance through either the direct or queued legacy path, for any actor**. +This implements only that eligibility limit from #179, not its broader matrix +or evidence-ingestion programme. + +The primary trust boundary is **observation acceptance eligibility before +new accepted-force emission**. This is high-risk because it removes reachable +direct and independently reviewed acceptance, including observation correction. +The PR contains this one complete restriction and its tests/documentation; +it does not change observation payload meaning, reviewer grants, correction +compatibility/retirement authority, custody or production activation. + +**Explicit change to an earlier approved capability:** correction decision +`OFARM2-LEGACY-CORRECTION-AUTHORIZATION-001` version 2, RFC v0.2 C03/C10, +previously preserved observation acceptance and same-family correction. +Approval of this new decision narrows only their observation acceptance +availability: no new accepted observation or observation correction successor +can be emitted, even with valid `REVIEW_ACCEPT` and `REVIEW_SUPERSEDE`. +Their relationship/provenance checks and retirement permissions are not +weakened. Other assertion families retain their decided correction paths. +The historical RFC and completed decision are not rewritten. This explicit +restriction, rather than treating a former positive test as disposable, is +why a fresh semantic approval is required. + +## Authority, threat and containment + +| Owner | Authority that stays with it | +| --- | --- | +| Task user | This new eligibility restriction and later exact-head merge acceptance | +| D8/D17 and existing review policy | Routine-operation and bounded-structure rights; compliance independent review | +| Existing transport/parser | Bound actor; strict optional boolean; raw request and idempotency identity | +| Existing authority/validation/evidence gates | Farm/scope, grants, durable evidence, target/carrier and correction relationships | +| Existing review-promotion gate | One observation eligibility decision before either acceptance emitter | +| Existing queue validator | Stored target, actor relationship, rationale/evidence, terminal and bundle checks | +| Existing Store/pipeline/emitters | Transactions, append-only lineage, replay, rejection/contest and materialization | +| Review, CI, publisher and GitHub | Findings, mechanical evidence/custody and native PR state; no user authority | + +Protected assets are the capture/accepted distinction, accountable review, +accepted history and predecessor lineage. The caller can control the body, +confirmation, reviewer hint, requested target and fresh/reused idempotency key, +and may legitimately hold both assertion and review rights on the farm. +A distinct authorized reviewer is also in scope: stronger grants cannot +manufacture missing typed observation semantics. Trusted-process modification, +direct database writers, host compromise and stolen credentials are excluded. + +The supported reachable negatives are legacy `POST /commit` and +`POST /review/accept`, backed by real `GatePipeline.commit` and Store. +Public production governed routes remain closed (`kernel/api.py:74–83`). +This is a legacy development/conformance correction, not a production +reachability, readiness or authentication claim. + +## Smallest proposed correction and ordering + +Keep every existing type/target map and all earlier gates. At the start of +`ReviewPromotionGate.run`, before reviewer-hint routing, retirement authority +and both acceptance emitters, determine the assertion type for this act: + +- For a validated queue **ACCEPT** with `acceptance_target`, use the fetched + `ctx.acceptance_payload` assertion type, never caller-supplied type fields. +- Otherwise use the existing commit-class-to-assertion-type map. Queue REJECT + and CONTEST are governance decisions, so neither becomes an observation + acceptance merely because its stored target is an observation. + +One explicit observation condition controls both outcomes. A direct observation +that passed earlier gates uses the existing pending-assertion emitter and +returns `RETAIN_DRAFT`. Omitted/false confirmation retains the ordinary +capture diagnostic; literal true reports registered `HIGH_CONSEQUENCE_BLOCKED` +with text explaining that acceptance awaits approved typed semantics and +cannot be enabled by a distinct reviewer. A queued observation ACCEPT that +reaches this gate returns `RETAIN_DRAFT` with the same eligibility explanation, +without a new assertion, ReviewDecision, REVIEW edge, consequence or retirement. +No new constant set, helper, policy service, contract value or persistent flag +is needed for this single condition with two actual emission paths. + +Existing earlier refusals retain their precedence. In particular, the +asserter's queued self-acceptance still fails the existing D8 validator with +`HUMAN_APPROVAL_REQUIRED`; a distinct reviewer passes that relationship check +and encounters the new eligibility refusal. Missing authority/evidence, invalid +targets, wrong farm/bundle and terminal claims need not reach the new guard. +The final outcome is not silently relabelled to hide those earlier reasons. + +Keep `COMMIT_CLASS_TO_PROMOTION_TARGET` and `ACCEPTANCE_BY_ASSERTION_TYPE`: +they also describe historical consequence families, requested-target/subject +validation, evidence floors and correction provenance. Removing observation +entries would skip capture checks or change historical interpretation. +Those maps are representational, not sufficient current acceptance authority; +the existing governed pipeline remains the only supported acceptance entry. + +Direct captures retain `claimState: PENDING_REVIEW` because that is the existing +inert assertion vocabulary, not a promise that acceptance is enabled. They may +remain visible in the pending queue. A distinct authorized reviewer may still +reject them under D20; the asserter may not self-reject. Rejection remains +terminal and append-only. A blocked acceptance must not consume the claim or +prevent a later lawful rejection. No queue UI, new state or invented decline +is added. User-facing diagnostics must not promise that an advisor can accept. + +## History, correction and non-effects + +History is deliberately preserved. Matching old successful idempotency keys +still return old accepted references before eligibility is reevaluated. No +assertion, acceptance, consequence or lineage is repaired, deleted or relabelled. +The same body under a fresh key encounters the new rule. Previously queued +observations also cannot newly accept; they can remain pending or be lawfully +rejected. Existing accepted observations stay available for reads and governed +CONTEST. Old acceptance replay is not new acceptance authority. + +An otherwise-valid new observation correction can retain its existing inert +intent and pending assertion, but cannot produce a successor or retire its +predecessor. A previously queued correction is similarly blocked before the +retirement check/emitter. Existing provenance/compatibility validation can +still refuse it earlier. An old disputed observation may therefore remain +unresolved by correction until a future explicit typed-semantics decision. +This material limit is accepted only if the user approves this decision. + +Operation/structure/compliance acceptance, correction and review decisions, +strict confirmation parsing, raw request digest, actor binding, grant ownership, +evidence policy, transaction ownership, D20 rejection, contest rules, signing, +publication custody, canonical contracts, manifests and production closure +are non-effects. No general promotion matrix or latent structure predicate +repair travels here. #179 and #180 retain their wider programme scope. + +## Decision-level invariants and falsifiable verification + +All candidate results below are **planned**, not executed Phase A evidence. +Use fictional fixtures, real HTTP and durable Store records in function-isolated +disposable PostgreSQL databases. Preserve earlier refusal reasons and check +whole accepted-record/lineage snapshots, not response enums alone. + +| ID | Required result and counterexample | +| --- | --- | +| O01 | Valid fresh confirmed direct observations with reviewer omitted/null/self/distinct all retain one pending assertion and emit zero accepted reviews/consequences/retirements. The unmodified base positively reproduces direct acceptance for omitted/null/self; the candidate refuses that capability. Check direct-call and real HTTP paths. | +| O02 | Omitted and literal-false confirmation retain capture-only behavior and ordinary diagnostics, irrespective of reviewer hint; true cannot accept. Malformed confirmation still gets pre-transaction 422 and actor mismatch retains 403 precedence. Raw body and digest stay unchanged. Do not substitute another class's confirmation controls for observation coverage. | +| O03 | Continue the same captured observation: self and distinct queued acceptance cannot consume it; both leave zero REVIEW edges and accepted outputs. A distinct authorized rejection still appends exactly one REJECTED decision/edge, changes derived disposition without editing claimState, and rejects duplicate review. Self-rejection remains refused. | +| O04 | Missing/invalid evidence, authority, requested target, subject, scope, rationale, bundle and already-decided target retain the earlier governing refusals and create no accepted truth. Valid observation creation without REVIEW_ACCEPT still permits inert capture; granting REVIEW_ACCEPT cannot bypass O01/O03. | +| O05 | Valid observation correction, direct or previously queued, never emits successor/accepted review/retirement or changes the old accepted/disputed predecessor. Earlier provenance/compatibility refusals remain meaningful. Operation, bounded structure and independently reviewed compliance keep their acceptance/contest/correction positive controls. | +| O06 | In one retained disposable database, unmodified base HTTP creates fictional direct and queued accepted observations, pending observation/correction targets and history. Candidate matching historical keys reuse exact old references with no new accepted objects/edges; fresh keys cannot accept. Old accepted observations remain readable/contestable, old pending targets cannot newly accept, and old raw record bytes remain unchanged. | +| O07 | No new authority, state, contract, transaction owner or production route. All emitted records remain reachable; refusal/rollback/replay preserve atomicity and accepted-state non-effects. Production closure, mandatory package/architecture checks, pinned full inventory, fresh exact-head review, two hosted baselines and publication receipt are required implementation evidence. | + +The implementation tests must distinguish the public request outcome, immutable +claimState, derived review disposition and emitted authority/lineage records. +For accepted-history controls, run unmodified base bytes against the same +retained disposable database before candidate bytes. Do not create fake past +acceptances by changing candidate guards, disabling protection, monkeypatching +emitters or inserting hand-claimed accepted results. + +Expected areas are `kernel/stages.py`, a focused observation eligibility test +module, observation expectations/fixtures in `test_review_fixes.py` and +`test_correction_authorization.py`, affected conformance/review tests, +`docs/REVIEW_DISPUTE_SEMANTICS.md`, this RFC and the generated test inventory. +The current H3 positive observation control must become a durable-evidence +capture control. Observation members of correction matrices must not simply +be removed: retain historical-target/cross-family negatives and assert the +new refusal while preserving other families' positive paths. Additions within +this boundary are explained in final scope; semantic expansion needs approval. + +Before each commit run the mandatory package check with CPython 3.12.13, then +whitespace and relevant cheap checks. After approval, run the focused real +PostgreSQL 17.10 tests and history probe, update inventory through the prescribed +maintenance command, and obtain an exact-head content review with zero Blockers. +Only then admit fresh hosted baselines and separate publication. Existing +PR #386 tests, reviews, admission and receipts are historical and cannot replace +new implementation evidence. No expensive baseline is requested for Phase A. + +## Alternatives, excellence and approval limits + +- **EXC-001:** one observation acceptance condition at the common pre-emission + gate; existing validators remain owners of their distinct earlier decisions. +- **EXC-002:** no second policy table, authoritative state, durable flag or + copied emitter; both paths consume the same eligibility decision. +- **EXC-003:** O01–O07 trace real entry points through stored effects, including + omitted/false, independent reviewer, correction and retained-history controls. +- **EXC-004:** retire the positive claim that current observation acceptance is + permitted; retain historical/type maps that still serve validation and reads. +- **EXC-005:** no abstraction is proposed; two guarded emissions justify one + direct condition, not a new framework. +- **EXC-006:** routing only self-review to a distinct actor is fewer changed + paths but conflicts with #179. Deleting map entries skips material evidence/ + subject checks; a full allowlist/matrix redesign belongs to #179. Blocking + all governance acts on observations would wrongly remove rejection/contest. + +This is provisional pre-deployment maintenance. Removing the guard requires a +new explicit decision supported by typed observation semantics and corresponding +acceptance evidence; an arbitrary later payload or reviewer grant cannot open +the path. Phase A is acceptable because it reduces unsupported accepted force +while preserving captures and history. An invariant failure or unavoidable +change to another authority boundary requires redesign before implementation. + +The live card must name the created draft PR and carry the exact approval form: +`I approve OFARM2 decision OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001 version 1.` +Capability, effects/non-effects, authority, O01–O07, historical treatment or +named-PR changes require a new version and exact same-task approval. Generic +“go,” earlier approvals, review findings and GitHub activity do not authorize +this runtime change. Final merge remains a separate later exact-head stop. + +Next: review this Phase A at its exact draft head, resolve demonstrated design +Blockers, and present the complete same-task semantic decision card. From 0931e47a2f03c5ff10eeeaac12108e2add175544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samo=20A=C4=8Dko?= Date: Sun, 13 Sep 2026 22:16:17 +0200 Subject: [PATCH 2/4] Specify retained observation history fixtures Resolve PR388 design review B1 by sharing the pinned unmodified-base retained-database fixture across O06 and six correction negatives. Clarify diagnostics and evidence-case reporting without changing O01-O07 or runtime; track separate reporting work in Delivery389. --- ...Legacy_Observation_Eligibility_RFC_v0_1.md | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md index 45cbf4df..725c0ef4 100644 --- a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md +++ b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md @@ -95,13 +95,19 @@ One explicit observation condition controls both outcomes. A direct observation that passed earlier gates uses the existing pending-assertion emitter and returns `RETAIN_DRAFT`. Omitted/false confirmation retains the ordinary capture diagnostic; literal true reports registered `HIGH_CONSEQUENCE_BLOCKED` -with text explaining that acceptance awaits approved typed semantics and +with the exact title `Observation acceptance disabled` and text explaining +that acceptance awaits approved typed semantics and cannot be enabled by a distinct reviewer. A queued observation ACCEPT that reaches this gate returns `RETAIN_DRAFT` with the same eligibility explanation, without a new assertion, ReviewDecision, REVIEW edge, consequence or retirement. -No new constant set, helper, policy service, contract value or persistent flag +No new runtime constant set, helper, policy service, contract value or persistent flag is needed for this single condition with two actual emission paths. +For the O02 diagnostic checks, assert the pair `(HIGH_CONSEQUENCE_BLOCKED, +Observation acceptance disabled)` on the new direct/queued eligibility result; +earlier refusals keep their existing code/title. This pins an implementation +diagnostic without changing O02 or introducing a new reason-code enum. + Existing earlier refusals retain their precedence. In particular, the asserter's queued self-acceptance still fails the existing D8 validator with `HUMAN_APPROVAL_REQUIRED`; a distinct reviewer passes that relationship check @@ -115,6 +121,9 @@ validation, evidence floors and correction provenance. Removing observation entries would skip capture checks or change historical interpretation. Those maps are representational, not sufficient current acceptance authority; the existing governed pipeline remains the only supported acceptance entry. +In particular, accepting `ACCEPTED_OBSERVATION_OCCURRENCE_STATE` as a matching +requested-target value establishes type compatibility only: a well-shaped +request still reaches the new acceptance-disabled outcome. Direct captures retain `claimState: PENDING_REVIEW` because that is the existing inert assertion vocabulary, not a promise that acceptance is enabled. They may @@ -124,6 +133,17 @@ terminal and append-only. A blocked acceptance must not consume the claim or prevent a later lawful rejection. No queue UI, new state or invented decline is added. User-facing diagnostics must not promise that an advisor can accept. +Evidence sufficiency remains distinct from acceptance eligibility. Direct +observation capture does not create `case_payload`, so `_store_case` returns +without inserting an EvidenceSufficiencyCase; use the existing pending emitter +with `amend_case_for_routing=False`. A validated queued ACCEPT may already have +persisted a satisfied evidence case before this guard. Its evidence decision +is not acceptance permission and is not retroactively amended. The new +eligibility explanation belongs to the result problems and promotion gate log. +[Delivery #389](https://github.com/samovers/OFARM2/issues/389), under #179, owns +any separate improvement to retained case/final-outcome reporting for consumers; +this PR neither invents a direct observation case nor changes case semantics. + ## History, correction and non-effects History is deliberately preserved. Matching old successful idempotency keys @@ -176,6 +196,8 @@ emitters or inserting hand-claimed accepted results. Expected areas are `kernel/stages.py`, a focused observation eligibility test module, observation expectations/fixtures in `test_review_fixes.py` and `test_correction_authorization.py`, affected conformance/review tests, +one small test-local retained-history fixture/base-phase driver (expected +`kernel/tests/observation_history.py`, shared with O06), `docs/REVIEW_DISPUTE_SEMANTICS.md`, this RFC and the generated test inventory. The current H3 positive observation control must become a durable-evidence capture control. Observation members of correction matrices must not simply @@ -183,6 +205,44 @@ be removed: retain historical-target/cross-family negatives and assert the new refusal while preserving other families' positive paths. Additions within this boundary are explained in final scope; semantic expansion needs approval. +**Concrete mechanism for those retained negatives:** reuse O06's two-process +retained-database procedure for all six observation-predecessor rows, rather +than asking candidate `_original` to create a newly accepted observation. +The test-local fixture runs unmodified pinned base +`9d7541d96bc708e9270b986927d7f4b8a035454f` (tree +`63d532112ed7c705997d0d71f5f6d0fec12928f7`) in a separate process to create real +accepted predecessors through the legacy HTTP path, closes that process, then +opens the candidate on the same function-isolated disposable database. For +the stale-target C05 row, base also queues both corrections and accepts the +competing correction before candidate revalidation. The candidate must retain +the earlier `SUPERSEDED_RECORD_USED` refusal for the loser, not merely fail at +the new guard. The three cross-family rows and separate same-family/different- +subject row preserve their relationship refusal and absence of a new assertion; +the latter specifically keeps `CORRECTION_REQUIRED`. The rejection row retains +its lawful terminal rejection and unchanged predecessor. Other families keep +their existing candidate fixtures and positive paths. No row is dropped and +no raw accepted-record seeding or guard exception is granted. + +The helper materializes the complete fixed base from local Git objects into +one owned temporary source tree per pytest session, authenticating its +commit/tree and executed source bytes. Hosted conformance already supplies +full history (`.github/workflows/conformance.yml`, checkout `fetch-depth: 0`); +missing local objects fail clearly, without a network fetch, skip or fallback. +Each affected case adds one base process and one owned isolated database, +using the pinned interpreter/dependencies and explicit per-database DSN. +The base process imports only that base runtime; its JSON output carries +scenario/actor/result references and snapshots, not a replacement writer. +Candidate connections open after base exits. Finally close all connections, +drop only the owned database and remove owned temporary outputs. This is +bounded test infrastructure shared with O06, not a checked-in old-runtime copy, +production service, workflow change or new publication boundary. + +For historical queued-acceptance replay, preserve the complete normalized +governance submission through `POST /commit`, including its original generated +decision-time field. Calling `/review/accept` again generates a new time and +therefore a different source digest. Tenant, runtime-bundle and source-digest +matching remain mandatory; never weaken replay checks to reuse a key. + Before each commit run the mandatory package check with CPython 3.12.13, then whitespace and relevant cheap checks. After approval, run the focused real PostgreSQL 17.10 tests and history probe, update inventory through the prescribed @@ -201,8 +261,9 @@ new implementation evidence. No expensive baseline is requested for Phase A. omitted/false, independent reviewer, correction and retained-history controls. - **EXC-004:** retire the positive claim that current observation acceptance is permitted; retain historical/type maps that still serve validation and reads. -- **EXC-005:** no abstraction is proposed; two guarded emissions justify one - direct condition, not a new framework. +- **EXC-005:** no runtime abstraction is proposed; two guarded emissions justify + one direct condition. The bounded test fixture has existing consumers in + O06 and the retained correction negatives, not a hypothetical future use. - **EXC-006:** routing only self-review to a distinct actor is fewer changed paths but conflicts with #179. Deleting map entries skips material evidence/ subject checks; a full allowlist/matrix redesign belongs to #179. Blocking From 4f6fa2036863cefb8f7635b474aae7ea8faedbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samo=20A=C4=8Dko?= Date: Sun, 13 Sep 2026 22:41:35 +0200 Subject: [PATCH 3/4] Keep legacy observation acceptance closed pending typed semantics Implement approved OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001 v1 for Delivery #387. Preserve valid capture and historical review/replay while blocking both new acceptance paths before retirement. Retain all correction matrix controls using genuine fixed-base HTTP history and pin the expanded test inventory. --- .../review_baseline_test_inventory.json | 156 ++++++- docs/REVIEW_DISPUTE_SEMANTICS.md | 76 +++- ...Legacy_Observation_Eligibility_RFC_v0_1.md | 49 +- kernel/stages.py | 26 ++ kernel/tests/conftest.py | 1 + kernel/tests/observation_history.py | 215 +++++++++ kernel/tests/test_correction_authorization.py | 86 +++- kernel/tests/test_observation_eligibility.py | 418 ++++++++++++++++++ kernel/tests/test_review_fixes.py | 15 +- 9 files changed, 986 insertions(+), 56 deletions(-) create mode 100644 kernel/tests/observation_history.py create mode 100644 kernel/tests/test_observation_eligibility.py diff --git a/conformance/review_baseline_test_inventory.json b/conformance/review_baseline_test_inventory.json index 81872502..f6027148 100644 --- a/conformance/review_baseline_test_inventory.json +++ b/conformance/review_baseline_test_inventory.json @@ -3465,6 +3465,156 @@ "sourceModule": "kernel.tests.test_migration_sets", "sourcePath": "kernel/tests/test_migration_sets.py" }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_needs_creation_grant_but_accept_grant_cannot_enable_it", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_cross_bundle_acceptance_keeps_prior_refusal", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_direct_refusals[no-authority-AUTHORITY_DENIED-AUTHORITY]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_direct_refusals[no-durable-evidence-EVIDENCE_INSUFFICIENT-EVIDENCE_SUFFICIENCY]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_direct_refusals[wrong-farm-subject-SCOPE_NOT_AUTHORIZED-VALIDATION]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_direct_refusals[wrong-subject-type-IDENTITY_UNRESOLVED-VALIDATION]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_direct_refusals[wrong-target-HIGH_CONSEQUENCE_BLOCKED-VALIDATION]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_queue_refusals[no-authority-AUTHORITY_DENIED]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_queue_refusals[no-rationale-EVIDENCE_INSUFFICIENT]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_queue_refusals[wrong-evidence-EVIDENCE_REFERENCE_UNAVAILABLE]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_keeps_earlier_queue_refusals[wrong-target-EVIDENCE_REFERENCE_UNAVAILABLE]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_late_failure_rolls_back_capture_and_retry_stays_inert", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_transport_refusals_precede_all_durable_effects[1]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_transport_refusals_precede_all_durable_effects[None]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_transport_refusals_precede_all_durable_effects[confirmation3]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_transport_refusals_precede_all_durable_effects[true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_real_base_history_replays_but_fresh_observation_acceptance_stays_disabled", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_same_observation_survives_acceptance_refusals_then_terminal_rejection", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, { "nodeid": "kernel/tests/test_ofarm_pkg_contract_check.py::test_boolean_additional_properties_forms_are_explicit", "sourceModule": "kernel.tests.test_ofarm_pkg_contract_check", @@ -11896,7 +12046,7 @@ "sourcePath": "kernel/tests/test_review_fixes.py" }, { - "nodeid": "kernel/tests/test_review_fixes.py::test_h3_promoting_observation_with_evidence_promotes", + "nodeid": "kernel/tests/test_review_fixes.py::test_h3_observation_with_durable_evidence_is_captured", "sourceModule": "kernel.tests.test_review_fixes", "sourcePath": "kernel/tests/test_review_fixes.py" }, @@ -22416,8 +22566,8 @@ "sourcePath": "kernel/tests/test_tenant_uow_observation.py" } ], - "entriesSha256": "737e688267c914da5d829097004d7ea9f94726f80e0d02b9136478574573b384", - "entryCount": 4483, + "entriesSha256": "b0d3a235ac23c01dc9e82c3ce1e2bfd5cce9b3952854e68fd426a033f65b8370", + "entryCount": 4513, "schemaVersion": "ofarm.review-baseline-test-inventory.v1", "testRoot": "kernel/tests" } diff --git a/docs/REVIEW_DISPUTE_SEMANTICS.md b/docs/REVIEW_DISPUTE_SEMANTICS.md index 29159401..2024a064 100644 --- a/docs/REVIEW_DISPUTE_SEMANTICS.md +++ b/docs/REVIEW_DISPUTE_SEMANTICS.md @@ -1,9 +1,10 @@ # Review / dispute state-transition semantics **Status:** G5-1 (REJECT) settled · G5-3 (CONTEST) settled — see §6. -The approved PR #380 correction authorization amendment is recorded in §6.7 +The merged PR #380 correction authorization amendment is recorded in §6.7 and the [version-2 design](rfcs/OFARM_Legacy_Correction_Authorization_RFC_v0_2.md); -its implementation is written, with verification and content review pending. +the approved [PR #388 observation eligibility restriction](rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md) +narrows its observation acceptance and correction availability as described below. **Scope:** generic Core/Platform review-verb semantics only. No Slovenia specifics (those ride a profile through the generic mechanism — M2 brief mechanism-boundary rule). **This is a candidate package decision (DECISIONS.md @@ -22,9 +23,10 @@ not an "obvious default." Every effect — on the queued assertion, its A queued claim is an `AssertionRecord` (`ofarm.assertionrecord.v0.1`) emitted with `claimState = "PENDING_REVIEW"` (`kernel/emission.py:157-159`, -`emit_pending_assertion`). Acceptance is the reviewer's **own** governed -`GOVERNANCE_DECISION` commit. It **never edits the queued assertion**; it -**appends** new records (`kernel/emission.py:238-313`, `emit_queue_acceptance`): +`emit_pending_assertion`). Where acceptance is enabled, it is the reviewer's +**own** governed `GOVERNANCE_DECISION` commit. It **never edits the queued +assertion**; it **appends** new records (`kernel/emission.py:238-313`, +`emit_queue_acceptance`): - a `ReviewDecision` (`ofarm.reviewdecision.v0.1`) — `reviewAction = "REVIEW_ACCEPT"`, `decisionOutcomeState = "ACCEPTED"`, `reviewedArtifactRef` @@ -134,14 +136,46 @@ replay-policy change is included. Queue acceptance itself leaves the original assertion's `claimState: PENDING_REVIEW` bytes unchanged; accepted disposition and removal from the pending queue are derived as described in §1. -**Known K04 residual:** an otherwise authorized and evidenced observation -asserter can directly self-accept a confirmed `OBSERVATION_ASSERTION` on the -legacy route with omitted, null or self-named reviewer metadata, while its -own queued acceptance is refused. This unresolved discrepancy is not permitted -D8 behavior. [Delivery #387](https://github.com/samovers/OFARM2/issues/387) -owns that separate correction; PR #386 does not approve or implement it, and -does not certify the wider self-review matrix. Production governed routes -remain closed. The original scoped R04 and historical decision in +**Approved observation eligibility restriction:** [Delivery #387 / PR #388](https://github.com/samovers/OFARM2/pull/388) +keeps otherwise-valid legacy `OBSERVATION_ASSERTION` submissions as captures +and disables every new direct or queued observation acceptance until approved +typed semantics exist. This closes the K04 direct/queued self-review discrepancy +and implements that eligibility limit from #179; a distinct reviewer or stronger +review grants cannot enable acceptance. Earlier authority, validation and +evidence refusals keep their existing precedence and explanations. + +A direct observation that passes those earlier gates retains one assertion +with immutable `claimState: PENDING_REVIEW` and returns `RETAIN_DRAFT`. +Omitted or false confirmation keeps ordinary capture diagnostics. Literal true, +and a queued ACCEPT that reaches the common promotion guard, report +`HIGH_CONSEQUENCE_BLOCKED` with title `Observation acceptance disabled`. +Neither path emits a new accepted ReviewDecision, consequence or retirement. +Queue acceptance leaves its target unconsumed; self-acceptance can still refuse +earlier under D8. Pending visibility does not promise acceptance availability. +A distinct authorized reviewer can still reject the claim, with terminal +disposition derived from the new REVIEW edge rather than a claimState edit; +self-rejection and duplicate review retain their existing refusals. + +This restriction also covers otherwise-valid new and previously queued +observation corrections: inert intent can remain, but no successor is accepted +and no predecessor is retired. It expressly narrows observation availability +under correction decision `OFARM2-LEGACY-CORRECTION-AUTHORIZATION-001` +version 2, C03/C10 (§6.7), without changing its relationship or retirement +authority checks. Existing accepted observations remain readable and +contestable. Matching historical accepted keys still replay their original +references under the unchanged digest, tenant and runtime-bundle checks; +fresh keys face the restriction. No historical bytes are repaired or relabelled. +An old disputed observation can therefore remain unresolved by correction. +Other assertion families keep their decided acceptance and correction paths. + +Evidence sufficiency and acceptance eligibility remain separate. Direct +observation capture does not create an EvidenceSufficiencyCase; a queued ACCEPT +may already have persisted a satisfied case before the eligibility refusal. +That case is not acceptance permission. The result problems and promotion gate +log carry the final eligibility explanation. Separate [Delivery #389](https://github.com/samovers/OFARM2/issues/389) +owns retained-case/final-outcome reporting improvements; PR #388 changes no case +semantics. Production governed routes remain closed. The original scoped R04 +and historical decision in [the version-2 review-confirmation RFC](rfcs/OFARM_Legacy_Review_Confirmation_RFC_v0_2.md) remain unchanged. @@ -689,11 +723,17 @@ amendment separates validating that relationship from authorizing retirement: emission. Its request, result and trace are persisted and referenced by the `PromotionTrace` gate's `relatedArtifactRefs`. -This preserves all four acceptance → contest → compatible correction paths and -the existing evidence, self-review, CONTEST and REJECT rules. Refusal and rejection -retire nothing. Accepted history may originate under an older RuntimeBundle; -a queued assertion being accepted must still satisfy the existing current-bundle -gate. Standalone `REVIEW_SUPERSEDE` requests and `recordClass = "CORRECTION"` +PR #380 preserved all four acceptance → contest → compatible correction paths. +The later approved observation eligibility decision in §3.1 narrows only new +observation acceptance and correction: valid intent can be captured, but no new +observation successor or retirement can be emitted. The historical correction +decision's C03/C10 remain unchanged records of its original scope. Operation, +bounded structure and independently reviewed compliance retain their acceptance +and compatible correction paths. Existing evidence, relationship, CONTEST and +REJECT rules remain; refusal and rejection retire nothing. Accepted history may +originate under an older RuntimeBundle; a queued assertion being accepted must +still satisfy the existing current-bundle gate. Standalone `REVIEW_SUPERSEDE` +requests and `recordClass = "CORRECTION"` carriers remain closed. The primary trust boundary is semantic promotion and supersession authorization; production activation and transaction ownership are outside this amendment. diff --git a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md index 725c0ef4..79da64a1 100644 --- a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md +++ b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md @@ -1,11 +1,25 @@ # Legacy observation acceptance eligibility -Status: **Phase A proposal; runtime implementation is not approved**. +Status: **Approved decision; implemented, with review and hosted evidence pending**. Decision: `OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001`, version 1. Delivery: [#387](https://github.com/samovers/OFARM2/issues/387), under #180; -related promotion-matrix programme: #179. The existing draft PR must be named -in the live task-user decision card before approval. This record is design -evidence, not user approval or OFARM law. +implementation: [PR #388](https://github.com/samovers/OFARM2/pull/388); +related promotion-matrix programme: #179. This record is design evidence, +not user approval or OFARM law. + +Approval navigation: in Codex task `01a07cc8-4157-7b33-a0ca-becb772e0e8b`, +the original card is message +`msg_0d813f8071772f9a016aa6ef908cdc87d2b3edbcf339956013` +(2026-09-13T18:46:59.909Z, session line 11819), naming existing draft PR #388. +The later task-user message +`msg_01a09c78-89b3-75b3-91fa-c8e6d25863b8` +(2026-09-13T20:32:20.403Z, session line 12080) states exactly: +`I approve OFARM2 decision OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001 version 1.` +The original messages were directly retrieved in that order. The design +clarifications at `0931e47a2f03c5ff10eeeaac12108e2add175544` preserved O01–O07 +and the live card's semantic scope. This reference supplies navigation only; +the task-user message supplies approval. Merge requires a later exact-head +packet and authorization under `AGENTS.md`. Design base: `9d7541d96bc708e9270b986927d7f4b8a035454f`, after merged PR #386; runtime tree: `63d532112ed7c705997d0d71f5f6d0fec12928f7`. @@ -13,9 +27,9 @@ PRs #380, #382, #384 and #386 remain completed historical work. ## Problem, selected outcome and exact boundary -An authorized actor with observation-creation and review-accept grants can -self-accept a confirmed legacy `OBSERVATION_ASSERTION` with omitted, null or -self-named reviewer metadata. The same actor's queued acceptance is refused +At the design base, an authorized actor with observation-creation and +review-accept grants can self-accept a confirmed legacy `OBSERVATION_ASSERTION` +with omitted, null or self-named reviewer metadata. The same actor's queued acceptance is refused under D8, while a distinct authorized reviewer can accept it. Audit K04 and PR #386's executed review already demonstrate that discrepancy. The complete review is now public as [review 5191268560](https://github.com/samovers/OFARM2/pull/386#pullrequestreview-5191268560), @@ -27,7 +41,7 @@ D8 permits routine-operation self-review; D17 adds only bounded farm-owned structure. Neither grants observation self-review. [Issue #179](https://github.com/samovers/OFARM2/issues/179) also requires observation promotion to remain disabled until typed semantics exist. Merely sending observations to a distinct reviewer would leave that -recorded requirement unmet. The proposed independently useful outcome is: +recorded requirement unmet. The approved independently useful outcome is: **retain otherwise-valid observation captures, but permit no new observation acceptance through either the direct or queued legacy path, for any actor**. This implements only that eligibility limit from #179, not its broader matrix @@ -43,14 +57,14 @@ compatibility/retirement authority, custody or production activation. **Explicit change to an earlier approved capability:** correction decision `OFARM2-LEGACY-CORRECTION-AUTHORIZATION-001` version 2, RFC v0.2 C03/C10, previously preserved observation acceptance and same-family correction. -Approval of this new decision narrows only their observation acceptance +This approved decision narrows only their observation acceptance availability: no new accepted observation or observation correction successor can be emitted, even with valid `REVIEW_ACCEPT` and `REVIEW_SUPERSEDE`. Their relationship/provenance checks and retirement permissions are not weakened. Other assertion families retain their decided correction paths. The historical RFC and completed decision are not rewritten. This explicit restriction, rather than treating a former positive test as disposable, is -why a fresh semantic approval is required. +why this restriction required its own semantic approval. ## Authority, threat and containment @@ -79,7 +93,7 @@ Public production governed routes remain closed (`kernel/api.py:74–83`). This is a legacy development/conformance correction, not a production reachability, readiness or authentication claim. -## Smallest proposed correction and ordering +## Smallest correction and ordering Keep every existing type/target map and all earlier gates. At the start of `ReviewPromotionGate.run`, before reviewer-hint routing, retirement authority @@ -160,7 +174,7 @@ predecessor. A previously queued correction is similarly blocked before the retirement check/emitter. Existing provenance/compatibility validation can still refuse it earlier. An old disputed observation may therefore remain unresolved by correction until a future explicit typed-semantics decision. -This material limit is accepted only if the user approves this decision. +This material limit is part of the approved decision. Operation/structure/compliance acceptance, correction and review decisions, strict confirmation parsing, raw request digest, actor binding, grant ownership, @@ -171,7 +185,8 @@ repair travels here. #179 and #180 retain their wider programme scope. ## Decision-level invariants and falsifiable verification -All candidate results below are **planned**, not executed Phase A evidence. +The table defines required candidate results, not a claim that checks passed. +Executed results belong to PR #388's implementation evidence. Use fictional fixtures, real HTTP and durable Store records in function-isolated disposable PostgreSQL databases. Preserve earlier refusal reasons and check whole accepted-record/lineage snapshots, not response enums alone. @@ -244,7 +259,7 @@ therefore a different source digest. Tenant, runtime-bundle and source-digest matching remain mandatory; never weaken replay checks to reuse a key. Before each commit run the mandatory package check with CPython 3.12.13, then -whitespace and relevant cheap checks. After approval, run the focused real +whitespace and relevant cheap checks. Run the focused real PostgreSQL 17.10 tests and history probe, update inventory through the prescribed maintenance command, and obtain an exact-head content review with zero Blockers. Only then admit fresh hosted baselines and separate publication. Existing @@ -276,12 +291,10 @@ the path. Phase A is acceptable because it reduces unsupported accepted force while preserving captures and history. An invariant failure or unavoidable change to another authority boundary requires redesign before implementation. -The live card must name the created draft PR and carry the exact approval form: -`I approve OFARM2 decision OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001 version 1.` Capability, effects/non-effects, authority, O01–O07, historical treatment or named-PR changes require a new version and exact same-task approval. Generic “go,” earlier approvals, review findings and GitHub activity do not authorize this runtime change. Final merge remains a separate later exact-head stop. -Next: review this Phase A at its exact draft head, resolve demonstrated design -Blockers, and present the complete same-task semantic decision card. +Next: obtain exact-head content review, then fresh hosted evidence and +publication before presenting the separate final merge packet. diff --git a/kernel/stages.py b/kernel/stages.py index e14f255e..272a7111 100644 --- a/kernel/stages.py +++ b/kernel/stages.py @@ -624,6 +624,32 @@ def run(self, ctx: GateContext) -> GatePass | GateRefusal: sub = ctx.sub confirmed = sub.get("confirmAccept") is True + # Observation capture remains available; new acceptance awaits typed + # semantics (OFARM2-LEGACY-OBSERVATION-ELIGIBILITY-001). Queued ACCEPT + # uses the validated stored target, never a caller's assertion-type hint. + assertion_type = ( + ctx.acceptance_payload.get("assertionType") + if ctx.acceptance_target and ctx.review_branch == "ACCEPT" + else policy.COMMIT_CLASS_TO_ASSERTION_TYPE.get(ctx.commit_class) + ) + if assertion_type == "OBSERVATION_ASSERTION": + problems = [runtime_problem( + "HIGH_CONSEQUENCE_BLOCKED", "Observation acceptance disabled", + "observation acceptance requires typed semantics that are not " + "available; a distinct reviewer cannot enable acceptance", + severity="WARNING")] if confirmed or ctx.acceptance_target else [] + ctx.log("REVIEW_PROMOTION", "RETAIN_DRAFT", + reason_code=problems[0]["reasonCode"] if problems else None, + rationale=problems[0]["detail"] if problems else + "no review act: capture is not commitment (Kernel rule 3)") + if ctx.acceptance_target: + return GateRefusal("REVIEW_PROMOTION", "RETAIN_DRAFT", + "RETAIN_DRAFT", problems) + emitter.emit_pending_assertion(amend_case_for_routing=False) + ctx.problems.extend(problems) + ctx.final_outcome = "RETAIN_DRAFT" + return GatePass() + # D8 scopes self-review to ROUTINE OPERATION CLAIMS. A compliance # assertion reviewed by its own asserter is outside that scope and # outside the pilot's claim limits — it routes to the advisor queue. diff --git a/kernel/tests/conftest.py b/kernel/tests/conftest.py index 3c4abe79..1a91916b 100644 --- a/kernel/tests/conftest.py +++ b/kernel/tests/conftest.py @@ -23,6 +23,7 @@ from kernel.runtime_activation import complete_store_startup # noqa: E402 from kernel.runtime_bundle import RuntimeBundleBuilder # noqa: E402 from kernel.store import Store # noqa: E402 +from kernel.tests.observation_history import observation_base_tree # noqa: E402,F401 EVIDENCE_DIR = config.PACKAGE_ROOT / "conformance" / "evidence" PLATFORM_MVP_EVIDENCE_SUITE = manifest.PLATFORM_MVP_TEST_SUITE_REF diff --git a/kernel/tests/observation_history.py b/kernel/tests/observation_history.py new file mode 100644 index 00000000..b56bafad --- /dev/null +++ b/kernel/tests/observation_history.py @@ -0,0 +1,215 @@ +"""Real observation history from one fixed, unmodified pre-restriction runtime. + +Only the immutable source tree is session-shared. Each caller owns a fresh DB; +the base subprocess exits before the candidate opens it. No accepted row is +fabricated and no candidate protection is disabled. +""" +from __future__ import annotations + +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import sys +import tarfile +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from uuid import uuid4 + +import psycopg +from psycopg import sql +from psycopg.conninfo import conninfo_to_dict, make_conninfo +import pytest + + +BASE_COMMIT = "9d7541d96bc708e9270b986927d7f4b8a035454f" +BASE_TREE = "63d532112ed7c705997d0d71f5f6d0fec12928f7" + + +@pytest.fixture(scope="session") +def observation_base_tree(): + """Authenticate all fixed-base files from local Git objects, without fetch.""" + checkout = Path(__file__).resolve().parents[2] + git_env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + + def git(*args): + return subprocess.run( + ["git", "-c", "core.hooksPath=" + os.devnull, "-c", "core.fsmonitor=false", *args], + cwd=checkout, env=git_env, check=True, capture_output=True).stdout + + assert git("rev-parse", BASE_COMMIT + "^{commit}").decode().strip() == BASE_COMMIT + assert git("rev-parse", BASE_COMMIT + "^{tree}").decode().strip() == BASE_TREE + objects, modes = {}, {} + for entry in git("ls-tree", "-r", "-z", BASE_COMMIT).split(b"\0"): + if entry: + meta, name = entry.split(b"\t", 1) + mode, kind, oid = meta.decode().split() + assert mode in {"100644", "100755"} and kind == "blob", entry + objects[name.decode()] = oid + modes[name.decode()] = 0o755 if mode == "100755" else 0o644 + with TemporaryDirectory(prefix="ofarm-observation-base-") as directory: + root = Path(directory) + digests = {} + with tarfile.open(fileobj=io.BytesIO(git("archive", BASE_COMMIT))) as archive: + for member in archive: + name = PurePosixPath(member.name) + assert not name.is_absolute() and ".." not in name.parts, member.name + destination = root.joinpath(*name.parts) + if member.isdir(): + destination.mkdir(parents=True, exist_ok=True) + continue + assert member.isfile() and member.name in objects, member.name + assert member.name not in digests, member.name + data = archive.extractfile(member).read() + blob = b"blob " + str(len(data)).encode() + b"\0" + data + assert hashlib.sha1(blob).hexdigest() == objects[member.name], member.name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + destination.chmod(modes[member.name]) + digests[member.name] = hashlib.sha256(data).hexdigest() + assert digests.keys() == objects.keys() + yield root, digests + + +@pytest.fixture +def observation_history(observation_base_tree): + """Return one candidate env per caller, following completed base HTTP setup.""" + from contextlib import ExitStack + + from fastapi.testclient import TestClient + from kernel.gates import GatePipeline + from kernel.legacy_m1.api import create_test_app + from kernel.runtime_activation import complete_store_startup + from kernel.tests.conftest import _admin_dsn, _bound_store + from kernel.tests.test_review_confirmation import _assert_prior_records_unchanged, _snapshot + + assert sys.version_info[:3] == (3, 12, 13), "history needs pinned CPython 3.12.13" + root, digests = observation_base_tree + with ExitStack() as cleanup: + def open_history(scenario="original"): + assert scenario in {"original", "stale", "replay", "field"} + dbname = "ofarm_obs_history_" + uuid4().hex + + def drop(): + with psycopg.connect(_admin_dsn(), autocommit=True) as admin: + admin.execute(sql.SQL("DROP DATABASE {}").format(sql.Identifier(dbname))) + + with psycopg.connect(_admin_dsn(), autocommit=True) as admin: + admin.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(dbname))) + cleanup.callback(drop) + params = conninfo_to_dict(_admin_dsn()) + params["dbname"] = dbname + dsn = make_conninfo(**params) + child_env = {key: value for key, value in os.environ.items() + if not key.startswith("OFARM_")} + child_env["OFARM_DEPLOYMENT_IMAGE_DIGEST"] = "sha256:" + "a" * 64 + child = subprocess.run( + [sys.executable, "-I", "-B", str(Path(__file__).resolve()), str(root), scenario], + input=json.dumps({"dsn": dsn, "digests": digests}), text=True, + capture_output=True, env=child_env, cwd=root, timeout=120) + assert child.returncode == 0, child.stderr + history = json.loads(child.stdout) + assert history["source"]["commit"] == BASE_COMMIT + assert history["source"]["tree"] == BASE_TREE + store = _bound_store(dsn) + cleanup.callback(store.close) + complete_store_startup(store) + pipeline = GatePipeline(store) + client = cleanup.enter_context(TestClient(create_test_app(store, oidc=None))) + + def preserve_history(): + after = _snapshot(store) + _assert_prior_records_unchanged(history["snapshot"], after) + edges = {edge["edge_id"]: edge for edge in after["kernel_edge"]} + for edge in history["snapshot"]["kernel_edge"]: + assert edges[edge["edge_id"]] == edge + + preserve_history() + cleanup.callback(preserve_history) + return SimpleNamespace( + store=store, pipeline=pipeline, + outputs=pipeline.runtime_services.output_assembler, client=client, + history=history, **history["actors"]) + + yield open_history + + +def _base_phase(root, scenario, request): + """Base-only child: accepted outputs come exclusively from real HTTP.""" + assert not any(name == "kernel" or name.startswith("kernel.") for name in sys.modules) + sys.path.insert(0, str(root)) + from fastapi.testclient import TestClient + from kernel import demo + from kernel.legacy_m1.api import create_test_app + from kernel.runtime_activation import complete_store_startup + from kernel.tests.conftest import _bound_store + from kernel.tests import test_correction_authorization as correction + from kernel.tests.test_review_confirmation import _snapshot + + store = _bound_store(request["dsn"]) + try: + complete_store_startup(store) + demo.bootstrap(store) + with TestClient(create_test_app(store, oidc=None)) as client: + actors = { + "author": correction._actor(store, correction.ASSERT_ACTIONS), + "accept_only": correction._actor(store, ("REVIEW_ACCEPT",)), + "reviewer": correction._actor( + store, (*correction.ASSERT_ACTIONS, *correction.REVIEW_ACTIONS)), + } + env = SimpleNamespace(store=store, client=client, **actors) + original = correction._submission("OBSERVATION_ASSERTION", env.author) + if scenario == "field": + original["targetScopes"] = [{"scopeType": "FIELD", "scopeRef": demo.FIELD}] + assertion = correction._queue(env, original) + acceptance = correction._acceptance_submission(assertion, env.reviewer) + accepted = correction._commit(env, acceptance) + assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted + old = accepted["emittedAcceptedConsequenceRefs"][0] + history = { + "scenario": scenario, "actors": actors, "original": original, + "predecessor": old, "assertion": assertion, + "queued_acceptance": {"submission": acceptance, "result": accepted}, + } + if scenario in {"stale", "replay"}: + pending_correction = correction._correction(original, old) + history["correction"] = pending_correction + history["pending_correction"] = correction._queue(env, pending_correction) + if scenario == "stale": + history["loser"] = correction._queue(env, correction._correction(original, old)) + winner = correction._review(env, history["pending_correction"]) + assert winner["decisionOutcome"] == "PROMOTE_ACCEPTED", winner + history["winner"] = winner + if scenario == "replay": + history["direct_acceptances"] = {} + for hint in ("omitted", "null", "self"): + direct = correction._submission("OBSERVATION_ASSERTION", env.reviewer) + direct["confirmAccept"] = True + if hint != "omitted": + direct["reviewerPartyRef"] = None if hint == "null" else env.reviewer + result = correction._commit(env, direct) + assert result["decisionOutcome"] == "PROMOTE_ACCEPTED", result + history["direct_acceptances"][hint] = {"submission": direct, "result": result} + history["direct_acceptance"] = history["direct_acceptances"]["null"] + history["pending_observation"] = correction._queue( + env, correction._submission("OBSERVATION_ASSERTION", env.author)) + history["snapshot"] = _snapshot(store) + imported = {} + for name, module in tuple(sys.modules.items()): + if name == "kernel" or name.startswith("kernel."): + path = Path(module.__file__).resolve() + relative = path.relative_to(root).as_posix() + digest = hashlib.sha256(path.read_bytes()).hexdigest() + assert digest == request["digests"][relative], relative + imported[relative] = digest + history["source"] = {"commit": BASE_COMMIT, "tree": BASE_TREE, + "importedSources": imported} + return history + finally: + store.close() + + +if __name__ == "__main__": + print(json.dumps(_base_phase(Path(sys.argv[1]).resolve(), sys.argv[2], json.load(sys.stdin)))) diff --git a/kernel/tests/test_correction_authorization.py b/kernel/tests/test_correction_authorization.py index 9942ec4c..d1f6ef1f 100644 --- a/kernel/tests/test_correction_authorization.py +++ b/kernel/tests/test_correction_authorization.py @@ -20,6 +20,7 @@ from kernel.context import now_iso from kernel.contracts import canonical_json from kernel.legacy_m1.api import create_test_app +from kernel.tests.observation_history import observation_history # noqa: F401 FAMILIES = ( @@ -67,8 +68,16 @@ def _actor(store, actions, *, party_class="NATURAL_PERSON"): @pytest.fixture -def env(fresh_env): - store, pipeline, outputs = fresh_env +def env(request): + params = getattr(request.node, "callspec", SimpleNamespace(params={})).params + if params.get("old_family", params.get("family")) == "OBSERVATION_ASSERTION": + # Real fixed-base history preserves the observation members of these + # matrices after new observation acceptance is disabled. + scenario = ("stale" if "rechecks_target" in request.node.name else + "field" if "field_only" in request.node.name else "original") + yield request.getfixturevalue("observation_history")(scenario) + return + store, pipeline, outputs = request.getfixturevalue("fresh_env") with TestClient(create_test_app(store, oidc=None)) as client: yield SimpleNamespace( store=store, pipeline=pipeline, outputs=outputs, client=client, @@ -166,6 +175,9 @@ def _queue(env, sub): def _original(env, family, *, direct=False): + if family == "OBSERVATION_ASSERTION" and hasattr(env, "history"): + assert not direct + return deepcopy(env.history["original"]), env.history["predecessor"] sub = _submission(family, env.reviewer if direct else env.author) if direct: sub["confirmAccept"] = True @@ -232,7 +244,7 @@ def _retirement_receipt(store, result, actor, *, allowed): @pytest.mark.parametrize("family", FAMILIES) def test_each_family_retains_contest_then_authorized_correction(env, family): - """C03/C05–C07/C10: the dispute consumer works for all four source families.""" + """C03/C05–C07/C10: observation history stays contestable, new acceptance closes.""" original, old = _original(env, family) old_bytes = deepcopy(env.store.get_record(old)) contest = _post(env, "/review/contest", { @@ -253,11 +265,23 @@ def test_each_family_retains_contest_then_authorized_correction(env, family): before = _truth(env.store) denied = _review(env, assertion, actor=env.accept_only) _refused(env, denied, before, old) - _retirement_receipt(env.store, denied, env.accept_only, allowed=False) + if family != "OBSERVATION_ASSERTION": + _retirement_receipt(env.store, denied, env.accept_only, allowed=False) + else: + assert denied["problems"][0]["reasonCode"] == "HIGH_CONSEQUENCE_BLOCKED" assert env.store.edges_from(assertion, "REVIEW") == [] acceptance = _acceptance_submission(assertion, env.reviewer) accepted = _commit(env, acceptance) + if family == "OBSERVATION_ASSERTION": + _refused(env, accepted, before, old) + assert accepted["problems"][0]["reasonCode"] == "HIGH_CONSEQUENCE_BLOCKED" + assert not accepted.get("emittedReviewDecisionRefs") + assert env.store.edges_from(assertion, "REVIEW") == [] + assert env.store.get_record(old) == old_bytes + assert env.store.get_payload(assertion)["claimState"] == "PENDING_REVIEW" + assert [edge["dst_record_id"] for edge in env.store.edges_from(old, "DISPUTE")] == [dispute] + return assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted assert len(accepted["emittedAcceptedConsequenceRefs"]) == 1 successor = accepted["emittedAcceptedConsequenceRefs"][0] @@ -322,7 +346,7 @@ def test_structural_correction_refuses_unhashable_schema_version(env, schema_ver @pytest.mark.parametrize("family", FAMILIES) @pytest.mark.parametrize("correct", (False, True), ids=("ordinary-acceptance", "correction-chain")) def test_field_only_event_scopes_preserve_acceptance_and_correction(env, family, correct): - """Contained field anchors remain immutable through both lawful queue paths.""" + """Field anchors remain immutable, including restricted observation intent.""" original = _submission(family, env.author) original["targetScopes"] = [{"scopeType": "FIELD", "scopeRef": demo.FIELD}] assertion = _queue(env, original) @@ -330,15 +354,26 @@ def test_field_only_event_scopes_preserve_acceptance_and_correction(env, family, event_payload = env.store.get_payload(event) assert event_payload["anchorScopes"] == original["targetScopes"] event_bytes = canonical_json(event_payload).encode() + before = _truth(env.store) accepted = _review(env, assertion) - assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted - old = accepted["emittedAcceptedConsequenceRefs"][0] - assert env.store.get_payload(old)["sourceEventRef"] == event + if family == "OBSERVATION_ASSERTION": + old = env.history["predecessor"] + _refused(env, accepted, before, old) + assert accepted["problems"][0]["reasonCode"] == "HIGH_CONSEQUENCE_BLOCKED" + assert not accepted.get("emittedReviewDecisionRefs") + assert env.store.edges_from(assertion, "REVIEW") == [] + else: + assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted + old = accepted["emittedAcceptedConsequenceRefs"][0] + assert env.store.get_payload(old)["sourceEventRef"] == event assert canonical_json(env.store.get_payload(event)).encode() == event_bytes _assert_receipt(env.store, accepted) if not correct: return + if family == "OBSERVATION_ASSERTION": + original = env.history["original"] + old_row = deepcopy(env.store.get_record(old)) contest = _post(env, "/review/contest", { "farmRef": demo.FARM, "consequenceRef": old, @@ -352,7 +387,18 @@ def test_field_only_event_scopes_preserve_acceptance_and_correction(env, family, queued_bytes = canonical_json(env.store.get_payload(queued_event)).encode() assert env.store.get_payload(queued_event)["anchorScopes"] == original["targetScopes"] assert not env.store.is_superseded(old) + before = _truth(env.store) corrected = _review(env, queued) + if family == "OBSERVATION_ASSERTION": + _refused(env, corrected, before, old) + assert corrected["problems"][0]["reasonCode"] == "HIGH_CONSEQUENCE_BLOCKED" + assert not corrected.get("emittedReviewDecisionRefs") + assert env.store.edges_from(queued, "REVIEW") == [] + assert env.store.get_record(old) == old_row + assert canonical_json(env.store.get_payload(event)).encode() == event_bytes + assert canonical_json(env.store.get_payload(queued_event)).encode() == queued_bytes + assert [edge["dst_record_id"] for edge in env.store.edges_from(old, "DISPUTE")] == [dispute] + return assert corrected["decisionOutcome"] == "PROMOTE_ACCEPTED", corrected successor = corrected["emittedAcceptedConsequenceRefs"][0] assert env.store.get_payload(successor)["sourceEventRef"] == queued_event @@ -414,6 +460,8 @@ def test_full_authority_does_not_allow_cross_family_intent(env, old_family, new_ result = _commit(env, correction) _refused(env, result, before, old) assert not result.get("emittedAssertionRecordRefs"), result + if old_family == "OBSERVATION_ASSERTION": + assert result["problems"][0]["reasonCode"] == "CORRECTION_REQUIRED" @pytest.mark.parametrize("family", FAMILIES) @@ -432,21 +480,29 @@ def test_full_authority_does_not_allow_different_subject_or_identity(env, family refused = _commit(env, correction) _refused(env, refused, before, old) assert not refused.get("emittedAssertionRecordRefs"), refused + if family == "OBSERVATION_ASSERTION": + assert refused["problems"][0]["reasonCode"] == "CORRECTION_REQUIRED" @pytest.mark.parametrize("family", FAMILIES) def test_queued_correction_rechecks_target_after_another_correction(env, family): original, old = _original(env, family) - first = _queue(env, _correction(original, old)) - second = _queue(env, _correction(original, old)) - accepted = _review(env, first) - assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted + if family == "OBSERVATION_ASSERTION": + # Both pending claims and the winning acceptance precede the restriction. + second = env.history["loser"] + else: + first = _queue(env, _correction(original, old)) + second = _queue(env, _correction(original, old)) + accepted = _review(env, first) + assert accepted["decisionOutcome"] == "PROMOTE_ACCEPTED", accepted before = _truth(env.store) stale = _review(env, second) assert stale["decisionOutcome"] != "PROMOTE_ACCEPTED", stale assert _truth(env.store) == before assert env.store.edges_from(second, "REVIEW") == [] assert len(env.store.edges_to(old, "LINEAGE_SUPERSEDES")) == 1 + if family == "OBSERVATION_ASSERTION": + assert stale["problems"][0]["reasonCode"] == "SUPERSEDED_RECORD_USED" @pytest.mark.parametrize("location", ("origin-source", "queued-source", "queued-intent", @@ -868,6 +924,12 @@ def test_rejection_still_closes_a_correction_without_retiring_truth(env, family) assert env.store.get_payload(assertion)["claimState"] == "PENDING_REVIEW" assert [edge["dst_record_id"] for edge in env.store.edges_from( assertion, "LINEAGE_SUPERSEDES_INTENT")] == [old] + if family == "OBSERVATION_ASSERTION": + assert len(env.store.edges_from(assertion, "REVIEW")) == 1 + repeated = _review(env, assertion) + _refused(env, repeated, before, old) + assert repeated["problems"][0]["reasonCode"] == "SUPERSEDED_RECORD_USED" + assert len(env.store.edges_from(assertion, "REVIEW")) == 1 @pytest.mark.parametrize("commit_class", ("NOTE", "HYPOTHESIS_ASSERTION", "ADVISORY_OUTPUT")) diff --git a/kernel/tests/test_observation_eligibility.py b/kernel/tests/test_observation_eligibility.py new file mode 100644 index 00000000..42890fc3 --- /dev/null +++ b/kernel/tests/test_observation_eligibility.py @@ -0,0 +1,418 @@ +"""Observation eligibility O01–O07 through real HTTP, pipeline and PostgreSQL. + +Only fictional records and function-isolated disposable databases are used. +Historical acceptance comes from the pinned unmodified base runtime, never +from a candidate guard exception or hand-created accepted record. +""" +from __future__ import annotations + +from copy import deepcopy +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from kernel import demo +from kernel.contracts import sha256_of +from kernel.gates import GatePipeline +from kernel.legacy_m1.api import create_test_app +from kernel.tests.observation_history import ( # noqa: F401 + BASE_COMMIT, + BASE_TREE, + observation_history, +) +from kernel.tests.test_correction_authorization import ( + _acceptance_submission, + _actor, + _grant, +) +from kernel.tests.test_review_confirmation import ( + CONSEQUENCE_KIND, + OMITTED, + REVIEW_KIND, + _assert_prior_records_unchanged, + _assert_valid_replay, + _commit, + _post, + _snapshot, +) +from kernel.tests.test_runtime_bundle_receipts import _second_store + + +DISABLED = ("HIGH_CONSEQUENCE_BLOCKED", "Observation acceptance disabled") + + +def _id(prefix): + return f"{prefix}:observation-eligibility.{uuid4().hex[:12]}" + + +@pytest.fixture +def env(fresh_env): + store, pipeline, outputs = fresh_env + with TestClient(create_test_app(store, oidc=None)) as client: + yield SimpleNamespace(store=store, pipeline=pipeline, + outputs=outputs, client=client) + + +def _observation(*, actor=demo.FARMER, confirmation=True, reviewer=None): + submission = { + "commitClass": "OBSERVATION_ASSERTION", "actingPartyRef": actor, + "farmRef": demo.FARM, "subjectType": "FIELD", "subjectRef": demo.FIELD, + "idempotencyKey": _id("observation"), "eventTime": "2026-06-10T09:00:00Z", + "evidenceRefs": [demo.PHOTO_EVIDENCE], + "requestedPromotionTarget": "ACCEPTED_OBSERVATION_OCCURRENCE_STATE", + } + if confirmation is not OMITTED: + submission["confirmAccept"] = confirmation + if reviewer is not OMITTED: + submission["reviewerPartyRef"] = reviewer + return submission + + +def _truth(store): + return {row["record_id"]: row + for kind in (REVIEW_KIND, CONSEQUENCE_KIND) + for row in store.find_by_kind(kind)} + + +def _no_acceptance(store, result, before): + assert not result.get("emittedReviewDecisionRefs"), result + assert not result.get("emittedAcceptedConsequenceRefs"), result + assert _truth(store) == before + + +def _diagnostic(result): + return [(p["reasonCode"], p["title"]) for p in result["problems"]] + + +def _pending(env, result, truth, *, confirmed=True, actor=demo.FARMER): + assert result["decisionOutcome"] == "RETAIN_DRAFT", result + assert _diagnostic(result) == ([DISABLED] if confirmed else []) + _no_acceptance(env.store, result, truth) + assert len(result["emittedAssertionRecordRefs"]) == 1 + target = result["emittedAssertionRecordRefs"][0] + row = env.store.get_record(target) + assert row["payload"]["assertionType"] == "OBSERVATION_ASSERTION" + assert row["payload"]["claimState"] == "PENDING_REVIEW" + assert row["payload"]["assertedByPartyRef"] == actor + assert row["tenant_ref"] == env.store.tenant_ref + assert row["runtime_bundle_digest"] == env.store.runtime_bundle_digest + assert env.store.edges_from(target, "REVIEW") == [] + assert [edge["src_record_id"] for edge in + env.store.edges_to(target, "PROMOTION_EMITS")] == [result["promotionTraceRef"]] + trace = env.store.get_payload(result["promotionTraceRef"]) + assert any(g["gate"] == "VALIDATION" and g["outcome"] == "PASS" + for g in trace["gateSequence"]) + assert trace["gateSequence"][-1]["gate"] == "REVIEW_PROMOTION" + return target + + +def _review(env, target, *, actor=demo.ADVISOR, kind="accept", **extra): + response = env.client.post(f"/review/{kind}", json={ + "farmRef": demo.FARM, "assertionRef": target, + "rationale": "Fictional reviewer checked the observation and evidence", + "idempotencyKey": _id(kind), **extra, + }, headers={"x-acting-party": actor}) + assert response.status_code == 200, response.text + return response.json() + + +@pytest.mark.parametrize("confirmation", [ + pytest.param(True, id="true"), pytest.param(OMITTED, id="omitted"), + pytest.param(False, id="false"), +]) +@pytest.mark.parametrize("reviewer", [ + pytest.param(OMITTED, id="reviewer-omitted"), pytest.param(None, id="reviewer-null"), + pytest.param(demo.FARMER, id="reviewer-self"), + pytest.param(demo.ADVISOR, id="reviewer-distinct"), +]) +def test_observation_capture_matrix_http_and_direct(env, confirmation, reviewer): + """O01/O02: both public entries retain capture and exact raw identity.""" + for commit in (lambda sub: _commit(env, sub), env.pipeline.commit): + submission = _observation(confirmation=confirmation, reviewer=reviewer) + original = deepcopy(submission) + before, truth = _snapshot(env.store), _truth(env.store) + result = commit(submission) + target = _pending(env, result, truth, confirmed=confirmation is True) + assert env.store.edges_from(target, "LINEAGE_SUPERSEDES_INTENT") == [] + assert submission == original + assert env.store.get_payload(result["requestId"])["sourcePayloadDigest"] == \ + sha256_of(original) + after = _snapshot(env.store) + logs = [row for row in after["kernel_gate_log"] + if row["request_id"] == result["requestId"] + and row["gate"] == "REVIEW_PROMOTION"] + reason = DISABLED[0] if confirmation is True else None + assert [(row["outcome"], row["reason_code"]) for row in logs] == \ + [("RETAIN_DRAFT", reason)] + _assert_prior_records_unchanged(before, after) + assert env.store.unreachable_authoritative_records() == [] + + +def test_same_observation_survives_acceptance_refusals_then_terminal_rejection(env): + """O03: the refusal does not consume the claim; lawful reject still does.""" + # Grant reject explicitly so self-rejection reaches the relationship check. + _grant(env.store, demo.FARMER, ("REVIEW_REJECT_OR_CONTEST",)) + before, truth = _snapshot(env.store), _truth(env.store) + captured = _commit(env, _observation()) + target = _pending(env, captured, truth) + target_row = env.store.get_record(target) + for actor, diagnostic in ((demo.FARMER, ("HUMAN_APPROVAL_REQUIRED", "Self-review out of scope")), + (demo.ADVISOR, DISABLED)): + refused = _review(env, target, actor=actor, + actingPartyRef=demo.FARMER, assertionType="OPERATION_CLAIM_ASSERTION") + assert refused["decisionOutcome"] == "RETAIN_DRAFT", refused + assert _diagnostic(refused) == [diagnostic] + assert not refused.get("emittedAssertionRecordRefs") + _no_acceptance(env.store, refused, truth) + assert env.store.get_record(target) == target_row + assert env.store.edges_from(target, "REVIEW") == [] + assert target in {p["assertionRef"] for p in env.outputs._pending_claims(demo.FARM)} + + # Direct callers can supply extra raw fields that the HTTP body model + # discards. Neither those fields nor reviewer hints replace the stored type. + forged = dict(_acceptance_submission(target, demo.ADVISOR), + assertionType="OPERATION_CLAIM_ASSERTION", reviewerPartyRef=demo.FARMER) + direct_refusal = env.pipeline.commit(forged) + assert _diagnostic(direct_refusal) == [DISABLED] + _no_acceptance(env.store, direct_refusal, truth) + assert env.store.get_payload(direct_refusal["requestId"])["sourcePayloadDigest"] == sha256_of(forged) + assert env.store.edges_from(target, "REVIEW") == [] + + self_rejection = _review(env, target, actor=demo.FARMER, kind="reject") + assert _diagnostic(self_rejection) == [("HUMAN_APPROVAL_REQUIRED", "Self-review out of scope")] + _no_acceptance(env.store, self_rejection, truth) + rejected = _review(env, target, kind="reject", evidenceRefs=[demo.PHOTO_EVIDENCE]) + assert rejected["decisionOutcome"] == "RETAIN_DRAFT", rejected + assert rejected["problems"] == [] + assert not rejected.get("emittedAcceptedConsequenceRefs") + assert len(rejected["emittedReviewDecisionRefs"]) == 1 + review_ref = rejected["emittedReviewDecisionRefs"][0] + review = env.store.get_payload(review_ref) + assert review["decisionOutcomeState"] == "REJECTED" + assert review["reviewAction"] == "REVIEW_REJECT_OR_CONTEST" + assert review["decidedByPartyRef"] == demo.ADVISOR + assert review["reviewedArtifactRef"] == target + assert env.store.get_record(target) == target_row + assert [edge["dst_record_id"] for edge in env.store.edges_from(target, "REVIEW")] == [review_ref] + assert target not in {p["assertionRef"] for p in env.outputs._pending_claims(demo.FARM)} + after_rejection = _truth(env.store) + assert set(after_rejection) - set(truth) == {review_ref} + for kind in ("accept", "reject"): + duplicate = _review(env, target, kind=kind) + assert _diagnostic(duplicate) == [("SUPERSEDED_RECORD_USED", "Target already reviewed")] + _no_acceptance(env.store, duplicate, after_rejection) + _assert_prior_records_unchanged(before, _snapshot(env.store)) + assert env.store.unreachable_authoritative_records() == [] + + +def test_observation_capture_needs_creation_grant_but_accept_grant_cannot_enable_it(env): + """O04: creation-only authority captures; adding review rights never accepts.""" + actor = _actor(env.store, ("OBSERVE_CREATE_OBSERVATION",)) + for confirmation in (False, True): + truth = _truth(env.store) + result = _commit(env, _observation(actor=actor, confirmation=confirmation)) + _pending(env, result, truth, confirmed=confirmation, actor=actor) + _grant(env.store, actor, ("REVIEW_ACCEPT", "REVIEW_SUPERSEDE")) + truth = _truth(env.store) + result = _commit(env, _observation(actor=actor)) + _pending(env, result, truth, actor=actor) + + +@pytest.mark.parametrize("condition, reason, gate", [ + ("no-authority", "AUTHORITY_DENIED", "AUTHORITY"), + ("no-durable-evidence", "EVIDENCE_INSUFFICIENT", "EVIDENCE_SUFFICIENCY"), + ("wrong-target", "HIGH_CONSEQUENCE_BLOCKED", "VALIDATION"), + ("wrong-subject-type", "IDENTITY_UNRESOLVED", "VALIDATION"), + ("wrong-farm-subject", "SCOPE_NOT_AUTHORIZED", "VALIDATION"), +]) +def test_observation_keeps_earlier_direct_refusals(env, condition, reason, gate): + """O04: compatible target maps and earlier capture checks remain active.""" + submission = _observation() + if condition == "no-authority": + submission["actingPartyRef"] = _actor(env.store, ("REVIEW_ACCEPT",)) + elif condition == "no-durable-evidence": + submission["evidenceRefs"] = [demo.FARMER] + elif condition == "wrong-target": + submission["requestedPromotionTarget"] = "COMPLIANCE_FACT" + elif condition == "wrong-subject-type": + submission["subjectType"] = "TENANT" + else: + submission.update(subjectType="FARM", subjectRef="farm:fictional.other") + truth = _truth(env.store) + result = _commit(env, submission) + assert result["decisionOutcome"] != "PROMOTE_ACCEPTED", result + assert result["problems"][0]["reasonCode"] == reason + assert DISABLED not in _diagnostic(result) + assert env.store.get_payload(result["promotionTraceRef"])["gateSequence"][-1]["gate"] == gate + _no_acceptance(env.store, result, truth) + + +@pytest.mark.parametrize("condition, reason", [ + ("no-authority", "AUTHORITY_DENIED"), + ("wrong-target", "EVIDENCE_REFERENCE_UNAVAILABLE"), + ("no-rationale", "EVIDENCE_INSUFFICIENT"), + ("wrong-evidence", "EVIDENCE_REFERENCE_UNAVAILABLE"), +]) +def test_observation_keeps_earlier_queue_refusals(env, condition, reason): + truth = _truth(env.store) + captured = _commit(env, _observation(confirmation=False)) + target = _pending(env, captured, truth, confirmed=False) + actor, supplied_target, extra = demo.ADVISOR, target, {} + if condition == "no-authority": + actor = _actor(env.store, ("OBSERVE_CREATE_OBSERVATION",)) + elif condition == "wrong-target": + supplied_target = demo.FARMER + elif condition == "no-rationale": + extra["rationale"] = " " + else: + extra["evidenceRefs"] = [demo.FARMER] + result = _review(env, supplied_target, actor=actor, **extra) + assert result["problems"][0]["reasonCode"] == reason + assert DISABLED not in _diagnostic(result) + _no_acceptance(env.store, result, truth) + assert env.store.edges_from(target, "REVIEW") == [] + + +def test_observation_cross_bundle_acceptance_keeps_prior_refusal(env): + truth = _truth(env.store) + captured = _commit(env, _observation(confirmation=False)) + target = _pending(env, captured, truth, confirmed=False) + other = _second_store(env.store) + try: + assert other.runtime_bundle_digest != env.store.runtime_bundle_digest + result = GatePipeline(other).commit(_acceptance_submission(target, demo.ADVISOR)) + assert _diagnostic(result) == [("PACK_CONFLICT", "Cross-bundle acceptance refused")] + _no_acceptance(other, result, truth) + assert other.edges_from(target, "REVIEW") == [] + finally: + other.close() + + +@pytest.mark.parametrize("confirmation", [None, "true", 1, []]) +def test_observation_transport_refusals_precede_all_durable_effects(env, confirmation): + """O02: actual observation bodies preserve 403 before malformed 422.""" + submission = _observation(confirmation=confirmation) + before = _snapshot(env.store) + mismatch = _post(env, submission, actor=demo.WORKER) + assert mismatch.status_code == 403 + assert mismatch.json()["detail"]["reasonCode"] == "ACTOR_BINDING_UNRESOLVED" + assert _snapshot(env.store) == before + malformed = _post(env, submission) + assert malformed.status_code == 422 + assert malformed.json() == {"detail": "malformed ingress submission header"} + assert _snapshot(env.store) == before + + +def test_observation_late_failure_rolls_back_capture_and_retry_stays_inert(env, monkeypatch): + """O07: fail after the real final write; no partial capture or key survives.""" + submission = _observation() + before, truth = _snapshot(env.store), _truth(env.store) + prior_assertions = env.store.find_by_kind("ofarm.assertionrecord.v0.1") + original_claim = env.store.idempotency_claim + + def fail_after_claim(*args, **kwargs): + original_claim(*args, **kwargs) + assert len(env.store.find_by_kind("ofarm.assertionrecord.v0.1")) == len(prior_assertions) + 1 + raise RuntimeError("fictional observation failure after idempotency write") + + with monkeypatch.context() as patch: + patch.setattr(env.store, "idempotency_claim", fail_after_claim) + with pytest.raises(RuntimeError, match="fictional observation failure"): + env.pipeline.commit(submission) + assert _snapshot(env.store) == before + captured = _commit(env, submission) + _pending(env, captured, truth) + _assert_valid_replay(env, submission, captured) + changed = dict(submission, confirmAccept=False) + conflicting = _commit(env, changed) + assert _diagnostic(conflicting)[0][0] == "IDEMPOTENCY_REPLAY_CONFLICT" + _no_acceptance(env.store, conflicting, truth) + assert env.store.unreachable_authoritative_records() == [] + + +def test_real_base_history_replays_but_fresh_observation_acceptance_stays_disabled( + observation_history): # noqa: F811 — imported pytest fixture + """O01/O05/O06: one retained database crosses unmodified base to candidate.""" + env = observation_history("replay") + history = env.history + assert history["source"]["commit"] == BASE_COMMIT + assert history["source"]["tree"] == BASE_TREE + assert "kernel/stages.py" in history["source"]["importedSources"] + truth = _truth(env.store) + _assert_prior_records_unchanged(history["snapshot"], _snapshot(env.store)) + old = history["predecessor"] + predecessor = env.store.get_record(old) + + # Reuse exactly the stable original normalized bodies. In particular, a + # repeated /review/accept would generate a NEW decisionTime and not replay. + assert set(history["direct_acceptances"]) == {"omitted", "null", "self"} + for fixture in (*history["direct_acceptances"].values(), history["queued_acceptance"]): + original, submission = fixture["result"], fixture["submission"] + assert original["decisionOutcome"] == "PROMOTE_ACCEPTED" + assert len(original["emittedReviewDecisionRefs"]) == 1 + assert len(original["emittedAcceptedConsequenceRefs"]) == 1 + replay = _assert_valid_replay(env, submission, original) + assert env.store.get_payload(replay["requestId"])["sourcePayloadDigest"] == \ + sha256_of(submission) + assert _truth(env.store) == truth + + for fixture in history["direct_acceptances"].values(): + fresh = dict(fixture["submission"], idempotencyKey=_id("fresh")) + captured = _commit(env, fresh) + _pending(env, captured, truth, actor=env.reviewer) + terminal = dict(history["queued_acceptance"]["submission"], idempotencyKey=_id("fresh")) + refused = _commit(env, terminal) + assert _diagnostic(refused) == [("SUPERSEDED_RECORD_USED", "Target already reviewed")] + _no_acceptance(env.store, refused, truth) + + for name in ("pending_observation", "pending_correction"): + target = history[name] + old_row = env.store.get_record(target) + assert old_row["payload"]["claimState"] == "PENDING_REVIEW" + blocked = _review(env, target, actor=env.reviewer) + assert blocked["decisionOutcome"] == "RETAIN_DRAFT", blocked + assert _diagnostic(blocked) == [DISABLED] + assert not blocked.get("emittedAssertionRecordRefs") + _no_acceptance(env.store, blocked, truth) + assert env.store.get_record(target) == old_row + assert env.store.edges_from(target, "REVIEW") == [] + assert [edge["dst_record_id"] for edge in env.store.edges_from( + history["pending_correction"], "LINEAGE_SUPERSEDES_INTENT")] == [old] + assert not env.store.is_superseded(old) + + # Existing accepted observations remain readable and contestable. Contest + # appends a dispute; the old consequence bytes and acceptance stay in force. + read = env.client.get(f"/records/{old}", headers={"x-acting-party": demo.FARMER}) + assert read.status_code == 200, read.text + contest = env.client.post("/review/contest", json={ + "farmRef": demo.FARM, "consequenceRef": old, + "rationale": "Fictional historical observation remains disputed", + "idempotencyKey": _id("contest"), + }, headers={"x-acting-party": env.reviewer}) + assert contest.status_code == 200, contest.text + disputed = contest.json() + assert disputed["decisionOutcome"] == "RETAIN_DRAFT", disputed + assert not disputed.get("emittedAcceptedConsequenceRefs") + assert len(disputed["emittedReviewDecisionRefs"]) == 1 + review_ref = disputed["emittedReviewDecisionRefs"][0] + assert env.store.get_payload(review_ref)["decisionOutcomeState"] == "CONTESTED" + assert [edge["dst_record_id"] for edge in env.store.edges_from(old, "DISPUTE")] == [review_ref] + assert env.store.get_record(old) == predecessor + + # Even the fully authorized reviewer cannot create a correction successor + # to remove that dispute. Only the validated inert intent is retained. + truth = _truth(env.store) + correction = dict(history["correction"], idempotencyKey=_id("correction"), + actingPartyRef=env.reviewer, confirmAccept=True) + corrected = _commit(env, correction) + target = _pending(env, corrected, truth, actor=env.reviewer) + assert [edge["dst_record_id"] for edge in env.store.edges_from( + target, "LINEAGE_SUPERSEDES_INTENT")] == [old] + assert env.store.edges_to(old, "LINEAGE_SUPERSEDES") == [] + assert not env.store.is_superseded(old) + assert env.store.get_record(old) == predecessor + assert env.store.get_payload(old)["inForceState"] == "IN_FORCE" + _assert_prior_records_unchanged(history["snapshot"], _snapshot(env.store)) + assert env.store.unreachable_authoritative_records() == [] diff --git a/kernel/tests/test_review_fixes.py b/kernel/tests/test_review_fixes.py index be89823f..d2937ef0 100644 --- a/kernel/tests/test_review_fixes.py +++ b/kernel/tests/test_review_fixes.py @@ -49,7 +49,7 @@ def test_h2_bogus_ucum_dose_unit_blocks_promotion(pipeline): # --------------------------------------------------------------------------- def test_h3_promoting_observation_without_evidence_stays_draft(pipeline): - # OBSERVATION_ASSERTION promotes; with no evidence it must RETAIN_DRAFT + # Observation capture still requires evidence; with none it must RETAIN_DRAFT # rather than backfill evidenceRefs with the captured event id. r = pipeline.commit({ "commitClass": "OBSERVATION_ASSERTION", @@ -87,9 +87,9 @@ def test_h3_promoting_observation_with_unresolvable_evidence_stays_draft( assert not r.get("emittedAcceptedConsequenceRefs"), why -def test_h3_promoting_observation_with_evidence_promotes(pipeline): - # positive control: the same observation WITH a real, resolving durable - # EvidenceRecord promotes (evidenceRefs are the submitted evidence). +def test_h3_observation_with_durable_evidence_is_captured(pipeline): + # A real EvidenceRecord passes the evidence floor, allowing capture. New + # observation acceptance remains disabled pending typed semantics. r = pipeline.commit({ "commitClass": "OBSERVATION_ASSERTION", "actingPartyRef": demo.FARMER, "farmRef": demo.FARM, @@ -97,7 +97,12 @@ def test_h3_promoting_observation_with_evidence_promotes(pipeline): "eventTime": "2026-06-10T09:00:00Z", "evidenceRefs": [demo.PHOTO_EVIDENCE], "confirmAccept": True}) - assert r["decisionOutcome"] == "PROMOTE_ACCEPTED" + assert r["decisionOutcome"] == "RETAIN_DRAFT" + assert len(r["emittedAssertionRecordRefs"]) == 1 + assert not r.get("emittedReviewDecisionRefs") + assert not r.get("emittedAcceptedConsequenceRefs") + assert [(p["reasonCode"], p["title"]) for p in r["problems"]] == [ + ("HIGH_CONSEQUENCE_BLOCKED", "Observation acceptance disabled")] # --------------------------------------------------------------------------- From 524c31d295094c54f648e6d72941e6ff3709c9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samo=20A=C4=8Dko?= Date: Mon, 14 Sep 2026 11:32:16 +0200 Subject: [PATCH 4/4] Preserve temporal warnings on retained observation captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry earlier validator findings into the direct observation result and promotion log while preserving the approved RETAIN_DRAFT outcome and closed acceptance path. Expand the HTTP/direct capture matrix across timestamp plausibility, clarify diagnostic behavior, and regenerate the pinned inventory. Addresses PR #388 implementation review B1 for Delivery #387; O01–O07 and the observation eligibility boundary remain unchanged. --- .../review_baseline_test_inventory.json | 88 ++++++++++++++++--- docs/REVIEW_DISPUTE_SEMANTICS.md | 3 + ...Legacy_Observation_Eligibility_RFC_v0_1.md | 6 ++ kernel/stages.py | 2 + kernel/tests/test_observation_eligibility.py | 44 ++++++++-- 5 files changed, 121 insertions(+), 22 deletions(-) diff --git a/conformance/review_baseline_test_inventory.json b/conformance/review_baseline_test_inventory.json index f6027148..bcab3d94 100644 --- a/conformance/review_baseline_test_inventory.json +++ b/conformance/review_baseline_test_inventory.json @@ -3466,62 +3466,122 @@ "sourcePath": "kernel/tests/test_migration_sets.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-false]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-distinct-false]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-omitted]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-distinct-omitted]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-distinct-true]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-distinct-true]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-false]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-null-false]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-omitted]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-null-omitted]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-null-true]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-null-true]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-false]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-omitted-false]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-omitted]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-omitted-omitted]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-omitted-true]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-omitted-true]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-false]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-self-false]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-omitted]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-self-omitted]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, { - "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[reviewer-self-true]", + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[in-window-reviewer-self-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-distinct-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-distinct-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-distinct-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-null-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-null-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-null-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-omitted-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-omitted-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-omitted-true]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-self-false]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-self-omitted]", + "sourceModule": "kernel.tests.test_observation_eligibility", + "sourcePath": "kernel/tests/test_observation_eligibility.py" + }, + { + "nodeid": "kernel/tests/test_observation_eligibility.py::test_observation_capture_matrix_http_and_direct[out-of-window-reviewer-self-true]", "sourceModule": "kernel.tests.test_observation_eligibility", "sourcePath": "kernel/tests/test_observation_eligibility.py" }, @@ -22566,8 +22626,8 @@ "sourcePath": "kernel/tests/test_tenant_uow_observation.py" } ], - "entriesSha256": "b0d3a235ac23c01dc9e82c3ce1e2bfd5cce9b3952854e68fd426a033f65b8370", - "entryCount": 4513, + "entriesSha256": "63b4151b7187ccbae704dbfc36843812527650a69868874354a04d0b7c47a363", + "entryCount": 4525, "schemaVersion": "ofarm.review-baseline-test-inventory.v1", "testRoot": "kernel/tests" } diff --git a/docs/REVIEW_DISPUTE_SEMANTICS.md b/docs/REVIEW_DISPUTE_SEMANTICS.md index 2024a064..9351def2 100644 --- a/docs/REVIEW_DISPUTE_SEMANTICS.md +++ b/docs/REVIEW_DISPUTE_SEMANTICS.md @@ -149,6 +149,9 @@ with immutable `claimState: PENDING_REVIEW` and returns `RETAIN_DRAFT`. Omitted or false confirmation keeps ordinary capture diagnostics. Literal true, and a queued ACCEPT that reaches the common promotion guard, report `HIGH_CONSEQUENCE_BLOCKED` with title `Observation acceptance disabled`. +A direct capture outside the event-time plausibility window retains its earlier +`EVIDENCE_INSUFFICIENT` warning in the stored result and promotion gate reason, +before any acceptance-disabled diagnostic, while still returning `RETAIN_DRAFT`. Neither path emits a new accepted ReviewDecision, consequence or retirement. Queue acceptance leaves its target unconsumed; self-acceptance can still refuse earlier under D8. Pending visibility does not promise acceptance availability. diff --git a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md index 79da64a1..fdb058d6 100644 --- a/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md +++ b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md @@ -122,6 +122,12 @@ Observation acceptance disabled)` on the new direct/queued eligibility result; earlier refusals keep their existing code/title. This pins an implementation diagnostic without changing O02 or introducing a new reason-code enum. +A direct observation outside the event-time plausibility window still returns +`RETAIN_DRAFT`, preserving its earlier `EVIDENCE_INSUFFICIENT` / `Event time +outside plausibility window` warning in result problems and as the promotion +gate's reason code, before the eligibility diagnostic when confirmation is true. +This preserves O02's ordinary diagnostics without promising available acceptance. + Existing earlier refusals retain their precedence. In particular, the asserter's queued self-acceptance still fails the existing D8 validator with `HUMAN_APPROVAL_REQUIRED`; a distinct reviewer passes that relationship check diff --git a/kernel/stages.py b/kernel/stages.py index 272a7111..f6e55304 100644 --- a/kernel/stages.py +++ b/kernel/stages.py @@ -638,6 +638,8 @@ def run(self, ctx: GateContext) -> GatePass | GateRefusal: "observation acceptance requires typed semantics that are not " "available; a distinct reviewer cannot enable acceptance", severity="WARNING")] if confirmed or ctx.acceptance_target else [] + if not ctx.acceptance_target: + problems = ctx.review_route_reasons + problems ctx.log("REVIEW_PROMOTION", "RETAIN_DRAFT", reason_code=problems[0]["reasonCode"] if problems else None, rationale=problems[0]["detail"] if problems else diff --git a/kernel/tests/test_observation_eligibility.py b/kernel/tests/test_observation_eligibility.py index 42890fc3..e4925811 100644 --- a/kernel/tests/test_observation_eligibility.py +++ b/kernel/tests/test_observation_eligibility.py @@ -14,6 +14,7 @@ from fastapi.testclient import TestClient from kernel import demo +from kernel.context import now_iso from kernel.contracts import sha256_of from kernel.gates import GatePipeline from kernel.legacy_m1.api import create_test_app @@ -41,6 +42,7 @@ DISABLED = ("HIGH_CONSEQUENCE_BLOCKED", "Observation acceptance disabled") +TEMPORAL = ("EVIDENCE_INSUFFICIENT", "Event time outside plausibility window") def _id(prefix): @@ -86,9 +88,11 @@ def _diagnostic(result): return [(p["reasonCode"], p["title"]) for p in result["problems"]] -def _pending(env, result, truth, *, confirmed=True, actor=demo.FARMER): +def _pending(env, result, truth, *, confirmed=True, actor=demo.FARMER, + temporal_warning=False): assert result["decisionOutcome"] == "RETAIN_DRAFT", result - assert _diagnostic(result) == ([DISABLED] if confirmed else []) + assert _diagnostic(result) == (([TEMPORAL] if temporal_warning else []) + + ([DISABLED] if confirmed else [])) _no_acceptance(env.store, result, truth) assert len(result["emittedAssertionRecordRefs"]) == 1 target = result["emittedAssertionRecordRefs"][0] @@ -127,25 +131,49 @@ def _review(env, target, *, actor=demo.ADVISOR, kind="accept", **extra): pytest.param(demo.FARMER, id="reviewer-self"), pytest.param(demo.ADVISOR, id="reviewer-distinct"), ]) -def test_observation_capture_matrix_http_and_direct(env, confirmation, reviewer): - """O01/O02: both public entries retain capture and exact raw identity.""" +@pytest.mark.parametrize("out_of_window", [ + pytest.param(False, id="in-window"), pytest.param(True, id="out-of-window"), +]) +def test_observation_capture_matrix_http_and_direct( + env, confirmation, reviewer, out_of_window): + """O01/O02: preserve temporal diagnostics, capture and exact raw identity.""" for commit in (lambda sub: _commit(env, sub), env.pipeline.commit): submission = _observation(confirmation=confirmation, reviewer=reviewer) + submission["eventTime"] = "2999-01-01T00:00:00Z" if out_of_window else now_iso() original = deepcopy(submission) before, truth = _snapshot(env.store), _truth(env.store) result = commit(submission) - target = _pending(env, result, truth, confirmed=confirmation is True) + target = _pending(env, result, truth, confirmed=confirmation is True, + temporal_warning=out_of_window) + assert env.store.get_payload(result["resultId"]) == result + assert all(problem["severity"] == "WARNING" for problem in result["problems"]) assert env.store.edges_from(target, "LINEAGE_SUPERSEDES_INTENT") == [] assert submission == original assert env.store.get_payload(result["requestId"])["sourcePayloadDigest"] == \ sha256_of(original) after = _snapshot(env.store) + prior_ids = {row["record_id"] for row in before["kernel_record"]} + assert [row["record_id"] for row in after["kernel_record"] + if row["record_kind"] == "ofarm.assertionrecord.v0.1" + and row["record_id"] not in prior_ids] == [target] + assert [row for row in after["kernel_edge"] + if row["edge_type"] == "LINEAGE_SUPERSEDES"] == [ + row for row in before["kernel_edge"] + if row["edge_type"] == "LINEAGE_SUPERSEDES"] logs = [row for row in after["kernel_gate_log"] if row["request_id"] == result["requestId"] and row["gate"] == "REVIEW_PROMOTION"] - reason = DISABLED[0] if confirmation is True else None - assert [(row["outcome"], row["reason_code"]) for row in logs] == \ - [("RETAIN_DRAFT", reason)] + first_problem = result["problems"][0] if result["problems"] else None + reason = first_problem["reasonCode"] if first_problem else None + rationale = first_problem["detail"] if first_problem else \ + "no review act: capture is not commitment (Kernel rule 3)" + assert [(row["outcome"], row["reason_code"], row["rationale"]) for row in logs] == \ + [("RETAIN_DRAFT", reason, rationale)] + trace = env.store.get_payload(result["promotionTraceRef"]) + assert trace["finalOutcome"] == "RETAIN_DRAFT" + assert trace["gateSequence"][-1] == { + "gate": "REVIEW_PROMOTION", "outcome": "RETAIN_DRAFT", "rationale": rationale, + } _assert_prior_records_unchanged(before, after) assert env.store.unreachable_authoritative_records() == []