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
83 changes: 47 additions & 36 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,42 +68,53 @@ 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).
- **Increment 6 (draft PR #9, HELD): exported per-verb `apt_<verb>()` API** — the
nine `apt_<verb>(preview, ...)` commit wrappers with the preview
`{verb,resource,plan_hash}` match check; interactive defaults to
`base::interactive()`. This makes pkgops **mutation-capable**. Held pending the
VM proof (Part B) that the complete public path + durable record shape hold
against a real broker/polkit.
- **VM-gate increment Part A (this): durable audit record grammar** — enrich the
committed outcome with the broker `RECORD_SCHEMA` fields so the exported API
writes a complete record. `.observe()` reads the resolved records' post-state
into the record's `observed` object, keyed by `package:arch` (`{status,version}`
for txn/configure, `{selection}` for hold -- one entry per matched arch, since an
unqualified hold target can span arches); `.freeze_reader()` gives verdict +
observe one shared post-read. `state_changed` is a real pre/post `.observe()` diff
(D7 = S-B), `NA` when either side is unavailable, never inferred from
`effect_issued`; `apt.update` observes nothing, so observed/changed/state_changed
are all omitted.
`.authorized_via()` records `pkexec`/`autonomous`/`pkcheck` at the authorization
site (`.authorize()` now returns `list(decision, via)`). `.outcome_record()`
maps onto the broker's 16-field allow-list (omitting `NA`/`NULL` optionals;
`verified` → `changed` only when the post-state was read), and
`.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
broker/polkit; then the `rctl apt.*` surface.
- **VM-gate increment Part A (merged, `ee013da`, 0.0.1.8): durable audit record
grammar** — enrich the committed outcome with the broker `RECORD_SCHEMA` fields so
the exported API writes a complete record. `.observe()` reads the resolved records'
post-state into the record's `observed` object, keyed by `package:arch`
(`{status,version}` for txn/configure, `{selection}` for hold -- one entry per
matched arch, since an unqualified hold target can span arches); `.freeze_reader()`
gives verdict + observe one shared post-read. `state_changed` is a real pre/post
`.observe()` diff (D7 = S-B), `NA` when either side is unavailable, never inferred
from `effect_issued`; `apt.update` observes nothing, so observed/changed/state_changed
are all omitted. `.authorized_via()` records `pkexec`/`autonomous`/`pkcheck` at the
authorization site (`.authorize()` now returns `list(decision, via)`).
`.outcome_record()` maps onto the broker's 16-field allow-list (omitting `NA`/`NULL`
optionals; `verified` → `changed` only when the post-state was read), and
`.validate_record()` mirrors the broker guard. Observation is **success-path only**.
Plan: `runix/docs/pkgops-vm-gate-plan.md`.
- **Coarse `outcome` conformance (merged, 0.0.1.9)**: `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()` 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 (`.PKGOPS_RECORD_REQUIRED`). The R-level refuse path
(`session_ops.R`) already carried `outcome`; the native effect-session
`open_intent` gained it in runix (`effect_session.c`).
- **Increment 6 (this branch, PR #9, 0.0.1.10, held draft): the exported per-verb
`apt_<verb>()` commit API** (`R/commit_api.R`) — nine public entrypoints, each
committing the `pkgops_preview` its `apt_<verb>_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 `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 + the coarse-`outcome`
fix (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.
- **Part B (disposable-VM proof): PASSED** — the real broker/polkit VM run drove the
whole public path on a fresh disposable guest (every functional gate via
`pkgops::apt_<verb>()`; G12-G14 + G15 via the `rab-exercise` broker oracle;
G11a/G11b via direct `pkexec`): polkit matrix 23/23, §7 gates 68/68. It surfaced
and fixed the runix native `open_intent`/empty-resource gaps, the coarse-`outcome`
omission (0.0.1.9), and a pkgexec pre-redemption cid bug (G10, `apt_locked`).
G9/G-OWN are pkgops preview-side refusals (no intent opened), not
broker-redemption refusals. Next: the `rctl apt.*` surface.

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
13 changes: 7 additions & 6 deletions 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.9
Version: 0.0.1.10
Date: 2026-08-20
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",
Expand All @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
36 changes: 35 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
# pkgops 0.0.1.10

Commit lifecycle (slice 3b): the **exported per-verb `apt_<verb>()` 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_<verb>_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_<verb>()` 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.
* Rebased onto the merged Part A durable-record grammar (0.0.1.8) plus the
coarse-`outcome` conformance fix (0.0.1.9, 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_<verb>_run()` convenience (definable purely in terms of the
two-call form).


# pkgops 0.0.1.9

The durable audit record now carries the broker-required coarse `outcome` field.
Expand All @@ -9,7 +44,6 @@ derives `outcome` from the closed status classification (`ok` for `ok`/`no_op`,
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
74 changes: 69 additions & 5 deletions R/commit.R
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,25 @@
## 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. 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") || !.valid_broker_cid(cid)) {
return(cond)
}
if (!.valid_broker_cid(cond$correlation_id)) {
cond$correlation_id <- cid
}
cond
}

.commit_and_classify <- function(ops, session, preview, lock_timeout,
deadline_ms) {
commit <- tryCatch(
Expand All @@ -228,9 +247,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))
}
.classify_commit(commit, preview)
## 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)
}
decided
}

## Handle a machine-mode polkit refusal (contract 4.4). No effect intent is ever
Expand Down Expand Up @@ -292,11 +322,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 {
"<malformed>"
}
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_<verb>() 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) {
Expand Down
Loading