diff --git a/CLAUDE.md b/CLAUDE.md index f339114..f172c67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ reviewed increments** — hold at each increment before the next. terminal outcome (only signaled as closed when `audit_persisted == TRUE` with a valid broker cid, `.valid_broker_cid`); `check_failed` fails closed with nothing recorded. -- **Increment 5a (this): pkgstate verification predicates** (`R/verify.R`) — +- **Increment 5a (merged): pkgstate verification predicates** (`R/verify.R`) — `.verify(preview, reader)` checks every **resolved record** of a committed preview against native ground truth, per verb (§6.3): transaction verbs by each record's `action` (install/upgrade/downgrade → installed at `to_version`; remove @@ -53,19 +53,27 @@ reviewed increments** — hold at each increment before the next. surviving `config-files` is a *failed* purge) via `dpkg_installed()`; configure → fully `installed`; hold/unhold → the `dpkg_selections()` want reads back; update → `NA`. **Independent of the helper's status** (reads only the plan + ground - truth); returns `(verified TRUE/FALSE/NA, detail)`. pkgstate reads go through an - injectable reader seam (`pkgstate_reader()`/`set_pkgstate_reader`, - hermetic-test-only). **`pkgstate` is now an `Imports`** (the default reader uses - it). Record grammar/post-state pinned to pkgexec 0.0.3 + pkgstate 0.0.1.9. -- **Still NOT started** (later increments, each its own review): **5b — wire - `.verify()` into `.commit_session` step 6** (for a success status, verify and - **capture** the verdict into the outcome's `verified`/`verify_detail` — never - raise; a verification failure still writes the outcome), and then the **exported + truth); returns `(verified TRUE/FALSE/NA, detail)`. `architecture` is required by + the txn/configure grammar (a missing arch fails, never wildcard-matches). pkgstate + reads go through an injectable reader seam + (`pkgstate_reader()`/`set_pkgstate_reader`, hermetic-test-only). **`pkgstate` is + now an `Imports`** (the default reader uses it). Record grammar/post-state pinned + to pkgexec 0.0.3 + pkgstate 0.0.1.9. +- **Increment 5b (this): wire verification into `.commit_session` step 6** — + after commit + classify, on the **success path only** (`is.null(condition)`: an + `ok`/`no_op` that will be returned), `.verify_and_capture()` runs `.verify()` and + captures `verified`/`verify_detail` onto the returned outcome. **Observational**: + never raises, never changes close/open; a disagreeing post-state is + `verified = FALSE` + a detail, not a signal, so the outcome is still written + (step 7) and outcome-before-signal holds. A known failure / left-open effect is + not verified. The reader stays behind the seam, so `.commit_session` is fully + hermetic (fake session-ops + fake pkcheck + fake reader). +- **Still NOT started** (later increments, each its own review): the **exported per-verb `apt_()` API** (with the preview `{verb,resource,plan_hash}` match check). The durable outcome-record grammar (incl. the `observed`/`changed` - post-state fields verification now produces), the plain-intent refusal record - grammar, and the exact pkcheck rc→outcome split are pinned against a real - broker/polkit in the VM-gated increment. + post-state fields the verdict feeds), the plain-intent refusal record grammar, + and the exact pkcheck rc→outcome split are pinned against a real broker/polkit in + the VM-gated increment. The authoritative design is `runix/docs/pkgops-plan.md` (the approved contract) and `runix/docs/pkgops-implementation-plan.md` (rev 2, the build sequence). diff --git a/DESCRIPTION b/DESCRIPTION index 4de9029..851e757 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: pkgops Type: Package Title: Unprivileged Issuer for 'APT' Package-State Mutations -Version: 0.0.1.6 +Version: 0.0.1.7 Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index f19afe0..7697c0e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,30 @@ +# pkgops 0.0.1.7 + +Commit lifecycle (slice 3b): **wire verification into `.commit_session`** (§4.3 +step 6). The predicates from the previous increment now run inside the commit +lifecycle, capturing the verdict onto the outcome. Still hermetic (the broker, +`pkexec`/`pkcheck`, and the dpkg reader are all behind seams) and still internal +-- the exported per-verb API is the last increment. + +* Step 6 runs after the commit + classify, on the **success path only** (an + `ok`/`no_op` that will be returned, not signaled): it cross-checks the + committed preview's resolved records against native ground truth via `.verify()` + and captures `verified` / `verify_detail` onto the returned `pkgops_outcome`. +* Verification is **observational**: it never raises and never changes the + close/open decision. A disagreeing post-state is `verified = FALSE` plus a + detail on the outcome, **not** a signal, so the outcome is still written + (step 7) and the outcome-before-signal order (§4.8) holds. A known failure or a + left-open effect-unknown is not verified -- neither has a trustworthy + post-state. +* Independence from the helper's self-report (§4.7) is now load-bearing in the + lifecycle: a clean `ok` whose post-state disagrees is a verification failure. +* The dpkg reader stays behind the injectable seam, so `.commit_session` remains + fully hermetic (fake session-ops + fake `pkcheck` + fake reader). The + durable-record post-state fields that carry the verdict to the broker remain the + VM-gated increment; here the verdict lands on the outcome object only. +* Still deferred: the exported per-verb `apt_()` commit entrypoint (with the + preview `{verb,resource,plan_hash}` match check). + # pkgops 0.0.1.6 Commit lifecycle (slice 3b): the **pkgstate verification predicates** (§4.7 / diff --git a/R/commit.R b/R/commit.R index 4a0f619..2fdca56 100644 --- a/R/commit.R +++ b/R/commit.R @@ -1,13 +1,14 @@ ## The commit-session orchestrator: the branched commit lifecycle of the contract ## (pkgops-plan.md 4), wiring runix's exported effect-session API to the pure -## classifier (R/classify.R) and the polkit decision (R/polkit.R). Steps wired: -## 1 capability, 2 authorization (the polkit branch + the plain-intent terminal -## refusal), 3-5 open/commit/classify, 7 write_outcome, 8 return/signal, with the -## outcome-closed-before-signal discipline (4.8). ONE step remains DEFERRED to its -## own later increment and is marked below: pkgstate verification (contract 4.7). -## Until it lands there is no exported per-verb apt_() commit entrypoint -- -## an issuer cannot verify truthfully yet, so the mutation-capable path stays -## internal (.commit_session) and hermetic (fake session-ops + fake pkcheck). +## classifier (R/classify.R), the polkit decision (R/polkit.R), and pkgstate +## verification (R/verify.R). Steps wired: 1 capability, 2 authorization (the +## polkit branch + the plain-intent terminal refusal), 3-5 open/commit/classify, +## 6 verification (capture the verdict on the outcome, never raise), 7 +## write_outcome, 8 return/signal, with the outcome-closed-before-signal +## discipline (4.8). The mutation-capable path stays internal (.commit_session) +## and hermetic (fake session-ops + fake pkcheck + fake pkgstate reader) until the +## exported per-verb apt_() commit entrypoint (its own later increment, +## which adds the preview {verb,resource,plan_hash} match check). ## The broker's well-known AF_UNIX socket (matches runix's effect_capability ## default). A caller may override for a test/staging broker. @@ -62,6 +63,30 @@ persist_status = wr$status, detail = detail)) } +## step 6 (contract 4.7): cross-check the committed preview's resolved records +## against native ground truth and CAPTURE the verdict into the outcome. This is +## OBSERVATIONAL -- it never changes the close/open decision and never raises: a +## disagreeing post-state is `verified = FALSE` + a detail on the outcome, not a +## signal. So the outcome is still written (step 7) and outcome-before-signal +## holds. .verify() reads only the plan (preview) and the ground truth, never the +## helper's self-report (4.7), and by the time this runs the commit has applied, +## so the reader observes the POST-state. +## +## .verify() already normalizes malformed reader/record data to verified = FALSE +## without raising; the extra tryCatch is belt-and-suspenders so a residual error +## can never abort before write_outcome. The durable-record post-state fields +## (observed/changed) that carry this verdict to the broker are the VM-gated +## increment's; here the verdict lands on the returned outcome object only. +.verify_and_capture <- function(outcome, preview) { + v <- tryCatch(.verify(preview), error = function(e) { + list(verified = FALSE, + detail = paste0("verification error: ", conditionMessage(e))) + }) + outcome$verified <- v$verified + outcome$verify_detail <- v$detail + outcome +} + ## Run the commit and classify it into list(outcome, condition, leave_open), ## NEVER letting an exception escape before the caller can attempt the outcome ## close (4.3 step 8). A commit that RAISES is effect-UNKNOWN at the R boundary -- @@ -234,9 +259,17 @@ decided <- .commit_and_classify(ops, session, preview, lock_timeout, deadline_ms) - ## step 6 -- pkgstate VERIFICATION: DEFERRED to its own increment. Until then - ## `verified` stays NA (new_pkgops_outcome's default); a clean helper status - ## is NOT yet cross-checked against the post-state. + ## step 6 -- pkgstate VERIFICATION (contract 4.7). Only on the success path + ## (is.null(condition): an ok/no_op that will be RETURNED, not signaled): + ## cross-check the committed preview against native ground truth and capture + ## the verdict onto the outcome. A known failure already carries its own + ## condition and a left-open effect is unknown, so neither has a trustworthy + ## post-state to check. This is observational -- never raises, never changes + ## close/open -- so the outcome is still written below and the + ## outcome-before-signal order holds. + if (is.null(decided$condition)) { + decided$outcome <- .verify_and_capture(decided$outcome, preview) + } ## step 7 -- close the durable intent, UNLESS the effect is genuinely unknown ## (then the intent is left open for reconciliation, 4.8). diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 13f352d..5b75020 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -9,13 +9,18 @@ CID <- "20250101000000000000-0123456789abcdef" commit_session <- pkgops:::.commit_session set_ops <- pkgops:::set_session_ops set_pkcheck <- pkgops:::set_pkcheck +set_reader <- pkgops:::set_pkgstate_reader -## A committable preview: an `ok` plan carrying a bound digest. +## A committable preview: an `ok` plan carrying a bound digest. `records` are the +## resolved records step 6 verifies; the default is empty (nothing to verify -> +## verified NA, and the pkgstate reader is never touched, so the effect-path tests +## stay hermetic without injecting one). mkprev <- function(verb = "apt.install", resource = "nginx", packages = "nginx", - plan_hash = H, plan_schema = 1L, advisory_verdict = "ok") { + plan_hash = H, plan_schema = 1L, advisory_verdict = "ok", + records = list()) { structure(list(schema_version = 1L, verb = verb, resource = resource, plan_schema = plan_schema, plan_hash = plan_hash, - autonomous = FALSE, packages = packages, records = list(), + autonomous = FALSE, packages = packages, records = records, advisory_verdict = advisory_verdict, advisory_detail = NA_character_), class = "pkgops_preview") } @@ -84,12 +89,18 @@ ops_for <- function(log, commit, raise = FALSE, ## raised condition (failure). Also installs a fake pkcheck (default: rc 0 -> ## authorized, so the effect-path tests reach step 3 hermetically); restores both. run_commit <- function(ops, preview = prev, interactive = FALSE, - pkcheck = function(action) 0L, ...) { + pkcheck = function(action) 0L, reader = NULL, ...) { old_ops <- set_ops(ops) old_pk <- set_pkcheck(pkcheck) + if (!is.null(reader)) { + old_rd <- set_reader(reader) + } on.exit({ set_ops(old_ops) set_pkcheck(old_pk) + if (!is.null(reader)) { + set_reader(old_rd) + } }) tryCatch(commit_session(preview, socket_path = "/fake.sock", interactive = interactive, ...), @@ -360,3 +371,108 @@ lg <- newlog() r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), pkcheck = autofn) # apt.install -> rc 1 expect_inherits(r, "runix_unauthorized") expect_equal(lg$seq, c("capability", "refuse")) + +## ============================================================================ +## step 6 -- pkgstate verification (contract 4.7). The verdict is CAPTURED onto +## the outcome, NEVER raised; the outcome is still written, in order; and +## verification runs ONLY on the success path (an ok/no_op that is returned). +## ============================================================================ + +## A committable install preview carrying one resolved txn record, and a fake +## reader whose installed() reports a canned post-state (and counts its calls, so +## a test can prove verification did or did not run). selections() is unused by +## the txn family but must be a valid frame. +irec <- list(package = "nginx", architecture = "amd64", action = "install", + from_version = "", to_version = "1.2", flags = list()) +prev1 <- mkprev(records = list(irec)) +empty_sel <- function(packages = NULL) { + data.frame(package = character(), architecture = character(), + selection = character(), stringsAsFactors = FALSE) +} +counting_reader <- function(status = "installed", version = "1.2") { + e <- new.env(parent = emptyenv()) + e$n <- 0L + e$reader <- list(installed = function() { + e$n <- e$n + 1L + data.frame(package = "nginx", version = version, architecture = "amd64", + status = status, stringsAsFactors = FALSE) + }, selections = empty_sel) + e +} + +## success + a MATCHING post-state -> verified TRUE, outcome returned + written, +## and verification ran exactly once (it read the post-state). +cr_ok <- counting_reader(status = "installed", version = "1.2") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = cr_ok$reader) +expect_inherits(r, "pkgops_outcome") +expect_identical(r$verified, TRUE) +expect_true(is.na(r$verify_detail)) +expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome")) +expect_equal(cr_ok$n, 1L) + +## success + a DISAGREEING post-state -> verified FALSE + a detail, but the +## outcome is STILL returned (not a raised condition) and STILL written: a failed +## verification never raises and never changes the close. +cr_bad <- counting_reader(status = "installed", version = "1.1") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = cr_bad$reader) +expect_inherits(r, "pkgops_outcome") +expect_false(inherits(r, "condition")) +expect_identical(r$verified, FALSE) +expect_true(grepl("version 1.1", r$verify_detail)) +expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome")) + +## a reader that EXPLODES is captured, never propagated -> verified FALSE, the +## outcome is still returned and written (the belt over .verify()). +boom_reader <- list(installed = function() stop("dpkg exploded"), + selections = empty_sel) +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = boom_reader) +expect_inherits(r, "pkgops_outcome") +expect_identical(r$verified, FALSE) +expect_true(grepl("verification error|absent|malformed", r$verify_detail)) +expect_true("write_outcome" %in% lg$seq) + +## no resolved records -> nothing to verify -> verified NA on a success (and the +## reader is never touched, so the default hermetic tests stay clean). +cr_untouched <- counting_reader() +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev, + reader = cr_untouched$reader) +expect_inherits(r, "pkgops_outcome") +expect_true(is.na(r$verified)) +expect_equal(cr_untouched$n, 0L) # short-circuits, no read + +## a KNOWN FAILURE is not verified: verification does not run (a failure path has +## no trustworthy post-state), and the mapped condition is still signaled after +## the outcome was written. +cr_fail <- counting_reader(status = "installed", version = "1.2") # would verify TRUE +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "operation_failed", TRUE)), preview = prev1, + reader = cr_fail$reader) +expect_inherits(r, "runix_operation_failed") +expect_equal(cr_fail$n, 0L) # verification skipped +expect_true("write_outcome" %in% lg$seq) # known close still written + +## a LEFT-OPEN effect_unknown is not verified either (the effect is unknown) and +## the intent stays open (no write_outcome). +cr_open <- counting_reader(status = "installed", version = "1.2") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("effect_unknown", effect_issued = NA)), + preview = prev1, reader = cr_open$reader) +expect_inherits(r, "runix_helper_bad_result") +expect_equal(cr_open$n, 0L) +expect_false("write_outcome" %in% lg$seq) + +## verification is INDEPENDENT of the helper status (4.7): a clean `ok` whose +## post-state disagrees is a verification FAILURE, not a pass. +cr_lie <- counting_reader(status = "half-configured", version = "1.2") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = cr_lie$reader) +expect_identical(r$verified, FALSE) # helper said ok; the host disagrees +expect_true(grepl("half-configured", r$verify_detail))