Skip to content

3b increment 5a: pkgstate verification predicates - #7

Merged
TroyHernandez merged 4 commits into
masterfrom
verify-predicates
Aug 18, 2026
Merged

TroyHernandez merged 4 commits into
masterfrom
verify-predicates

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

The pkgstate-verification predicates (contract §4.7 / §6.3) — pure and hermetic,
held before they are wired into .commit_session. Same cadence as the classifier
→ orchestration and the polkit decision → wiring: build the checker first, wire it
next. pkgstate becomes an Imports here (the default reader uses it).

What this adds

.verify(preview, reader) checks every resolved record of a committed preview
against native dpkg/apt ground truth, per verb:

verb family check (reader)
install / remove / purge / upgrade / dist_upgrade each record by its own action (dpkg_installed()): 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
configure each package fully installed (dpkg_installed())
hold / unhold the dpkg_selections() want reads back (install vs hold)
update no observable post-state → verified = NA

Independent of the helper's self-report (§4.7): .verify reads only the plan
(the preview) and the ground truth (the reader), so a clean status with a
disagreeing post-state is a verification failure. It returns (verified TRUE/FALSE/NA, detail) and never raises.

Record grammar is now load-bearing

The record grammar the 3a preview slice carried advisory-only is pinned here to
shipped pkgexec 0.0.3 (the five transaction actions
install/remove/purge/upgrade/downgrade — no configure/reinstall in txn
records; the configure/hold state words) and pkgstate 0.0.1.9 (dpkg_installed
status = dpkg's state word verbatim; dpkg_selections selection = the want
word). Key semantic pinned from the source: dpkg has no "removed"/"purged"
status word — removed-with-configs is config-files, purged is an absent row, so
purge-vs-remove is distinguished by row-presence + config-files.

Hermetic

The pkgstate reads go through an injectable reader seam
(pkgstate_reader()/set_pkgstate_reader), so the 50 new tests (498 total) run
against canned data frames and never query dpkg. CI installs pkgstate (pure R,
public sibling; the fake reader means dpkg is never touched on the runner).

Point for review

Multi-arch hold: hold records carry no architecture, so hold/unhold is verified
by package name only across all dpkg_selections rows for that package (all
must agree — fail closed). Flagging the name-only match.

Deferred to later increments

  • 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 (§4.3 step 6).
    This is also where the durable outcome record gains the observed/changed
    post-state fields.
  • then the exported per-verb apt_<verb>() API.

CI hermetic; R CMD check clean (0 notes — pkgstate is used, no unused-Import
NOTE) but for the expected "New submission" WARNING.

Add .verify(preview, reader) (R/verify.R): check every RESOLVED RECORD of
a committed preview against native dpkg/apt ground truth, per verb (4.7 /
6.3). The next increment wires this into .commit_session step 6.

  transaction verbs (install/remove/purge/upgrade/dist_upgrade): each
    record checked by ITS OWN action via pkgstate::dpkg_installed() --
    install/upgrade/downgrade must be `installed` at to_version; remove
    leaves config-files/not-installed/an absent row; purge must be absent
    or not-installed (a surviving config-files is a FAILED purge, since
    dpkg has no purged status word -- purge is the absence of a DB entry).
  configure: each record's package must be fully `installed`.
  hold/unhold: the pkgstate::dpkg_selections() want reads back (install
    vs hold), matched by package name (hold records carry no arch).
  update: no observable post-state -> verified NA.

Verification is INDEPENDENT of the helper's self-report (4.7): .verify
reads only the plan (preview) and the ground truth (reader), so a clean
status with a disagreeing post-state is a failure. It returns (verified
TRUE/FALSE/NA, detail) and NEVER raises.

The record grammar the 3a preview slice carried advisory-only is now
load-bearing, pinned to shipped pkgexec 0.0.3 (the five transaction
actions install/remove/purge/upgrade/downgrade; the configure/hold state
words) and pkgstate 0.0.1.9 (dpkg_installed `status` = the state word;
dpkg_selections `selection` = the want word).

The pkgstate reads go through an injectable reader seam
(pkgstate_reader()/set_pkgstate_reader), so the 50 new tests run against
canned data frames and never query dpkg. CI installs pkgstate (pure R,
public sibling).

Still deferred: wiring .verify() into .commit_session (capturing the
verdict into the outcome, never raising), then the exported apt_<verb>().
Two 5a review blockers.

1. Multi-arch hold now preserves target identity. .split_pkg_arch() splits
   a hold record's `pkg:arch` package (hold records carry no separate
   architecture field) and matches BOTH package and architecture against
   dpkg_selections; an unqualified name still matches across all its rows
   (every row must agree, conservative). Tests: qualified success/failure,
   wrong-arch no-match, and two qualified targets on distinct arches.

2. .verify() is now genuinely non-raising on malformed reader/record data:
   - .rec_str() returns NA for a non-list record instead of erroring on
     `rec[[field]]`;
   - the hold selection comparison is NA-safe (an NA selection is a
     mismatch, not `if (NA)`);
   - .require_cols() rejects a reader frame missing an expected column, and
     an outer tryCatch in .verify() normalizes ANY residual error (a
     throwing reader, a malformed frame) into verified = FALSE with a
     detail -- a post-state pkgops cannot read is fail-closed, never a pass.
   Regression tests: NA selection, non-list records, a column-short frame,
   a throwing reader, and a hostile-input batch asserting .verify always
   returns a well-formed (verified, detail) list.
@TroyHernandez

Copy link
Copy Markdown
Contributor Author

Both blockers fixed in the latest commit.

  1. Arch-qualified hold identity: .split_pkg_arch() splits a hold record's pkg:arch and matches both package and architecture; unqualified names still match across all rows (conservative). Tests cover qualified success/failure, wrong-arch (no match), and two qualified targets on distinct arches.
  2. .verify() never raises: .rec_str() handles non-list records, the hold comparison is NA-safe (no if (NA)), .require_cols() rejects column-short frames, and an outer tryCatch normalizes any residual error into verified = FALSE with a detail. Regression tests: NA selection, non-list records, a column-short frame, a throwing reader, and a hostile-input batch.

…ildcard

The pinned pkgexec 0.0.3 record grammar makes architecture a required
field on transaction and configure records (digest.h). The verifier
previously treated a missing architecture as a wildcard
(is.na(arch) | inst$architecture == arch), which matched every arch row
and inspected the first -- a missing/malformed arch could pass against
an unrelated arch's post-state.

Require architecture in both .verify_txn and .verify_configure (a missing
or malformed arch is now a malformed record), and filter the installed
frame by an exact package+architecture match with no wildcard fallback.

Adds a multi-arch regression test: the same package on two arches is
matched to its own arch row (not the first), and a record with no
architecture fails as malformed.
@TroyHernandez

Copy link
Copy Markdown
Contributor Author

Third 5a blocker fixed (2caeea0): architecture is required for transaction/configure records, never a wildcard.

The pinned pkgexec 0.0.3 record grammar (digest.h) makes `architecture` a required field on txn and configure records. `.verify_txn` / `.verify_configure` previously matched a missing arch as `is.na(arch) | inst$architecture == arch` -- that wildcarded across every arch row and inspected the first, so a missing/malformed architecture could pass against an unrelated arch's post-state.

Now:

  • a missing/malformed `architecture` is a malformed record (fails, like a missing `package`/`action`);
  • the installed frame is filtered by an exact `package` + `architecture` match with no wildcard fallback.

Regression test added: the same package on two arches matches each record to its own arch row (i386 at the wrong version fails while amd64 passes), and a record with no `architecture` field fails as malformed for both txn and configure.

528 tests pass; `R CMD check` clean (only the expected New-submission / non-CRAN-deps WARNING). 5a is ready for review.

@TroyHernandez
TroyHernandez marked this pull request as ready for review August 18, 2026 20:30
@TroyHernandez
TroyHernandez merged commit 54eaed9 into master Aug 18, 2026
1 check passed
@TroyHernandez
TroyHernandez deleted the verify-predicates branch August 18, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant