From d7613bdc4284ca61eeb946502c879bfc49c98a78 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 16:08:25 -0500 Subject: [PATCH 1/8] commit-api: export the nine per-verb apt_() commit entrypoints The exported public API that completes the commit lifecycle. Each apt_(preview, ...) takes the pkgops_preview its apt__preview() twin produced and drives .commit_session for it, returning a pkgops_outcome or signaling a typed condition. This is the increment that makes pkgops mutation-capable. .commit_verb() adds the two checks the verb-agnostic .commit_session cannot make -- the argument is a pkgops_preview, and its verb is the one this function commits (apt_install() refuses an apt.remove preview: a verb/preview mismatch) -- both before any capability call or intent, then delegates. The plan_hash the preview carries stays the integrity authority (the helper re-validates it under the dpkg lock); pkgops does not re-derive it at the R layer. interactive defaults to interactive(): an R console commits through the pkexec prompt, a script or CI run commits in machine mode (pkcheck). lock_timeout/deadline_ms/socket_path pass through to the session. Updates the DESCRIPTION: mutation is no longer out of scope. Hermetic tests cover the verb match, the nine-verb surface, the non-ok/non-preview refusals, parameter pass-through, and both authorization modes. --- DESCRIPTION | 11 ++- R/commit.R | 40 +++++++- R/commit_api.R | 132 +++++++++++++++++++++++++ inst/tinytest/test_commit_api.R | 165 ++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 R/commit_api.R create mode 100644 inst/tinytest/test_commit_api.R diff --git a/DESCRIPTION b/DESCRIPTION index 851e757..ee4426e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,11 +10,12 @@ Authors@R: c( Description: The unprivileged R issuer for authorized 'APT' package-state mutations in the Runix system-administration framework. It plans a change with the read-only 'runix-apt-preview' helper, returning a typed, advisory - preview whose plan digest binds exactly what a later commit would apply. - Mutation itself is out of scope of this release: previews open no intent, - take no lock, and mint nothing. Reads of installed state live in the sibling - 'pkgstate'; the privileged effectors and the preview helper are shipped - separately by 'pkgexec'. + preview whose plan digest binds exactly what a commit would apply, then + commits that exact plan through the privileged effect-session: it negotiates + an effect receipt, authorizes the verb through 'polkit', opens an + effect-bound intent, and records a durable, verified outcome. The privileged + effectors and the preview helper are shipped separately by 'pkgexec'; reads + of installed state live in the sibling 'pkgstate'. License: MIT + file LICENSE OS_type: unix SystemRequirements: pkgexec (provides the unprivileged 'runix-apt-preview' diff --git a/R/commit.R b/R/commit.R index 2fdca56..7b5f740 100644 --- a/R/commit.R +++ b/R/commit.R @@ -175,11 +175,45 @@ stop(cond) } +## The exported per-verb commit entrypoint's shared body (R/commit_api.R). It adds +## the two checks the verb-agnostic .commit_session cannot make -- the argument is +## a pkgops_preview, and its verb is the one THIS function commits (apt_install() +## must never commit an apt.remove plan) -- then delegates. Both run BEFORE any +## capability call or intent; .commit_session then enforces committability +## (advisory_verdict == "ok", a bound digest) and drives the lifecycle. The +## plan_hash the preview carries is the integrity authority: the privileged helper +## re-validates it under the dpkg lock, so a drifted plan is refused there, never +## applied -- pkgops does not (and cannot) re-derive it at the R layer. +.commit_verb <- function(expected_verb, preview, lock_timeout, deadline_ms, + interactive, socket_path) { + if (!inherits(preview, "pkgops_preview")) { + stop_pkgops("commit requires a pkgops_preview (from ", + sub("^apt\\.", "apt_", expected_verb), "_preview())", + class = "pkgops_bad_request") + } + if (!identical(preview$verb, expected_verb)) { + got <- if (.is_scalar_str(preview$verb)) { + shQuote(preview$verb) + } else { + "" + } + stop_pkgops("verb/preview mismatch: ", + sub("^apt\\.", "apt_", expected_verb), + "() cannot commit a preview for ", got, + class = "pkgops_bad_request", + data = list(expected_verb = expected_verb, + preview_verb = preview$verb)) + } + .commit_session(preview, socket_path = socket_path, + interactive = interactive, lock_timeout = lock_timeout, + deadline_ms = deadline_ms) +} + ## Drive the branched commit lifecycle for one committable preview and return the ## pkgops_outcome (success) or SIGNAL the mapped condition (failure), with the -## outcome ALWAYS written before the signal (4.8). Internal for now -- the public -## per-verb API that wraps this (with the preview {verb,resource,plan_hash} match -## check) lands once pkgstate verification completes the lifecycle. +## outcome ALWAYS written before the signal (4.8). Verb-agnostic: it reads the verb +## from the preview. The exported per-verb apt_() wrappers (R/commit_api.R) +## reach it through .commit_verb(), which adds the verb/preview match check. .commit_session <- function(preview, socket_path = .PKGOPS_BROKER_SOCKET, interactive = FALSE, lock_timeout = 0L, deadline_ms = 120000L) { diff --git a/R/commit_api.R b/R/commit_api.R new file mode 100644 index 0000000..0b2f477 --- /dev/null +++ b/R/commit_api.R @@ -0,0 +1,132 @@ +#' Commit an authorized apt package-state change +#' +#' Commit the exact change an \code{apt__preview()} resolved. Each function +#' takes the \code{pkgops_preview} its preview twin produced and drives the +#' privileged commit lifecycle for it: it negotiates the effect-receipt +#' capability, authorizes the verb through polkit, opens an effect-bound intent, +#' commits through the runix effect-session, verifies the post-state against the +#' plan, and writes the durable outcome. Unlike the preview twin, this mutates the +#' system. +#' +#' A commit binds \strong{only} the preview it is handed. The \code{plan_hash} the +#' preview carries is what the privileged helper re-validates under the \code{dpkg} +#' lock, so a plan that has drifted since the preview is refused there, never +#' applied. Each \code{apt_()} refuses, before anything is opened, a preview +#' for a different verb (an \code{apt.remove} preview handed to +#' \code{apt_install()} is a \code{pkgops_bad_request}), a non-\code{ok} preview (a +#' \code{no_op} or a policy refusal is never committable), and anything that is not +#' a \code{pkgops_preview}. +#' +#' \strong{Authorization.} With \code{interactive = TRUE} the \code{pkexec} prompt +#' authenticates the change at the privileged spawn; with \code{interactive = +#' FALSE} (machine mode) a non-interactive \code{pkcheck} decides, and a denial or +#' an approval challenge becomes a durably-audited refusal, never a prompt. The +#' default follows \code{\link{interactive}()} -- an R console commits +#' interactively, a script or CI run commits in machine mode. +#' +#' On any refusal or failure the call signals a typed condition inheriting +#' \code{pkgops_error} and \code{runix_error} (for example \code{runix_unauthorized}, +#' \code{runix_held}, \code{runix_apt_locked}, \code{runix_operation_failed}, +#' \code{runix_dpkg_broken}). An effect whose outcome could not be determined +#' (\code{runix_helper_bad_result}) leaves the intent open for reconciliation and +#' is never reported as a clean failure. +#' +#' @param preview The \code{pkgops_preview} to commit, from the matching +#' \code{apt__preview()}. +#' @param lock_timeout Seconds the privileged helper waits for the \code{dpkg} +#' lock before refusing with \code{runix_apt_locked} (\code{0} = do not wait). +#' @param deadline_ms Overall commit deadline, in milliseconds. +#' @param interactive Authorize through the interactive \code{pkexec} prompt +#' (\code{TRUE}) or a non-interactive \code{pkcheck} (\code{FALSE}). Defaults to +#' \code{\link{interactive}()}. +#' @param socket_path The broker's \code{AF_UNIX} socket. +#' @return A \code{pkgops_outcome} recording the committed change: its +#' \code{effect_issued} and, for a verb with an observable post-state, its +#' \code{verified} verdict and \code{verify_detail}. Signals a typed condition +#' on any refusal or failure. +#' @examples +#' \dontrun{ +#' p <- apt_install_preview(c("nginx")) +#' out <- apt_install(p, lock_timeout = 300) +#' out$verified +#' } +#' @rdname apt_commit +#' @export +apt_install <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.install", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_remove <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.remove", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_purge <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.purge", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_hold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.hold", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_unhold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.unhold", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_update <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.update", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.upgrade", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} + +#' @rdname apt_commit +#' @export +apt_dist_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.dist_upgrade", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_configure <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.configure", preview, lock_timeout, deadline_ms, interactive, + socket_path) +} diff --git a/inst/tinytest/test_commit_api.R b/inst/tinytest/test_commit_api.R new file mode 100644 index 0000000..c2f33fd --- /dev/null +++ b/inst/tinytest/test_commit_api.R @@ -0,0 +1,165 @@ +# The exported per-verb commit API (R/commit_api.R): thin wrappers over +# .commit_session that add the verb/preview match check and pass the commit +# parameters through. Hermetic: the session ops, pkcheck, and (unused here, since +# the previews carry no records) the pkgstate reader are all behind seams. + +H <- strrep("b", 64L) +CID <- "20250101000000000000-0123456789abcdef" +set_ops <- pkgops:::set_session_ops +set_pkcheck <- pkgops:::set_pkcheck + +## A committable `ok` preview for a given verb. records = list() so step-6 +## verification short-circuits (verified NA) and never touches the reader. +mkprev <- function(verb, resource = "nginx", packages = "nginx", + records = list(), advisory_verdict = "ok") { + structure(list(schema_version = 1L, verb = verb, resource = resource, + plan_schema = 1L, plan_hash = H, autonomous = FALSE, + packages = packages, records = records, + advisory_verdict = advisory_verdict, + advisory_detail = NA_character_), class = "pkgops_preview") +} + +newlog <- function() { + e <- new.env(parent = emptyenv()) + e$seq <- character(0) + e +} + +## A recording fake ops set that commits cleanly (ok, effect TRUE). +ok_ops <- function(log) { + list(capability = function(socket_path, plan_schema, ...) { + log$seq <- c(log$seq, "capability") + invisible(TRUE) + }, refuse = function(socket_path, operation, resource, status, ...) { + log$seq <- c(log$seq, "refuse") + log$refuse <- list(operation = operation, status = status) + list(correlation_id = CID, audit_persisted = TRUE) + }, open = function(socket_path, operation, resource, plan_schema, plan_hash, + ...) { + log$seq <- c(log$seq, "open") + log$open <- list(operation = operation, resource = resource) + structure(list(handle = "fake", correlation_id = CID), + class = "runix_effect_session") + }, commit = function(session, packages, lock_timeout, deadline_ms, ...) { + log$seq <- c(log$seq, "commit") + log$commit <- list(packages = packages, lock_timeout = lock_timeout, + deadline_ms = deadline_ms) + structure(list(session_status = "ok", status = "ok", + effect_issued = TRUE, correlation_id = CID, + detail = NULL), class = "runix_commit_result") + }, write_outcome = function(session, record, ...) { + log$seq <- c(log$seq, "write_outcome") + list(status = "ok", detail = NULL) + }) +} + +## Run an exported commit fn under the fakes; return list(res, log). res is the +## outcome (success) or the raised condition. interactive is forced (default +## FALSE) so the run is deterministic regardless of the test session's mode. +run_api <- function(fn, preview, interactive = FALSE, pkcheck = function(a) 0L, + log = newlog(), ...) { + old_ops <- set_ops(ok_ops(log)) + old_pk <- set_pkcheck(pkcheck) + on.exit({ + set_ops(old_ops) + set_pkcheck(old_pk) + }) + res <- tryCatch(fn(preview, socket_path = "/fake.sock", + interactive = interactive, ...), + condition = function(c) c) + list(res = res, log = log) +} + +## The nine exported verbs and the request verb each commits. +verbs <- list(list(fn = pkgops::apt_install, verb = "apt.install"), + list(fn = pkgops::apt_remove, verb = "apt.remove"), + list(fn = pkgops::apt_purge, verb = "apt.purge"), + list(fn = pkgops::apt_hold, verb = "apt.hold"), + list(fn = pkgops::apt_unhold, verb = "apt.unhold"), + list(fn = pkgops::apt_update, verb = "apt.update"), + list(fn = pkgops::apt_upgrade, verb = "apt.upgrade"), + list(fn = pkgops::apt_dist_upgrade, verb = "apt.dist_upgrade"), + list(fn = pkgops::apt_configure, verb = "apt.configure")) + +## ---- each fn commits its OWN verb's preview, verb (not a path) reaches open --- +for (v in verbs) { + out <- run_api(v$fn, mkprev(v$verb)) + expect_inherits(out$res, "pkgops_outcome") + expect_equal(out$res$verb, v$verb) + expect_equal(out$log$open$operation, v$verb) # verb, never a path + expect_equal(out$log$seq, + c("capability", "open", "commit", "write_outcome")) +} + +## ---- each fn REFUSES a preview for a different verb, before anything opens ---- +for (i in seq_along(verbs)) { + v <- verbs[[i]] + other <- verbs[[if (i == 1L) 2L else 1L]]$verb # some other verb + out <- run_api(v$fn, mkprev(other)) + expect_inherits(out$res, "pkgops_bad_request") + expect_true(grepl("mismatch", conditionMessage(out$res))) + expect_equal(length(out$log$seq), 0L) # nothing opened or minted +} + +## ---- a non-preview argument is refused, nothing opened ----------------------- +out <- run_api(pkgops::apt_install, 42) +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) +out <- run_api(pkgops::apt_purge, list(verb = "apt.purge")) # bare list +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) + +## the not-a-preview message names the matching preview constructor (incl. the +## underscore verb, so the apt. -> apt_ derivation is right) +out <- run_api(pkgops::apt_dist_upgrade, 42) +expect_true(grepl("apt_dist_upgrade_preview", conditionMessage(out$res))) + +## ---- a non-ok preview is still refused through the wrapper ------------------- +out <- run_api(pkgops::apt_install, mkprev("apt.install", advisory_verdict = "no_op")) +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) +for (verdict in c("held", "protected_package", "package_not_owned")) { + out <- run_api(pkgops::apt_install, + mkprev("apt.install", advisory_verdict = verdict)) + expect_inherits(out$res, "pkgops_bad_request") + expect_equal(length(out$log$seq), 0L) +} + +## ---- commit parameters pass through to the session commit ------------------- +out <- run_api(pkgops::apt_install, mkprev("apt.install"), + lock_timeout = 300L, deadline_ms = 45000L) +expect_equal(out$log$commit$lock_timeout, 300L) +expect_equal(out$log$commit$deadline_ms, 45000L) +out <- run_api(pkgops::apt_update, mkprev("apt.update", resource = "@indexes", + packages = character(0))) +expect_inherits(out$res, "pkgops_outcome") +expect_equal(out$log$commit$packages, character(0)) # whole-system: no targets + +## ---- interactive = TRUE defers to the pkexec prompt: pkcheck is NOT run ------ +never_pk <- function(a) stop("pkcheck must not run when interactive") +out <- run_api(pkgops::apt_install, mkprev("apt.install"), interactive = TRUE, + pkcheck = never_pk) +expect_inherits(out$res, "pkgops_outcome") +expect_false("refuse" %in% out$log$seq) +expect_equal(out$log$seq, c("capability", "open", "commit", "write_outcome")) + +## ---- interactive = FALSE runs pkcheck: a denial is a durably-audited refusal -- +out <- run_api(pkgops::apt_install, mkprev("apt.install"), interactive = FALSE, + pkcheck = function(a) 1L) +expect_inherits(out$res, "runix_unauthorized") +expect_equal(out$log$seq, c("capability", "refuse")) # never opened the effect + +## ---- an autonomous verb (apt.update) commits for a member (rc 0) ------------ +out <- run_api(pkgops::apt_update, mkprev("apt.update", resource = "@indexes", + packages = character(0)), + pkcheck = function(a) if (grepl("update$", a)) 0L else 1L) +expect_inherits(out$res, "pkgops_outcome") +expect_equal(out$log$seq, c("capability", "open", "commit", "write_outcome")) + +## ---- the exported surface is exactly the nine commit verbs ------------------ +exp_commit <- c("apt_install", "apt_remove", "apt_purge", "apt_hold", + "apt_unhold", "apt_update", "apt_upgrade", "apt_dist_upgrade", + "apt_configure") +ns <- getNamespaceExports("pkgops") +expect_true(all(exp_commit %in% ns)) # all nine exported +expect_true(all(paste0(exp_commit, "_preview") %in% ns)) # and their preview twins From 64ac57d96d05fcc852045134de22759d7cd5abba Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 16:08:59 -0500 Subject: [PATCH 2/8] rformat + document (apt_commit) --- NAMESPACE | 9 +++ R/commit_api.R | 35 +++++------ man/apt_commit.Rd | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 man/apt_commit.Rd diff --git a/NAMESPACE b/NAMESPACE index e5225ce..e0b185c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,13 +1,22 @@ # tinyrox says don't edit this manually, but it can't stop you! +export(apt_configure) export(apt_configure_preview) +export(apt_dist_upgrade) export(apt_dist_upgrade_preview) +export(apt_hold) export(apt_hold_preview) +export(apt_install) export(apt_install_preview) +export(apt_purge) export(apt_purge_preview) +export(apt_remove) export(apt_remove_preview) +export(apt_unhold) export(apt_unhold_preview) +export(apt_update) export(apt_update_preview) +export(apt_upgrade) export(apt_upgrade_preview) S3method(print,pkgops_outcome) diff --git a/R/commit_api.R b/R/commit_api.R index 0b2f477..e7b88f1 100644 --- a/R/commit_api.R +++ b/R/commit_api.R @@ -55,8 +55,8 @@ apt_install <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.install", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.install", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -64,8 +64,8 @@ apt_install <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_remove <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.remove", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.remove", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -73,8 +73,8 @@ apt_remove <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_purge <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.purge", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.purge", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -82,8 +82,8 @@ apt_purge <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_hold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.hold", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.hold", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -91,8 +91,8 @@ apt_hold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_unhold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.unhold", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.unhold", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -100,8 +100,8 @@ apt_unhold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_update <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.update", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.update", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit @@ -109,13 +109,14 @@ apt_update <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.upgrade", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.upgrade", preview, lock_timeout, deadline_ms, + interactive, socket_path) } #' @rdname apt_commit #' @export -apt_dist_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, +apt_dist_upgrade <- function(preview, lock_timeout = 0L, + deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { .commit_verb("apt.dist_upgrade", preview, lock_timeout, deadline_ms, @@ -127,6 +128,6 @@ apt_dist_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, apt_configure <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, interactive = base::interactive(), socket_path = .PKGOPS_BROKER_SOCKET) { - .commit_verb("apt.configure", preview, lock_timeout, deadline_ms, interactive, - socket_path) + .commit_verb("apt.configure", preview, lock_timeout, deadline_ms, + interactive, socket_path) } diff --git a/man/apt_commit.Rd b/man/apt_commit.Rd new file mode 100644 index 0000000..d6ab218 --- /dev/null +++ b/man/apt_commit.Rd @@ -0,0 +1,147 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{apt_commit} +\alias{apt_install} +\alias{apt_remove} +\alias{apt_purge} +\alias{apt_hold} +\alias{apt_unhold} +\alias{apt_update} +\alias{apt_upgrade} +\alias{apt_dist_upgrade} +\alias{apt_configure} +\title{Commit an authorized apt package-state change} +\usage{ +apt_install( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_remove( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_purge( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_hold( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_unhold( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_update( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_upgrade( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_dist_upgrade( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_configure( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) +} +\arguments{ +\item{preview}{The \code{pkgops_preview} to commit, from the matching +\code{apt__preview()}.} + +\item{lock_timeout}{Seconds the privileged helper waits for the \code{dpkg} +lock before refusing with \code{runix_apt_locked} (\code{0} = do not wait).} + +\item{deadline_ms}{Overall commit deadline, in milliseconds.} + +\item{interactive}{Authorize through the interactive \code{pkexec} prompt +(\code{TRUE}) or a non-interactive \code{pkcheck} (\code{FALSE}). Defaults to +\code{\link{interactive}()}.} + +\item{socket_path}{The broker's \code{AF_UNIX} socket.} +} +\value{ +A \code{pkgops_outcome} recording the committed change: its + \code{effect_issued} and, for a verb with an observable post-state, its + \code{verified} verdict and \code{verify_detail}. Signals a typed condition + on any refusal or failure. +} +\description{ +Commit the exact change an \code{apt__preview()} resolved. Each function +takes the \code{pkgops_preview} its preview twin produced and drives the +privileged commit lifecycle for it: it negotiates the effect-receipt +capability, authorizes the verb through polkit, opens an effect-bound intent, +commits through the runix effect-session, verifies the post-state against the +plan, and writes the durable outcome. Unlike the preview twin, this mutates the +system. +} +\details{ +A commit binds \strong{only} the preview it is handed. The \code{plan_hash} the +preview carries is what the privileged helper re-validates under the \code{dpkg} +lock, so a plan that has drifted since the preview is refused there, never +applied. Each \code{apt_()} refuses, before anything is opened, a preview +for a different verb (an \code{apt.remove} preview handed to +\code{apt_install()} is a \code{pkgops_bad_request}), a non-\code{ok} preview (a +\code{no_op} or a policy refusal is never committable), and anything that is not +a \code{pkgops_preview}. + +\strong{Authorization.} With \code{interactive = TRUE} the \code{pkexec} prompt +authenticates the change at the privileged spawn; with \code{interactive = +FALSE} (machine mode) a non-interactive \code{pkcheck} decides, and a denial or +an approval challenge becomes a durably-audited refusal, never a prompt. The +default follows \code{\link{interactive}()} -- an R console commits +interactively, a script or CI run commits in machine mode. + +On any refusal or failure the call signals a typed condition inheriting +\code{pkgops_error} and \code{runix_error} (for example \code{runix_unauthorized}, +\code{runix_held}, \code{runix_apt_locked}, \code{runix_operation_failed}, +\code{runix_dpkg_broken}). An effect whose outcome could not be determined +(\code{runix_helper_bad_result}) leaves the intent open for reconciliation and +is never reported as a clean failure. + +} +\examples{ +\dontrun{ +p <- apt_install_preview(c("nginx")) +out <- apt_install(p, lock_timeout = 300) +out$verified +} +} From d7c71daa0cd1575354e4e6575152e6dbfc33220d Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 16:09:25 -0500 Subject: [PATCH 3/8] Bump version to 0.0.1.8 --- DESCRIPTION | 2 +- NEWS.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index ee4426e..6ee6c8c 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.7 +Version: 0.0.1.8 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 7697c0e..d195d83 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,31 @@ +# pkgops 0.0.1.8 + +Commit lifecycle (slice 3b): the **exported per-verb `apt_()` commit API** +(§4.1 / §5). This is the increment that makes `pkgops` **mutation-capable** -- the +first release with a public code path that changes system state. + +* Nine exported entrypoints -- `apt_install`, `apt_remove`, `apt_purge`, + `apt_hold`, `apt_unhold`, `apt_update`, `apt_upgrade`, `apt_dist_upgrade`, + `apt_configure` -- each committing the `pkgops_preview` its + `apt__preview()` twin produced. A commit binds **only** that preview: its + `plan_hash` is what the privileged helper re-validates under the `dpkg` lock, so + a plan that drifted since the preview is refused there, never applied. +* Each `apt_()` refuses, **before anything is opened**, a preview for a + different verb (an `apt.remove` preview handed to `apt_install()` is a + `pkgops_bad_request` -- a verb/preview mismatch), a non-`ok` preview (a `no_op` + or a policy refusal is never committable), and any argument that is not a + `pkgops_preview`. It then drives the full `.commit_session` lifecycle + (capability, polkit, open, commit, verify, write-outcome, signal). +* `interactive` defaults to `interactive()`: an R console commits through the + `pkexec` prompt, a script or CI run commits in machine mode (a non-interactive + `pkcheck`, whose denial or approval challenge is a durably-audited refusal, never + a prompt). `lock_timeout` / `deadline_ms` / `socket_path` pass through. +* The `DESCRIPTION` no longer says mutation is out of scope. +* Still deferred to the **VM-gated increment**: the durable outcome-record grammar + (the `observed`/`changed` post-state fields the verdict feeds) and the real + broker/polkit proof; and, if wanted, the combined plan-and-commit `apt__run()` + convenience (definable purely in terms of the two-call form). + # pkgops 0.0.1.7 Commit lifecycle (slice 3b): **wire verification into `.commit_session`** (§4.3 From 98c02e1131a56adb2751ef9fe4beaff4861fc093 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 16:10:01 -0500 Subject: [PATCH 4/8] docs: mark 5b merged, exported apt_() commit API as this increment --- CLAUDE.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f172c67..679f8fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ reviewed increments** — hold at each increment before the next. (`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** — +- **Increment 5b (merged): 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**: @@ -68,12 +68,22 @@ reviewed increments** — hold at each increment before the next. (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 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. +- **Increment 6 (this): the exported per-verb `apt_()` commit API** + (`R/commit_api.R`) — nine public entrypoints, each committing the + `pkgops_preview` its `apt__preview()` twin produced. `.commit_verb()` + (in `R/commit.R`) adds the two checks `.commit_session` can't: the arg is a + `pkgops_preview`, and its verb is the one this fn commits (a verb/preview + mismatch is a `pkgops_bad_request`), both before anything opens; then delegates. + The `plan_hash` stays the integrity authority (the helper re-validates it under + the lock; pkgops does not re-derive it). `interactive` defaults to + `interactive()`. **This is the increment that makes pkgops mutation-capable** — + the `DESCRIPTION` no longer says mutation is out of scope. +- **Still NOT started** (the VM-gated increment): the durable outcome-record + grammar (incl. the `observed`/`changed` post-state fields the verdict feeds), the + plain-intent refusal record grammar, and the exact pkcheck rc→outcome split, + pinned against a real broker/polkit; and, if wanted, the combined + `apt__run()` convenience (plan+commit, defined in terms of the two-call + form). Then **slice 4: `rctl` apt.\*** (kept separate). The authoritative design is `runix/docs/pkgops-plan.md` (the approved contract) and `runix/docs/pkgops-implementation-plan.md` (rev 2, the build sequence). From 5f05f12fb19bc3f9b65e644089adf3c88db3a365 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 19 Aug 2026 23:22:58 -0500 Subject: [PATCH 5/8] commit: attach the session cid to left-open / effect-unknown conditions A left-open intent exists precisely so it can be reconciled later, which needs its correlation_id. But a commit that RAISED (a mid-flight kill -- the G-INT case) was re-signaled as the raw runix condition with NO cid: the cid lived only on the outcome object .commit_and_classify discards. A lost-result effect_unknown whose delivered frame dropped its correlation_id had the same gap. .ensure_cid(cond, cid) attaches the session correlation_id to a condition that reaches the caller on a left-open / effect-unknown path, PRESERVING its class and every structured field and ADDING the cid only when the condition does not already carry a usable one (never overwriting a cid a lower layer set). Applied in .commit_and_classify to both the raised-commit condition and any classified condition, so every open intent the caller sees is reconcilable. The classified helper-refusal conditions already carried commit$correlation_id, so this is a no-op for them; it fills the two gaps (raised commit, lost cid). 756 tinytest. --- R/commit.R | 32 ++++++++++++++++++++++++++++++-- inst/tinytest/test_commit.R | 16 ++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/R/commit.R b/R/commit.R index 1f17ce6..e83b340 100644 --- a/R/commit.R +++ b/R/commit.R @@ -193,6 +193,23 @@ ## any spawn (no child-side fd-close primitive); a future refinement could close ## that FALSE. Leaving it open is safe (reconciliation resolves an unmatched open ## intent as not-applied) and never fabricates a false effect. +## Attach a broker correlation_id to a condition that reaches the caller on a +## left-open / effect-unknown path, so a killed or lost intent stays RECONCILABLE +## (the whole point of leaving an intent open is to resolve it later, which needs +## its cid). Preserves the condition's CLASS and every existing field; ADDS +## `correlation_id` only when the condition does not already carry a usable one -- +## never overwriting a cid a lower layer set correctly. +.ensure_cid <- function(cond, cid) { + if (!inherits(cond, "condition") || !.is_scalar_str(cid)) { + return(cond) + } + have <- cond$correlation_id + if (is.null(have) || (length(have) == 1L && is.na(have))) { + cond$correlation_id <- cid + } + cond +} + .commit_and_classify <- function(ops, session, preview, lock_timeout, deadline_ms) { commit <- tryCatch( @@ -205,9 +222,20 @@ plan_hash = preview$plan_hash, status = "effect_unknown", effect_issued = NA, condition = commit) - return(list(outcome = oc, condition = commit, leave_open = TRUE)) + ## the RAW runix commit condition carries no session cid -> attach it, so a + ## mid-flight kill (G-INT) leaves a reconcilable open intent. + return(list(outcome = oc, + condition = .ensure_cid(commit, session$correlation_id), + leave_open = TRUE)) + } + ## every classified condition that reaches the caller carries a cid; fall back + ## to the session cid on a left-open / effect-unknown result whose delivered + ## frame lost its correlation_id (a lost result). + decided <- .classify_commit(commit, preview) + if (!is.null(decided$condition)) { + decided$condition <- .ensure_cid(decided$condition, session$correlation_id) } - .classify_commit(commit, preview) + decided } ## Handle a machine-mode polkit refusal (contract 4.4). No effect intent is ever diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index e25063a..945fe12 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -183,6 +183,7 @@ r <- run_commit(ops_for(lg, cr("effect_unknown", effect_issued = NA))) expect_inherits(r, "runix_helper_bad_result") expect_false("write_outcome" %in% lg$seq) # intent left open expect_equal(lg$seq, c("capability", "open", "commit")) +expect_equal(r$correlation_id, CID) # left-open intent stays reconcilable ## ---- a RAISED commit is effect-unknown: left open, original re-signaled ----- lg <- newlog() @@ -193,6 +194,21 @@ r <- run_commit(ops_for(lg, boom, raise = TRUE)) expect_inherits(r, "runix_capability_unavailable") # the original, re-signaled expect_false("write_outcome" %in% lg$seq) # left open expect_equal(lg$seq, c("capability", "open", "commit")) +## the raw runix condition carried no cid; .ensure_cid attaches the SESSION cid so +## the killed/lost intent is reconcilable (G-INT), without replacing its class/fields +expect_equal(r$correlation_id, CID) +expect_inherits(r, "runix_capability_unavailable") # class preserved +expect_equal(conditionMessage(r), "no closefrom primitive") # message preserved + +## ---- a left-open result whose delivered frame LOST its cid falls back to the +## session cid (never leaving an unreconcilable open intent) ------------------- +lg <- newlog() +lost <- list(session_status = "effect_unknown", status = NULL, + effect_issued = NA, correlation_id = NULL, detail = NULL) +r <- run_commit(ops_for(lg, lost)) +expect_inherits(r, "runix_helper_bad_result") +expect_false("write_outcome" %in% lg$seq) +expect_equal(r$correlation_id, CID) # fell back to the session cid ## ---- persist failure: write_outcome ran, effect open, broker_error --------- lg <- newlog() From 63664c7f78eb582eff2edef2c1deba17524bab94 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 19 Aug 2026 23:23:46 -0500 Subject: [PATCH 6/8] Bump version to 0.0.1.9 --- DESCRIPTION | 4 ++-- NEWS.md | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 6ee6c8c..74fc85e 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.8 -Date: 2026-08-18 +Version: 0.0.1.9 +Date: 2026-08-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 a85cde8..3459749 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,10 +21,15 @@ first release with a public code path that changes system state. `pkcheck`, whose denial or approval challenge is a durably-audited refusal, never a prompt). `lock_timeout` / `deadline_ms` / `socket_path` pass through. * The `DESCRIPTION` no longer says mutation is out of scope. -* Still deferred to the **VM-gated increment**: the durable outcome-record grammar - (the `observed`/`changed` post-state fields the verdict feeds) and the real - broker/polkit proof; and, if wanted, the combined plan-and-commit `apt__run()` - convenience (definable purely in terms of the two-call form). +* Rebased onto the merged Part A durable-record grammar (0.0.1.8, below). A + left-open / effect-unknown condition that reaches the caller -- a mid-flight kill, + or a lost result -- now carries the session `correlation_id` (added, never + replacing its class or fields), so an open intent stays **reconcilable**; the cid + used to live only on an outcome the classifier discards. +* Still deferred to the **VM-gated increment (Part B)**: the real broker/polkit + disposable-VM proof of the whole public path; and, if wanted, the combined + plan-and-commit `apt__run()` convenience (definable purely in terms of the + two-call form). # pkgops 0.0.1.8 From 1cb1281c02a9b0ab6e724d6c2efc72ba3f1641ed Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 19 Aug 2026 23:24:28 -0500 Subject: [PATCH 7/8] CLAUDE.md: note the left-open cid fix on the rebased #9 --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7cf9b0c..37af453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,8 +92,11 @@ reviewed increments** — hold at each increment before the next. `plan_hash` stays the integrity authority (the helper re-validates it under the lock; pkgops does not re-derive it). `interactive` defaults to `base::interactive()`. **This is the increment that makes pkgops mutation-capable** — the `DESCRIPTION` no - longer says mutation is out of scope. Rebased onto Part A; held draft pending the - Part B VM proof. + longer says mutation is out of scope. Rebased onto Part A (0.0.1.9); held draft + pending the Part B VM proof. Also: `.ensure_cid()` attaches the session + `correlation_id` to every left-open / effect-unknown condition that reaches the + caller (a mid-flight kill or a lost result), preserving its class/fields, so an + open intent is reconcilable -- needed by the Part B G-INT gate. - **Still NOT started: Part B** — the disposable-VM proof that drives the real pkgops path (34 gates via `pkgops::apt_()`, G12-G14 via the `rab-exercise` broker oracle, G11a/G11b via direct `pkexec`) and pins the durable record shape against a From 4157b9a336c59a67a9611d8413bbfb071fa77123 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:02:23 -0500 Subject: [PATCH 8/8] commit: harden .ensure_cid to replace empty/malformed cids (review round) .ensure_cid now stamps the session correlation_id whenever the condition's existing cid is not ALREADY a well-formed broker cid (reusing .valid_broker_cid), so an empty, NA, malformed, or non-scalar value is replaced -- not just a missing or NA one. A valid cid a lower layer set correctly is still kept, and a non-well-formed session cid is never stamped (no replacing one bad cid with another). This keeps a left-open / effect-unknown intent (G-INT) reconcilable even when the raw condition carried a garbage cid. Regression tests: a direct .ensure_cid unit test (empty/malformed/non-scalar replaced, valid kept, class+message preserved) and a G-INT end-to-end case (a raised commit whose condition carries "", a malformed cid, or NA still leaves a reconcilable open intent). 778 tests pass. CLAUDE.md: correct the stale "34 gates via pkgops" Part B wording to describe membership (every functional gate via pkgops; G12-G14 + G15 via rab-exercise; G11a/G11b via direct pkexec; G9/G-OWN are pkgops preview-side refusals). --- CLAUDE.md | 7 ++++--- R/commit.R | 14 ++++++++------ inst/tinytest/test_commit.R | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37af453..809e8b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,9 +98,10 @@ reviewed increments** — hold at each increment before the next. caller (a mid-flight kill or a lost result), preserving its class/fields, so an open intent is reconcilable -- needed by the Part B G-INT gate. - **Still NOT started: Part B** — the disposable-VM proof that drives the real pkgops - path (34 gates via `pkgops::apt_()`, G12-G14 via the `rab-exercise` broker - oracle, G11a/G11b via direct `pkexec`) and pins the durable record shape against a - real broker/polkit; then the `rctl apt.*` surface. + path (every functional gate via `pkgops::apt_()`; G12-G14 + G15 via the + `rab-exercise` broker oracle; G11a/G11b via direct `pkexec`) and pins the durable + record shape against a real broker/polkit; then the `rctl apt.*` surface. G9/G-OWN + are pkgops preview-side refusals (no intent opened), not broker-redemption refusals. 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/R/commit.R b/R/commit.R index e83b340..86e1d72 100644 --- a/R/commit.R +++ b/R/commit.R @@ -196,15 +196,17 @@ ## Attach a broker correlation_id to a condition that reaches the caller on a ## left-open / effect-unknown path, so a killed or lost intent stays RECONCILABLE ## (the whole point of leaving an intent open is to resolve it later, which needs -## its cid). Preserves the condition's CLASS and every existing field; ADDS -## `correlation_id` only when the condition does not already carry a usable one -- -## never overwriting a cid a lower layer set correctly. +## its cid). Preserves the condition's CLASS and every existing field. Replaces the +## condition's `correlation_id` unless it is ALREADY a well-formed broker cid: a +## missing, NA, empty, or malformed value is overwritten with the (well-formed) +## session cid, so an open intent is never left with an unreconcilable cid; a valid +## cid a lower layer set correctly is kept. A non-well-formed session cid is never +## stamped (that would replace one bad cid with another). .ensure_cid <- function(cond, cid) { - if (!inherits(cond, "condition") || !.is_scalar_str(cid)) { + if (!inherits(cond, "condition") || !.valid_broker_cid(cid)) { return(cond) } - have <- cond$correlation_id - if (is.null(have) || (length(have) == 1L && is.na(have))) { + if (!.valid_broker_cid(cond$correlation_id)) { cond$correlation_id <- cid } cond diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 945fe12..4545cbe 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -210,6 +210,44 @@ expect_inherits(r, "runix_helper_bad_result") expect_false("write_outcome" %in% lg$seq) expect_equal(r$correlation_id, CID) # fell back to the session cid +## ---- .ensure_cid: an EMPTY or MALFORMED existing cid is REPLACED (not only a +## missing/NA one), while a VALID broker cid is kept. This is the G-INT invariant: +## an open intent must never be left with an unreconcilable cid ------------------ +ec <- pkgops:::.ensure_cid +mkcond <- function(cid) structure(list(message = "m", call = NULL, + correlation_id = cid), + class = c("runix_error", "error", "condition")) +OTHER <- "20250202000000000000-fedcba9876543210" # a DIFFERENT valid broker cid +expect_equal(ec(mkcond(NULL), CID)$correlation_id, CID) # missing -> stamped +expect_equal(ec(mkcond(NA_character_), CID)$correlation_id, CID) # NA -> stamped +expect_equal(ec(mkcond(""), CID)$correlation_id, CID) # empty -> REPLACED +expect_equal(ec(mkcond("not-a-broker-cid"), CID)$correlation_id, CID) # malformed -> REPLACED +expect_equal(ec(mkcond(c(CID, CID)), CID)$correlation_id, CID) # non-scalar -> REPLACED +expect_equal(ec(mkcond(OTHER), CID)$correlation_id, OTHER) # valid cid KEPT +## a non-well-formed session cid is never stamped (no replacing bad with bad) +expect_equal(ec(mkcond(""), "bad")$correlation_id, "") +expect_null(ec(mkcond(NULL), "bad")$correlation_id) +## class + message preserved through the replacement +badc <- ec(mkcond(""), CID) +expect_inherits(badc, "runix_error") +expect_equal(conditionMessage(badc), "m") + +## ---- G-INT end to end: a raised commit whose condition carries an EMPTY or +## MALFORMED cid still leaves a reconcilable open intent (the bad cid is replaced +## with the session cid), class + message intact -------------------------------- +for (badcid in list("", "not-a-broker-cid", NA_character_)) { + lg <- newlog() + boom2 <- structure(list(message = "killed mid-commit", call = NULL, + correlation_id = badcid), + class = c("runix_capability_unavailable", "runix_error", + "error", "condition")) + r <- run_commit(ops_for(lg, boom2, raise = TRUE)) + expect_inherits(r, "runix_capability_unavailable") + expect_false("write_outcome" %in% lg$seq) # left open + expect_equal(r$correlation_id, CID) # bad cid replaced by session cid + expect_equal(conditionMessage(r), "killed mid-commit") +} + ## ---- persist failure: write_outcome ran, effect open, broker_error --------- lg <- newlog() r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE),