diff --git a/CLAUDE.md b/CLAUDE.md index d64911a..9c06c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DESCRIPTION b/DESCRIPTION index 369a482..d07f012 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-20 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 03853de..c44a75d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/R/commit.R b/R/commit.R index fd0208e..e8afd71 100644 --- a/R/commit.R +++ b/R/commit.R @@ -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) { @@ -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, @@ -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) diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index e25063a..0e8f7a9 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -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) @@ -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 ----- @@ -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") diff --git a/inst/tinytest/test_record.R b/inst/tinytest/test_record.R index c334d75..e56ff45 100644 --- a/inst/tinytest/test_record.R +++ b/inst/tinytest/test_record.R @@ -85,10 +85,19 @@ 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") @@ -96,38 +105,55 @@ 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 -----------------