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
32 changes: 20 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,35 @@ 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
→ `config-files`/`not-installed`/absent; purge → absent/`not-installed`, a
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_<verb>()` 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).
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 27 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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_<verb>()` 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 /
Expand Down
55 changes: 44 additions & 11 deletions R/commit.R
Original file line number Diff line number Diff line change
@@ -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_<verb>() 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_<verb>() 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.
Expand Down Expand Up @@ -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 --
Expand Down Expand Up @@ -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).
Expand Down
124 changes: 120 additions & 4 deletions inst/tinytest/test_commit.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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, ...),
Expand Down Expand Up @@ -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))