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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ reviewed increments** — hold at each increment before the next.
`.validate_record()` mirrors the broker guard (rejects non-allow-list field,
reserved key, wrong type). Observation is **success-path only**; the failure-path
`observed` shape stays deferred. Plan: `runix/docs/pkgops-vm-gate-plan.md`.
- **Part B follow-up (the coarse `outcome`)**: `RECORD_SCHEMA` marks `operation`
and `outcome` REQUIRED, so a record without `outcome` is `schema_invalid` at
`write_outcome` — a Part B finding the hermetic fake broker could not surface.
`.outcome_record()` now derives the coarse `outcome` from the closed status
(`ok` for `ok`/`no_op`, `error` for every closed failure/refusal), keeping the
detailed per-package result in `observed`. `.validate_record()` enforces the
required pair locally (`.PKGOPS_RECORD_REQUIRED`). The R-level refuse path
(`session_ops.R`) already carried `outcome` (`intent` + status); the native
effect-session open_intent gained the field in runix (`effect_session.c`).
- **Still NOT started** (later increments, each its own review): **Part B** — the
disposable-VM proof that pins the durable record grammar, the plain-intent
refusal record grammar, and the exact pkcheck rc→outcome split against a real
Expand Down
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.8
Date: 2026-08-18
Version: 0.0.1.9
Date: 2026-08-20
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",
comment = c(ORCID = "0009-0005-4248-604X")),
Expand Down
12 changes: 12 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# pkgops 0.0.1.9

The durable audit record now carries the broker-required coarse `outcome` field.
`RECORD_SCHEMA` marks `operation` and `outcome` REQUIRED, so a record without
`outcome` is `schema_invalid` at `write_outcome`; the disposable-VM proof (Part B)
surfaced the omission the hermetic fake broker could not. `.outcome_record()` now
derives `outcome` from the closed status classification (`ok` for `ok`/`no_op`,
`error` for every closed failure or refusal), keeping the detailed per-package
result in `observed`. `.validate_record()` enforces the required pair locally so a
future omission fails hermetically rather than only in the VM.


# pkgops 0.0.1.8

Durable audit record grammar (VM-gate increment, Part A). The committed outcome
Expand Down
27 changes: 25 additions & 2 deletions R/commit.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
"correlation_id", "phase", "host", "pid",
"actor", "time", "binding", "broker")

## The two fields the broker's RECORD_SCHEMA marks REQUIRED (runix-audit-broker
## src/json.c: operation, outcome). A record missing either is schema_invalid at
## the broker; enforce it here so a future omission fails hermetically, not only in
## the VM (the Part B finding was exactly a missing `outcome`).
.PKGOPS_RECORD_REQUIRED <- c("operation", "outcome")

## Whether a value matches its allow-list type: a scalar non-NA string/bool/number
## (number >= 0), or a named-list object.
.record_type_ok <- function(value, type) {
Expand Down Expand Up @@ -72,6 +78,11 @@
stop_pkgops("internal: outcome record carries a non-allow-list field: ",
unknown[1L], class = "pkgops_bad_request")
}
missing <- setdiff(.PKGOPS_RECORD_REQUIRED, nm)
if (length(missing) > 0L) {
stop_pkgops("internal: outcome record is missing the broker-required ",
"field: ", missing[1L], class = "pkgops_bad_request")
}
for (f in nm) {
if (!.record_type_ok(rec[[f]], unname(.PKGOPS_RECORD_FIELDS[f]))) {
stop_pkgops("internal: outcome record field `", f,
Expand Down Expand Up @@ -116,8 +127,20 @@
rec
}
}
rec <- list(operation = outcome[["verb"]], resource = outcome[["resource"]],
effect_issued = ei, scope = "system", preview = FALSE)
## the broker-required coarse `outcome`: "ok" for a success status (ok/no_op),
## "error" for every closed failure/refusal that reaches write_outcome. The
## DETAILED result stays in `observed` (the rich per-package post-state), never
## collapsed into this coarse field -- the same success/error split the reference
## boundary (rab-exercise) writes. RECORD_SCHEMA marks `outcome` REQUIRED, so a
## record without it is schema_invalid at the broker (the Part B finding).
outcome_label <- if (isTRUE(outcome[["status"]] %in% .PKGOPS_SUCCESS_STATUSES)) {
"ok"
} else {
"error"
}
rec <- list(operation = outcome[["verb"]], outcome = outcome_label,
resource = outcome[["resource"]], effect_issued = ei,
scope = "system", preview = FALSE)
rec <- add(rec, "authorized_via", outcome[["authorized_via"]])
rec <- add(rec, "observed", outcome[["observed"]])
rec <- add(rec, "changed", changed)
Expand Down
3 changes: 3 additions & 0 deletions inst/tinytest/test_commit.R
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome"))
expect_identical(lg$record$effect_issued, TRUE) # the broker's gate
expect_equal(lg$record$operation, "apt.install")
expect_equal(lg$record$resource, "nginx")
expect_equal(lg$record$outcome, "ok") # broker-required, success -> ok

## capability + open received the preview's schema/verb/resource/hash ----------
expect_equal(lg$cap$plan_schema, 1L)
Expand All @@ -132,6 +133,7 @@ r <- run_commit(ops_for(lg, cr("ok", "no_op", FALSE)))
expect_inherits(r, "pkgops_outcome")
expect_equal(r$status, "no_op")
expect_identical(lg$record$effect_issued, FALSE)
expect_equal(lg$record$outcome, "ok") # no_op is a success -> ok
expect_true("write_outcome" %in% lg$seq)

## ---- known failure: outcome WRITTEN, then the mapped condition signaled -----
Expand All @@ -142,6 +144,7 @@ expect_inherits(r, "pkgops_error")
# outcome-closed-before-signal: write_outcome ran before we got the condition
expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome"))
expect_identical(lg$record$effect_issued, TRUE) # the effect happened
expect_equal(lg$record$outcome, "error") # a closed failure -> error
expect_equal(r$verb, "apt.install")
expect_equal(r$detail, "dpkg exited 100")

Expand Down
66 changes: 46 additions & 20 deletions inst/tinytest/test_record.R
Original file line number Diff line number Diff line change
Expand Up @@ -85,49 +85,75 @@ r <- rec_of(mkout(verified = NA, authorized_via = "pkexec", observed = NULL,
observed_failed = NA, state_changed = NA,
verify_detail = NA_character_))
expect_equal(sort(names(r)),
sort(c("operation", "resource", "effect_issued", "scope", "preview",
"authorized_via")))
sort(c("operation", "outcome", "resource", "effect_issued", "scope",
"preview", "authorized_via")))
expect_equal(r$outcome, "ok") # a success status -> coarse "ok"
expect_equal(r$authorized_via, "pkexec")

## ---- the coarse `outcome`: ok for success, error for every closed failure ------
expect_equal(rec_of(mkout(status = "no_op", effect_issued = FALSE))$outcome, "ok")
expect_equal(rec_of(mkout(status = "operation_failed",
effect_issued = TRUE))$outcome, "error")
expect_equal(rec_of(mkout(status = "dpkg_broken", effect_issued = FALSE))$outcome,
"error")
expect_equal(rec_of(mkout(status = "held", effect_issued = FALSE))$outcome, "error")

## ---- effect_issued must be a known boolean (never NA at write time) ----------
expect_error(rec_of(mkout(effect_issued = NA)), class = "pkgops_bad_request")

## ============================================================================
## .validate_record: the schema guard
## ============================================================================

## a minimal valid record passes
expect_silent(validate(list(operation = "apt.install", resource = "nginx",
effect_issued = TRUE, scope = "system",
preview = FALSE)))
## a minimal valid record passes (operation + outcome are the broker-required pair)
expect_silent(validate(list(operation = "apt.install", outcome = "ok",
resource = "nginx", effect_issued = TRUE,
scope = "system", preview = FALSE)))

## the two broker-REQUIRED fields must BOTH be present
expect_error(validate(list(outcome = "ok", resource = "nginx")),
class = "pkgops_bad_request") # missing operation
expect_error(validate(list(operation = "apt.install", resource = "nginx")),
class = "pkgops_bad_request") # missing outcome

## a broker-RESERVED key is rejected
for (k in RESERVED) {
bad <- list(operation = "apt.install", resource = "nginx",
bad <- list(operation = "apt.install", outcome = "ok", resource = "nginx",
effect_issued = TRUE)
bad[[k]] <- "x"
expect_error(validate(bad), class = "pkgops_bad_request")
}

## a NON-allow-list field is rejected (the smuggled-field case)
expect_error(validate(list(operation = "apt.install", resource = "nginx",
effect_issued = TRUE, verified = TRUE)),
expect_error(validate(list(operation = "apt.install", outcome = "ok",
resource = "nginx", effect_issued = TRUE,
verified = TRUE)),
class = "pkgops_bad_request") # `verified` is NOT a broker field
expect_error(validate(list(operation = "apt.install", request_id = "42")),
expect_error(validate(list(operation = "apt.install", outcome = "ok",
request_id = "42")),
class = "pkgops_bad_request")

## wrong TYPES are rejected (the broker validates T_BOOL / T_OBJECT / ...)
expect_error(validate(list(changed = "yes")), class = "pkgops_bad_request") # bool
expect_error(validate(list(observed = "nginx")), class = "pkgops_bad_request") # object
expect_error(validate(list(state_changed = list(a = 1))),
## wrong TYPES are rejected (the broker validates T_BOOL / T_OBJECT / ...); each
## record carries the required pair so the presence guard does not mask the type
## check.
base_ok <- list(operation = "apt.install", outcome = "ok")
expect_error(validate(c(base_ok, list(changed = "yes"))),
class = "pkgops_bad_request") # bool
expect_error(validate(list(elapsed = -1)), class = "pkgops_bad_request") # >= 0
expect_error(validate(list(effect_issued = NA)), class = "pkgops_bad_request") # non-NA

## a correct object / bool / number pass
expect_silent(validate(list(observed = list("nginx:amd64" =
expect_error(validate(c(base_ok, list(observed = "nginx"))),
class = "pkgops_bad_request") # object
expect_error(validate(c(base_ok, list(state_changed = list(a = 1)))),
class = "pkgops_bad_request") # bool
expect_error(validate(c(base_ok, list(elapsed = -1))),
class = "pkgops_bad_request") # >= 0
expect_error(validate(c(base_ok, list(effect_issued = NA))),
class = "pkgops_bad_request") # non-NA

## a correct object / bool / number pass (with the required pair present)
expect_silent(validate(list(operation = "apt.install", outcome = "ok",
observed = list("nginx:amd64" =
list(status = "installed")))))
expect_silent(validate(list(changed = TRUE, state_changed = FALSE,
expect_silent(validate(list(operation = "apt.install", outcome = "error",
changed = TRUE, state_changed = FALSE,
observed_failed = FALSE, elapsed = 0)))

## ---- the allow-list is exactly the broker's 16 domain fields -----------------
Expand Down