diff --git a/conformance/review_baseline_test_inventory.json b/conformance/review_baseline_test_inventory.json index 81872502..bcab3d94 100644 --- a/conformance/review_baseline_test_inventory.json +++ b/conformance/review_baseline_test_inventory.json @@ -3465,6 +3465,216 @@ "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[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[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[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[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[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[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[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[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[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[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[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[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" + }, + { + "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 +12106,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 +22626,8 @@ "sourcePath": "kernel/tests/test_tenant_uow_observation.py" } ], - "entriesSha256": "737e688267c914da5d829097004d7ea9f94726f80e0d02b9136478574573b384", - "entryCount": 4483, + "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 29159401..9351def2 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,49 @@ 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`. +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. +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 +726,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 new file mode 100644 index 00000000..fdb058d6 --- /dev/null +++ b/docs/rfcs/OFARM_Legacy_Observation_Eligibility_RFC_v0_1.md @@ -0,0 +1,306 @@ +# Legacy observation acceptance eligibility + +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; +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`. +PRs #380, #382, #384 and #386 remain completed historical work. + +## Problem, selected outcome and exact boundary + +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), +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 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 +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. +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 this restriction required its own semantic approval. + +## 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 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 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 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. + +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 +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. +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 +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. + +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 +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 part of the approved 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 + +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. + +| 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, +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 +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. 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 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 + 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. + +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: 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..f6e55304 100644 --- a/kernel/stages.py +++ b/kernel/stages.py @@ -624,6 +624,34 @@ 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 [] + 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 + "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..e4925811 --- /dev/null +++ b/kernel/tests/test_observation_eligibility.py @@ -0,0 +1,446 @@ +"""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.context import now_iso +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") +TEMPORAL = ("EVIDENCE_INSUFFICIENT", "Event time outside plausibility window") + + +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, + temporal_warning=False): + assert result["decisionOutcome"] == "RETAIN_DRAFT", result + 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] + 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"), +]) +@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, + 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"] + 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() == [] + + +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")] # ---------------------------------------------------------------------------