diff --git a/DESCRIPTION b/DESCRIPTION index 2db6dd3..00de221 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: pkgops Type: Package Title: Unprivileged Issuer for 'APT' Package-State Mutations -Version: 0.0.1.10 -Date: 2026-08-20 +Version: 0.0.1.12 +Date: 2026-09-19 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index ca63db4..816b741 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,20 @@ +# pkgops 0.0.1.12 + +* Transaction and configure verification now match dpkg's `Architecture: all` + packages to libapt's concrete architecture records. Durable observations keep + the plan's package identity and capture the actual installed or broken state. +* Other concrete architectures remain distinct. Duplicate or ambiguous installed + rows fail verification and mark the observation unavailable. + +# pkgops 0.0.1.11 + +* Known commit failures now capture observed package state before writing the + durable outcome and raising the original condition. A `dpkg_broken` outcome + records the half-configured package state, verification verdict, and observed + transition while preserving the helper's status and `effect_issued` boolean. +* Failed state reads are recorded as unavailable. Unknown effects still leave + their intent open without a fabricated outcome. + # pkgops 0.0.1.10 Commit lifecycle (slice 3b): the **exported per-verb `apt_()` commit API** diff --git a/R/commit.R b/R/commit.R index 4afa907..82de4dd 100644 --- a/R/commit.R +++ b/R/commit.R @@ -454,14 +454,13 @@ } ## step 6 -- pkgstate VERIFICATION + post-state capture (contract 4.7 / VM-gate - ## plan 2.2-2.4). 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 + the observed post-state + the - ## pre/post diff onto the outcome. A known failure carries its own condition and - ## a left-open effect is unknown, so neither has a trustworthy post-state. 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)) { + ## plan 2.2-2.4). Observe every known result before closing its outcome, + ## including partial failures: the boundary contract requires broken package + ## state in the durable record. A trustworthy helper failure does not make + ## a fresh pkgstate read untrustworthy. Keep the helper's status and effect + ## boolean unchanged; observation never raises or changes close/open. Unknown + ## effects still leave their intent open without fabricating an outcome. + if (!decided$leave_open) { decided$outcome <- .capture_post(decided$outcome, preview, before) } diff --git a/R/verify.R b/R/verify.R index 65cee5b..ba7ae8e 100644 --- a/R/verify.R +++ b/R/verify.R @@ -135,6 +135,20 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader invisible(df) } +## libapt P.Arch() names the cache architecture, including the native slot for +## Architecture: all packages. dpkg retains "all" in its Architecture column. +## Match that architecture-independent row without accepting a different concrete +## architecture. Ambiguous ground truth fails closed rather than choosing a row. +.installed_row <- function(inst, package, architecture) { + row <- inst[!is.na(inst$package) & inst$package == package & + !is.na(inst$architecture) & + inst$architecture %in% c(architecture, "all"), , drop = FALSE] + if (nrow(row) > 1L) { + stop(sprintf("ambiguous installed state for %s:%s", package, architecture)) + } + row +} + ## Fold per-record failure messages into a (verified, detail) verdict: any ## failure -> verified FALSE with the joined reasons; none -> verified TRUE. .verify_result <- function(fails) { @@ -215,9 +229,7 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader fails <- c(fails, "malformed transaction record") next } - row <- inst[!is.na(inst$package) & inst$package == pkg & - !is.na(inst$architecture) & - inst$architecture == arch,, drop = FALSE] + row <- .installed_row(inst, pkg, arch) why <- .check_txn_state(action, to_version, row) if (!is.na(why)) { fails <- c(fails, sprintf("%s: %s", pkg, why)) @@ -241,9 +253,7 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader fails <- c(fails, "malformed configure record") next } - row <- inst[!is.na(inst$package) & inst$package == pkg & - !is.na(inst$architecture) & - inst$architecture == arch,, drop = FALSE] + row <- .installed_row(inst, pkg, arch) if (nrow(row) > 0L) { status <- as.character(row$status[1L]) } else { @@ -359,7 +369,9 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader } ## The observed installed state (txn + configure): {status, version} per record, -## keyed by `package:arch`. An absent package reads back as not-installed/"" so the +## keyed by the PLAN's `package:arch`, also when dpkg reports Architecture: all. +## Keeping the plan identity gives pre/post observations stable keys. An absent +## package reads back as not-installed/"" so the ## key is still present (documenting what was checked). A malformed record (no ## package/arch) is skipped -- .verify already fails it; the observation only ## records what it can read. @@ -375,9 +387,7 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader next } key <- paste0(pkg, ":", arch) - row <- inst[!is.na(inst$package) & inst$package == pkg & - !is.na(inst$architecture) & - inst$architecture == arch,, drop = FALSE] + row <- .installed_row(inst, pkg, arch) if (nrow(row) > 0L) { state[[key]] <- list(status = as.character(row$status[1L]), version = as.character(row$version[1L])) diff --git a/inst/tinytest/test_architecture_all.R b/inst/tinytest/test_architecture_all.R new file mode 100644 index 0000000..6ce7f0e --- /dev/null +++ b/inst/tinytest/test_architecture_all.R @@ -0,0 +1,65 @@ +# libapt's P.Arch() identifies the native cache slot for Architecture: all. +# dpkg's Architecture field remains "all". These independently specified frames +# reproduce the real canary mismatch without consulting or changing live dpkg. +local({ + verify <- pkgops:::.verify + observe <- pkgops:::.observe + reads <- 0L + frame <- function(arch = "all", status = "installed", version = "1.1", + package = "canary-fixture") { + data.frame(package = package, architecture = arch, status = status, + version = version, stringsAsFactors = FALSE) + } + reader <- function(df) list(installed = function() { + reads <<- reads + 1L + df + }) + preview <- function(action, arch = "amd64") { + list(verb = if (action == "configure") "apt.configure" else "apt.install", + records = list(list(package = "canary-fixture", architecture = arch, + action = action, from_version = "1.0", to_version = "1.1", + current_version = "1.0", state = "half-configured"))) + } + installed <- frame() + absent <- installed[FALSE, ] + for (action in c("install", "upgrade", "downgrade", "configure")) { + p <- preview(action) + expect_true(verify(p, reader(installed))$verified) + got <- observe(p, reader(installed)) + expect_identical(got$state, list("canary-fixture:amd64" = + list(status = "installed", version = "1.1"))) + expect_false(got$read_failed) + expect_false(verify(p, reader(frame(status = "half-configured")))$verified) + expect_false(verify(p, reader(frame(arch = "i386")))$verified) + expect_false(verify(p, reader(frame(package = "another-fixture")))$verified) + } + p <- preview("install") + expect_false(verify(p, reader(frame(version = "1.0")))$verified) + expect_true(verify(preview("install", "all"), reader(installed))$verified) + expect_false(verify(preview("install", "all"), reader(frame(arch = "amd64")))$verified) + # Concrete multiarch rows remain distinct: i386's version cannot satisfy amd64. + multi <- rbind(frame("amd64", version = "1.0"), frame("i386")) + expect_false(verify(p, reader(multi))$verified) + expect_true(verify(preview("install", "i386"), reader(multi))$verified) + for (action in c("remove", "purge")) { + p <- preview(action) + expect_false(verify(p, reader(installed))$verified) + expect_true(verify(p, reader(absent))$verified) + before <- observe(p, reader(installed)) + after <- observe(p, reader(absent)) + expect_true(pkgops:::.state_changed(before, after)) + } + expect_true(verify(preview("remove"), reader(frame(status = "config-files")))$verified) + expect_false(verify(preview("purge"), reader(frame(status = "config-files")))$verified) + # Never silently choose one row from contradictory or duplicate ground truth. + for (df in list(rbind(installed, frame("amd64")), rbind(installed, installed))) { + for (action in c("install", "configure")) { + p <- preview(action) + expect_false(verify(p, reader(df))$verified) + got <- observe(p, reader(df)) + expect_true(got$read_failed) + expect_null(got$state) + } + } + expect_true(reads > 0L) +}) diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 1482b21..7dc7430 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -1,5 +1,5 @@ # The commit-session orchestrator (R/commit.R): capability -> open -> commit -> -# classify -> [verify deferred] -> write_outcome -> signal, with the outcome +# classify -> observe known result -> write_outcome -> signal, with the outcome # ALWAYS written before the condition is signaled (contract 4.8). Hermetic: the # four runix effect-session calls are replaced through the session-ops seam # (R/session_ops.R), so nothing reaches a broker, a pkexec entrypoint, or dpkg. @@ -432,7 +432,7 @@ 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). +## verification runs for every known result, including a signaled failure. ## ============================================================================ ## A committable install preview carrying one resolved txn record, and a fake @@ -506,20 +506,20 @@ 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. +## A known failure still has observable post-state. A matching read does not +## replace the helper's failure: the error outcome is written before signaling. 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") -## only the pre-commit snapshot reads on a failure path; the verdict + post-state -## observation are success-path only, so no post-commit read happens -expect_equal(cr_fail$n, 1L) +expect_equal(cr_fail$n, 2L) # pre + shared post-read +expect_identical(lg$record$changed, TRUE) # matches despite helper failure +expect_identical(lg$record$state_changed, FALSE) # same observed state before/after +expect_identical(lg$record$outcome, "error") 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 +## A left-open effect_unknown is not verified (the effect is unknown) and ## the intent stays open (no write_outcome). cr_open <- counting_reader(status = "installed", version = "1.2") lg <- newlog() @@ -627,4 +627,5 @@ r <- run_commit(ops_for(lg, cr("ok", "operation_failed", TRUE)), preview = prev1 reader = counting_reader()$reader) expect_inherits(r, "runix_operation_failed") expect_equal(lg$record$authorized_via, "pkcheck") # authorized before it failed -expect_false("changed" %in% names(lg$record)) # not verified on a failure +expect_identical(lg$record$changed, TRUE) # independent post-state verdict +expect_identical(lg$record$outcome, "error") # helper failure preserved diff --git a/inst/tinytest/test_failure_audit.R b/inst/tinytest/test_failure_audit.R new file mode 100644 index 0000000..509efff --- /dev/null +++ b/inst/tinytest/test_failure_audit.R @@ -0,0 +1,127 @@ +# Known failures must record independently observed state before signaling. +# All external operations are fakes; the public commit API drives the lifecycle. +local({ + set_ops <- pkgops:::set_session_ops + set_pkcheck <- pkgops:::set_pkcheck + set_reader <- pkgops:::set_pkgstate_reader + cid <- "20260919000000000000-0123456789abcdef" + state <- function(status) { + data.frame(package = "canary-fixture", architecture = "all", + version = "1.0", status = status, stringsAsFactors = FALSE) + } + absent <- state("not-installed")[FALSE, ] + broken <- state("half-configured") + + exercise <- function(verb = "install", effect = TRUE, before = absent, + after = broken, read_fail = FALSE, persist_fail = FALSE, + unknown = FALSE) { + events <- character() + committed <- FALSE + record <- NULL + old_ops <- set_ops(list( + capability = function(...) events <<- c(events, "capability"), + open = function(...) { + events <<- c(events, "open") + list(correlation_id = cid) + }, + commit = function(...) { + events <<- c(events, "commit") + committed <<- TRUE + list(session_status = if (unknown) "effect_unknown" else "ok", + status = "dpkg_broken", effect_issued = if (unknown) NA else effect, + correlation_id = cid, detail = "synthetic failure") + }, + write_outcome = function(session, record, ...) { + events <<- c(events, "write_outcome") + assign("record", record, envir = parent.env(environment())) + list(status = if (persist_fail) "persist_failed" else "ok") + }, + refuse = function(...) stop("unexpected refusal path"))) + on.exit(set_ops(old_ops), add = TRUE) + old_pk <- set_pkcheck(function(action) { + events <<- c(events, "pkcheck") + 0L + }) + on.exit(set_pkcheck(old_pk), add = TRUE) + old_reader <- set_reader(list( + installed = function() { + events <<- c(events, if (committed) "post-read" else "pre-read") + if (committed && read_fail) stop("synthetic read failure") + if (committed) after else before + }, + selections = function(...) stop("unexpected selection read"))) + on.exit(set_reader(old_reader), add = TRUE) + preview <- structure(list(verb = paste0("apt.", verb), + resource = if (verb == "configure") "" else "canary-fixture", + packages = if (verb == "configure") character() else "canary-fixture", + plan_schema = 1L, plan_hash = strrep("b", 64L), advisory_verdict = "ok", + records = list(list(package = "canary-fixture", architecture = "amd64", + action = "install", to_version = "1.0", state = "half-configured"))), + class = "pkgops_preview") + commit <- if (verb == "configure") pkgops::apt_configure else pkgops::apt_install + result <- tryCatch(commit(preview, interactive = FALSE), error = function(e) { + events <<- c(events, "signal") + e + }) + list(result = result, record = record, events = events) + } + + # G6: package installation touched dpkg and left a half-configured package. + x <- exercise() + expect_inherits(x$result, "runix_dpkg_broken") + expect_identical(x$result$effect_issued, TRUE) + expect_identical(x$result$correlation_id, cid) + expect_identical(x$events, c("capability", "pkcheck", "open", "pre-read", + "commit", "post-read", "write_outcome", "signal")) + expect_identical(x$record$outcome, "error") + expect_identical(x$record$effect_issued, TRUE) + expect_identical(x$record$authorized_via, "pkcheck") + expect_identical(x$record$observed, + list("canary-fixture:amd64" = list(status = "half-configured", version = "1.0"))) + expect_identical(x$record$changed, FALSE) + expect_identical(x$record$state_changed, TRUE) + expect_identical(x$record$observed_failed, FALSE) + + # G7: configure ran but the package is still broken; no observed transition. + x <- exercise(verb = "configure", before = broken) + expect_inherits(x$result, "runix_dpkg_broken") + expect_identical(x$record$operation, "apt.configure") + expect_identical(x$record$observed[["canary-fixture:amd64"]]$status, "half-configured") + expect_identical(x$record$changed, FALSE) + expect_identical(x$record$state_changed, FALSE) + expect_identical(x$record$effect_issued, TRUE) + expect_identical(tail(x$events, 3L), c("post-read", "write_outcome", "signal")) + + # The same status can be a pre-effect refusal; never infer effect from status. + x <- exercise(effect = FALSE, before = broken) + expect_inherits(x$result, "runix_dpkg_broken") + expect_identical(x$result$effect_issued, FALSE) + expect_identical(x$record$effect_issued, FALSE) + expect_identical(x$record$state_changed, FALSE) + expect_identical(x$record$observed[["canary-fixture:amd64"]]$status, "half-configured") + + # A read failure cannot replace the helper failure or invent observed state. + x <- exercise(read_fail = TRUE) + expect_inherits(x$result, "runix_dpkg_broken") + expect_identical(x$record$outcome, "error") + expect_identical(x$record$effect_issued, TRUE) + expect_identical(x$record$observed_failed, TRUE) + expect_false(any(c("observed", "changed", "state_changed") %in% names(x$record))) + expect_identical(tail(x$events, 3L), c("post-read", "write_outcome", "signal")) + + # Audit persistence failure still supersedes the known helper failure. + x <- exercise(persist_fail = TRUE) + expect_inherits(x$result, "runix_broker_error") + expect_identical(x$result$persist_status, "persist_failed") + expect_identical(x$result$effect_issued, TRUE) + expect_identical(x$record$observed[["canary-fixture:amd64"]]$status, "half-configured") + expect_identical(tail(x$events, 3L), c("post-read", "write_outcome", "signal")) + + # Unknown effect: no post-read and no fabricated outcome closes the intent. + x <- exercise(unknown = TRUE) + expect_inherits(x$result, "runix_helper_bad_result") + expect_identical(x$result$effect_issued, NA) + expect_identical(x$result$correlation_id, cid) + expect_identical(x$record, NULL) + expect_identical(x$events, c("capability", "pkcheck", "open", "pre-read", "commit", "signal")) +})