Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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")),
Expand Down
17 changes: 17 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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_<verb>()` commit API**
Expand Down
15 changes: 7 additions & 8 deletions R/commit.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
30 changes: 20 additions & 10 deletions R/verify.R
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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]))
Expand Down
65 changes: 65 additions & 0 deletions inst/tinytest/test_architecture_all.R
Original file line number Diff line number Diff line change
@@ -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)
})
21 changes: 11 additions & 10 deletions inst/tinytest/test_commit.R
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
127 changes: 127 additions & 0 deletions inst/tinytest/test_failure_audit.R
Original file line number Diff line number Diff line change
@@ -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"))
})