This is developer documentation for the Rust code in controller/. It builds the command-line controller that ships as:
dist/PolicyWitness.app/Contents/MacOS/policy-witness
PolicyWitness is specimen-first. The launcher’s job is to drive the embedded runner service (PWRunner.xpc) and to print a stable, machine-readable JSON witness for each run. The runner is an unsandboxed XPC host with two short-lived children: pw-probe-runner applies the policy and attempts operations; sb_api_validator --batch queries that worker PID. Their observations are joined into one envelope. The controller treats that as an implementation detail of the runner — it only consumes the host's reply.
For the Swift runner implementation details, see runner/README.md.
Core controller modules:
controller/src/main.rs— entry point and module wiringcontroller/src/cli.rs— CLI usage text + top-level dispatchcontroller/src/run_flow.rs— run orchestration and JSON envelope assemblycontroller/src/runner_select.rs— runner selection + provenancecontroller/src/runner_client.rs— wrapper aroundpw-runner-clientcontroller/src/sandbox_log.rs— unified-log capture mapping for sandbox denialscontroller/src/log_capture.rs— bounded log subprocess reads, deadlines and owned cleanupcontroller/src/runner_commands.rs— external runner install/list/status/verify/remove/validate/reconcile
Support modules:
controller/src/app_layout.rs— app bundle layout + embedded tool resolutioncontroller/src/plist.rs— PlistBuddy helpers for Info.plist lookupscontroller/src/bundle.rs— bundle metadata reader for external runnerscontroller/src/request_patch.rs— request JSON injection helperscontroller/src/policy_check.rs— host-sidesbpl-checkwiringcontroller/src/utils.rs— shared time + output helperscontroller/src/evidence.rs— evidence manifest parsing + verificationcontroller/src/json_contract.rs— JSON envelope rendering with sorted keyscontroller/src/runner_manager.rs— external runner registry + launchd wiring
Standalone helper tools (embedded into the .app):
controller/src/bin/sandbox-log-observer.rs→dist/PolicyWitness.app/Contents/MacOS/sandbox-log-observer- Captures unified-log sandbox deny lines by PID + process name
controller/src/bin/sbpl-check.rs→dist/PolicyWitness.app/Contents/MacOS/sbpl-check- Compiles SBPL policies and reports compiler errors before the runner launches
controller/tools/sb_api_validator/sb_api_validator— embedded inside each XPC service bundle as…/Contents/MacOS/sb_api_validator. The runner host launches it once per run in--batchNDJSON mode to cross-checksandbox_checkverdicts inline alongside the C worker.controller/tools/pw_probe_runner/pw_probe_runner→ embedded INSIDE each XPC service bundle at…/Contents/XPCServices/<svc>.xpc/Contents/MacOS/pw-probe-runner(not in the app's top-levelContents/MacOS/). The runner host resolves it relative to its own bundle so built-in and BYOXPC runners both pick up the correct copy. It owns the post-apply syscall surface and is the runner's production code path.
The launcher intentionally exposes a minimal surface:
policy-witness run <request.json> [--timeout-ms <n>] [--log-timeout-ms <n>] [--no-log-capture] [--runner-mode <standard|byoxpc>]
policy-witness runner <command> [options]
policy-witness --version
--version (or version) prints a kind="version" envelope whose data.contract
holds the wire contract versions the build was made with. Every envelope carries
a top-level build object: version (nearest v* git tag), number (commit
count), describe (git describe --dirty) and commit. build.sh derives them
from git and stamps the same values into the app and XPC service Info.plists;
a plain cargo build reports unknown. The stamp says which code produced an
envelope; the contract numbers say how to read it.
run --log-timeout-ms <n> sets the optional log collection allowance (default
10,000 ms). It accepts positive integer milliseconds representable in the shared
monotonic clock, including deadline and cleanup-grace addition. Zero, invalid or
overflowing values fail before runner invocation, even with --no-log-capture.
There is no unlimited value. The runner's --timeout-ms is independent.
Runs a single runner evaluation against the selected runner service:
- Reads a request JSON file (runner request schema) that contains:
- a sandbox policy (
sbplsource), - and a probe plan (steps with
sandbox_check+ an attempted operation).
- a sandbox policy (
- Starts a fresh runner instance (one XPC host + two short-lived children), applies the policy exactly once inside the C worker, executes the probe plan and validator batch in parallel, and returns the runner's structured JSON result.
- Captures supporting evidence (best-effort) using
sandbox-log-observerand attaches it to the output. The requestedlog showinterval is the runner client's own start-to-end span, widened to whole seconds, with no fixed lookback. A backwards wall-clock reading prevents the scan. The interval does not guarantee that every denied attempt has a log record. Pass--no-log-captureto skip this scan entirely. Archive access has been observed to cost seconds even for short spans; that observation is not a fixed-cost guarantee. - The embedded
sb_api_validatorruns in--batchNDJSON mode (one process per run), spawned by the runner host alongside the C worker. It reads NDJSON probes from stdin and writes NDJSON verdicts to stdout; the host folds each verdict into the matchingrunner_result.steps[*].sandbox_checkblock and surfaces process metadata asrunner_result.validator_subprocess. Seetests/suites/validator_batch_mode/README.mdfor the wire contract. Failed launches retainrunner_result.validator_spawn_failurewith the native return code, executable path, operation and diagnostic, forwarded unchanged even for unfamiliar codes. This is host evidence, not a validator verdict; no subprocess is invented when launch fails. Validator coverage rules:- Predicted filter kinds (the validator calls
sandbox_check):path,global_name,local_name,none.none-filter probes callsandbox_check(pid, op, 0)with no filter argument and emitfilter_value: null/filter_type_id: 0in the verdict. - Skipped — prediction unavailable (verified-unreliable op+filter
pairs the runner accepts but deliberately does not predict for; see
docs/PolicyWitness.md"Filter kinds where prediction is unavailable"):(iokit-open-service, iokit_registry_entry_class),(iokit-open-user-client, iokit_user_client_class),(sysctl-read, sysctl_name). The runner short-circuits tosandbox_check.outcome="prediction_unavailable"(rc=-1); theattemptresult is the reliable evidence. - Rejected upstream: filter kinds the validator could in
principle author but the runner refuses to admit into the probe
plan (
MACH_PORT,PREFERENCE_DOMAIN, …).validateSandboxChecksrejects requests carrying these asbad_requestbefore any worker spawn; adding one to the supported set requires empirical verification that the userland predicate matches kernel enforcement (seetests/suites/witness_contract/harness/verify_filter_id.sh).
- Predicted filter kinds (the validator calls
- Prints a single JSON envelope to stdout (no output directories; stdout is the artifact).
- Emits
data.runner_provenanceanddata.app_provenanceto keep results auditable.
Exit codes:
0:result.ok=true1:result.ok=false2: usage / tool error (prints a JSON error envelope)
Current wire contracts: request schema 3, response schema 11, worker ABI 7, controller envelope 3. Each number is a separate contract. docs/contract.json owns all four, and generated copies carry them into code and documents.
Every step contains deny_signal: null because that channel is unobserved. Legacy signal objects remain readable by the Swift
decoder; external typed readers requiring an object must support null. The Rust
controller forwards the runner object without version coercion. Optional
subprocess objects may be omitted/null; per-step signal/errno/drift nulls require
key presence.
Each step carries an explicit comparison and submitted attempt provenance
(replies before schema 7 have neither); drift projects supported allow/success agreement to false. true requires
established query order plus state, identity and attribution evidence that the current response cannot express. Every current deny/success difference therefore retains null, including query_first rows. Older replies
preserve their original semantics.
A runner_reporting_failed result makes result.ok false and
forwards the host's reporting_failure diagnostic unchanged. Retained queries,
attempts and subprocess observations have no per-step comparisons; drift stays
null. evidence_retained: false explicitly identifies the minimal reply when
even evidence-preserving serialization failed.
See the comparison contract.
The controller prints one JSON envelope to stdout (kind="run"). It contains:
build: the build stamp described under the CLI surfacedata.runner_result: the runner's JSON (if parseable)data.runner_client: argv + stdout/stderr + timing, exact received/retained stream byte counts andcapture_limit_bytes(8 MiB).stdout_capture_erroridentifies controller prefix loss;stdout_parse_erroridentifies malformed untruncated JSON/UTF-8. Full output is collected first; this is not a streaming allocation bound. Synthetic non-invocations have null byte counts.data.policy_check: independentsbpl-checkreport, requested only onxpc_error. It describes that helper's compilation, not the missing worker's progress or the cause of a lost reply. Worker failures userunner_failed; worker operation/result evidence identifies compilation, setup and application independently. The controller retainsrunner_subprocess.worker_evidenceandpolicy_transfer_errorwithout interpreting their diagnostic codes.data.policy_augmentation: present only whenpolicy.augments(see docs/PolicyWitness.md → Augments) was non-empty. Records{ applied: [name, ...], original_sha256, applied_sha256 }so downstream readers can distinguish the caller's submitted source from the spliced source the runner actually compiled. Absent on every run that did not opt into augments.data.runner_startup_diagnostics: extra context when XPC startup fails (rare in practice — the unsandboxed host always replies unless launchd or codesign reject the bundle outright). Thisxpc_errorpath is the only one that triggers a host-sidesbpl-checkcompile (to populatepolicy_check_statusand disambiguate the failure).data.runner_sandbox_diagnostics: process disposition and optional denial correlation, independent of outcome labels.worker_pidcomes only fromrunner_subprocess.pid.process_dispositionisno_worker,unconfirmed,clean_exit,nonzero_exit,signaled,conflicting,withheldorunrecognized, projected from the worker disposition record the runner carries;termination_causenames witnessed host cleanup (host_sentinel_deadlineand its siblings), is null for a clean exit andunknownotherwise;stop_reason,disposition_integrityanddisposition_issuescarry the projected stop reason and the record's validation (legacy replies:unknownandnot_reported).capture_statusdistinguishes disabled, no worker and observer availability.correlation_statusisnot_attempted,unavailable,no_match, orpid_match.first_denyis an{event_index}reference intosandbox_log_capture.deny_events, not a termination cause.permission_failures_without_recordlists the step IDs whose attempt the runner classified as a permission-shaped failure and that no captured event names as a candidate; it is null unless correlation waspid_matchorno_matchand the reply carries per-step comparisons.no_matchbeside a non-empty list means the log holds no record of denials the attempts themselves reported, not that nothing was denied; the field never says why.data.sandbox_log_capture: optional observer evidence, also captured for successful runs; null when disabled or no authoritative worker PID exists.windowrecords the scanned interval: the runner client's start and end (started_at_unix_ms,ended_at_unix_ms) and the whole-second UTCstartandendstrings handed tolog show, which the observer mirrors back; a reply for any other interval iswindow_mismatch, notcaptured. If the client's end precedes its start, both strings are null, the raw milliseconds are retained, andinvalid_windowrecords that no observer was invoked. Ordered endpoints alone cannot establish clock continuity during the run. The window explicitly disclaims structured event timestamps, exact run membership, step ordering and PID-reuse protection.step_deniescontains event references with candidate step IDs: one candidate iscandidate, repeated matching attempts areambiguous. Matching requires worker PID, exact attempt-relevant operation and exact target/path evidence. Attempt kind/action come from the request joined by unique step ID, never the independent sandbox-check query.matching_evidencerecords each candidate's mapped operation, submitted kind/action, matched path and path sources. Unownednormalized_pathalone is not a match source. Unmatched events remain indeny_events. Validator queries can themselves generate denial records naming the worker PID before attempts begin. Neither a matching path nor a candidate association establishes that an attempted operation produced a log record. A complete requested interval does not guarantee complete log delivery. Operation mapping and correlation limits.data.runner_provenance: runner identity + entitlements metadatadata.app_provenance: embedded app evidence metadata (and optional verification)
data.sandbox_log_capture.capture_status values:
captured: both supervised captures completed, the reply shape and interval match, and parsing/correlation stayed within their budgets; this does not certify that every denial was loggedwindow_mismatch: observer returned different or missing bounds, or a trailing lookback; its raw reply and parsed denial events survive, butstep_deniesand diagnosticsfirst_denyare null and correlation isunavailableinvalid_window: the client's wall-clock end precedes its start; scan bounds are null and no observer runs. Raw timestamps and a diagnostic instderrsurvive; observer/events/associations are null and correlation isunavailableblocked: unified log access blocked (seeblocked_reason)error: observer returned an error or non-zero exitparse_error: observer stdout was not valid JSONtimeout: the shared collection deadline expiredoverflow: a stream, event, JSON-structure or candidate budget was exceededcapture_error: incomplete collection, decoding/read failure or unconfirmed cleanupinvalid_reply: parsed observer JSON fails the required observation, identity, metadata or supervision shaperequested_unavailable: observer could not be executed
Collection starts immediately before observer launch. The controller passes a
CLOCK_MONOTONIC deadline through the observer's internal --collection-budget
argument. Startup and log show consume this same allowance. Standalone observer
show mode defaults to 10,000 ms. A larger --log-timeout-ms changes waiting time
only; it changes neither the query interval nor byte limits and promises no
record. Cleanup has one 1,000 ms grace ending no later than the original deadline
plus that grace. This bounds supervised waits, not OS scheduling or arbitrary
work elsewhere in the controller.
The show path counts bytes while reading both pipes: inner stdout 1 MiB, inner
stderr 128 KiB, observer stdout 32 MiB, observer stderr 128 KiB. One additional
byte detects overflow but is not retained. The observer's bounded serializer
accounts for duplicated raw lines and JSON escaping. Event, JSON-structure and
candidate allocation guards bound derived data; the limits inventory
defines their counting rules and controls. These are stream and derived-data
bounds, not a promise about peak process memory. Standalone streaming/follow
mode has a separate contract and is not used by run.
The OS query requests supported Sandbox: process(pid) message tokens rather
than bare PID digits. Parsed worker PID is checked again before association.
The required archive control validates OS selection before parsing; parser and
argument tests alone do not prove query selection. Its fixture and reader
requirements are in the fixture README.
supervision under sandbox_log_capture reports the controller's observer
capture; observer.data.collection reports the observer's direct log-child
capture when a reply exists. Each includes the effective budget and source,
elapsed time, boundary, per-stream limit/read/retained counts, EOF/truncation/read
errors, process identity and wait observations, cutoff and cleanup facts.
processing_cutoff records controller parsing/correlation limits; its stream
identifies the bounded structure. Received byte counts describe actual reads,
not the total output a stopped producer might have emitted.
The controller spawns the observer into a dedicated group with PGID equal to its
PID. The log child inherits it. The controller observes leader exit without
reaping, signals the owned group, then performs bounded reaping and group probes.
It never sends a group signal after releasing the leader's ownership. Only an
ESRCH group probe establishes group_absent; signal delivery, pipe EOF and
leader exit alone do not. Lost ownership withholds signalling and leaves cleanup
unconfirmed. The observer separately reports its direct child's wait. If no
reply arrives, that child's identity and wait remain unknown even when the
controller confirms group absence.
Any failed or incomplete capture withholds all correlation: step_denies,
first_deny and permission_failures_without_record are null and
correlation_status is unavailable. An intact diagnostic reply and its events
survive failure. Incomplete JSON remains a bounded raw prefix, without fragment
repair or recovered events. Execution result, exit code, native observations and
disposition were completed before collection and remain unchanged.
Optional:
PW_VERIFY_EVIDENCE=1runs a manifest hash verification pass and includes adata.app_provenance.evidence_verifyreport in the output.
The execution channel is complete before optional log collection begins. For fixed runner reply and runner-client capture bytes, changing collector contents, availability or failure status cannot change execution evidence or the CLI exit status. A matching event corroborates a candidate; it never rewrites a comparison, drift, failure attribution or termination cause. Missing log evidence establishes neither allowance nor a sandbox cause for a permission-shaped failure.
Ownership is per field, including inside the shared diagnostics object:
| Wire fields | Owner and production writer | Inputs and readers |
|---|---|---|
result and the returned CLI exit status |
Execution: complete_execution in run_flow.rs; early admission/usage failures remain in cmd_run and cli.rs. |
Runner normalized_outcome and error, then runner-client capture/parse errors. cmd_run prints the completed result and returns its exit status; no log fields are inputs. |
data.runner_result, including predictions, native attempts, comparisons, drift and worker/validator observations |
Execution: runner_client.rs parses the runner reply; ExecutionData retains it unchanged. |
Log processing borrows the reply for worker identity and candidate matching. It has no mutable runner reference. |
data.runner_client, policy_check, runner_startup_diagnostics, provenance, request/runner metadata, augmentation and runner timeout |
Execution: runner-client capture, independent fallback compilation and cmd_run preparation. |
Client timestamps supply the log query window. Fallback compilation runs only for xpc_error; collector status does not request it or change its meaning. |
data.runner_sandbox_diagnostics.worker_pid, process_disposition, termination_cause, stop_reason, disposition_integrity, disposition_issues |
Execution: execution_diagnostics and project_disposition in run_flow.rs. |
Authoritative runner_subprocess.pid, carried disposition record, its raw supporting facts and reply steps/schema. No capture inputs. |
Entire data.sandbox_log_capture, including window, observer, observed_deny, deny_events, step_denies and transport diagnostics |
Logs: collect_log_evidence attaches capture or launch-failure diagnostics; sandbox_log.rs constructs the window and parses observer output; finish_sandbox_log_capture replaces candidate associations. |
sandbox-log-observer.rs supplies observer/query output and parsed events. Matching uses authoritative worker identity and submitted attempt operation/path evidence. step_denies references candidate step IDs; it is never written inside runner steps or comparisons. |
Diagnostics capture_status, correlation_status, first_deny, permission_failures_without_record |
Logs: log_diagnostics in run_flow.rs. |
Capture status, retained events, authoritative worker PID and candidate associations; the missing-record list also reads the runner's per-step comparison.observation. |
complete_execution constructs a CompletedExecution value before
attach_sandbox_logs invokes the collector. collect_log_evidence returns only
LogEvidence: the capture subtree and the four log-owned diagnostics. Separate
execution and log structs flatten into the existing JSON objects when attached;
neither the field paths nor their meanings change. Pre-run augment rejection
retains null capture and diagnostics without invoking collection.
Only a captured report with an event array and an authoritative worker PID can
reach pid_match or no_match. Failed reports may retain matching events as
diagnostics, but cannot supply step_denies, first_deny or
permission_failures_without_record. Disabled capture reports disabled /
not_attempted; absent worker identity reports no_worker / not_attempted;
unavailable capture reports its failure status / unavailable. A successful
empty event array remains captured / no_match. Without an authoritative PID,
even supplied matching events cannot gain associations.
permission_failures_without_record is null unless correlation reaches
pid_match or no_match and per-step comparisons are present. When available,
it lists exactly the permission-shaped steps without captured candidates, or
[] when none qualify. It makes no claim about what the OS log store contains,
why a record is absent, or whether a sandbox caused the attempted failure.
The permanent Rust control
collector_states_preserve_the_serialized_execution_half runs the production
completion/attachment/serialization path with eventful, empty and unrelated
successful captures, every supported collection failure status, and disabled
capture. It compares execution bytes after removing only the log-owned fields
and the envelope generation timestamp, checks CLI status and correlation, and
retains diagnostic events on failed captures. Current disposition, legacy
reply, missing-comparison and absent-worker cases are included. The window
replay control also sends serialized production output through the independent
Python consumer. These controls run in the default unit/rust.unit case.
The audit covers readers in this checkout, including test-only producers and their stored fixtures. Consumers outside this checkout were not audited.
| Reader or fixture | Use of the two channels |
|---|---|
consumer.py, recover_evidence |
Builds comparison/failure groups from runner steps. Copies capture, window, diagnostics and resolved candidate references into the separate denials answer; log fields never change step/failure groups. Shape/order validation reads runner evidence. |
| lifecycle_adapter.py, lifecycle_contract.py, lifecycle_oracle.py | The adapter selects only the execution diagnostic keys enumerated by DIAGNOSTICS_KEYS. Contract projections and oracle checks use worker records/raw facts and those execution projections. Constructed oracle envelopes supply disabled log fields; log evidence does not decide a lifecycle claim. |
| blackbox.py, validate_run.py | Validate runner shape and native steps. Legacy deny_signal assertions read runner step fields, not the optional log channel. |
| checker_controls.py | Constructs log captures to verify consumer retention, candidate provenance, distinct availability states and historical windows. Separately checks native comparisons and attribution limits. |
| disposition_controls.py, disposition fixtures | Validate execution projections, including rejection of a replaced termination cause. a1_expected.json and a1_known_loss.json carry disabled capture; neither supplies log evidence for the worker cause. |
| check_termination_correlation.py | Checks native denied writes and self-signal/clean-exit disposition independently; then checks capture state, window, candidates and missing-record diagnostics. |
| check_deny_capture_window.py | Reads client timestamps, requested/mirrored bounds, events, candidate references and missing-record diagnostics. Its live record-presence assertions affect test acceptance, not execution classification. |
| check_max_target_reply.py | Separately checks runner-reply retention and optional observer transport retention. No log-derived native outcome. |
| check_pre_apply_failure.py, check_attempt_in_flight.py | Read execution disposition/cause; pre-apply checks also require disabled capture and null first-deny evidence. Neither uses a deny event to assign worker termination. |
| Rust controls in run_flow.rs, sandbox_log.rs, runner_client.rs, and observer.py | Exercise projection, status gating, matching, transport retention and window propagation; the observer fixture supplies independently timed records. Production log-to-execution writes are excluded by the assembly boundary above. |
run can target specific runner modes by adding one of the following to the request:
runner: { mode, id, service, required_entitlements }(preferred)- Legacy top-level fields:
runner_id,runner_service,required_entitlements,runner_mode
If required_entitlements is present, the controller enforces a superset
check against the runner’s recorded entitlements before dispatch.
The only built-in mode is standard (default). If runner.mode is
present and an external runner is selected, it must equal byoxpc —
the only supported external runner kind.
These commands manage external runners signed with user entitlements:
policy-witness runner install --bundle <path-to-xpc-bundle> [--kind byoxpc] [--service-name <name>] [--scope user|system]
[--identity <codesign-id>] [--entitlements <plist>]
[--allow-adhoc]
[--env KEY=VALUE]
[--skip-bootstrap]
policy-witness runner list
policy-witness runner status --id <runner-id> | --service-name <name>
policy-witness runner verify --id <runner-id> | --service-name <name> [--timeout-ms <n>]
policy-witness runner remove --id <runner-id> | --service-name <name> [--skip-bootout]
policy-witness runner validate
policy-witness runner reconcile
Install saves a pending registry record before creating the launchd plist.
After bootstrap succeeds (or plist creation with --skip-bootstrap), it saves
installed. The install envelope includes data.state and a separate loaded
observation; installation state never substitutes for observed service presence.
Errors after the pending save identify its recovery record on stderr.
The registry lives under ~/Library/Application Support/PolicyWitness/runners.json;
PW_RUNNER_REGISTRY selects an alternate file.
Notes:
- BYOXPC is the only external runner kind. The bundle must be an XPC service
directory (
CFBundlePackageType=XPC!); the Mach service name equals the bundle'sCFBundleIdentifier. The executable is derived from<bundle>/Contents/MacOS/<CFBundleExecutable>. --entitlementsrequires either--identity <id>or--allow-adhoc. Without one of those the supplied entitlements would not be embedded into the binary, so the call is rejected up front.- A BYOXPC runner copied from the shipped
PWRunner.xpcinherits its signed-caller check (PWRunnerRequireSignedCaller): sign it with a Developer ID whose Team ID matches the caller (--identity), or remove those Info.plist keys for an ad-hoc/local runner. An ad-hoc runner that keeps the keys has no Team ID and is rejected at connect time (xpc_error). See docs/PolicyWitness.md → "Caller authentication and ad-hoc signing". runner verifydefaults to a 5-second timeout (override with--timeout-ms).runner removefirst atomically moves ownership intopending_cleanup. Launchd/plist failures appear indata.warnings;cleanup_retained: trueandretained_recordidentify recovery state. The record is retired only after service and plist absence are verified and retirement is saved.--skip-bootoutretains recovery while the service is present or unknown.runner status,runner verify, andrunner removeemit an envelope with the operation'skindandresult.normalized_outcome = "not_found"(exit code 2) when the lookup key is not in the registry, instead of plain-text stderr.runner validatere-reads each registry entry's on-disk signature and entitlements. It does not reconcile against launchctl orLaunchAgents/.
Schema 1 accepts additive fields: RunnerRecord.state defaults to installed
for older records, ownership is optional, and pending_cleanup defaults to an
empty collection. Ownership records keep absolute bundle/executable/plist paths,
launchd domain, installer UID and the expected plist hash. Cleanup records retain
that identity plus before/after observations, so recovery does not depend on test
output. Installation checks service, bundle and executable identities against
both collections; conflicts direct callers to runner remove --service-name.
Install, remove and validate hold an OS advisory lock beside the selected
registry across the read/modify/write sequence and launchd actions. Competing
modifiers fail with a registry-busy diagnostic. The stable .lock file remains
in place; never unlink it while held. Updates write a new temporary file and
atomically rename it, so unlocked list/status/verify/reconcile readers see a
complete old or new registry. Rust 1.89 or newer provides the file lock API.
List exposes both collections. Status and verify report pending installation
state; specimen selection rejects it with external runner is pending installation. Remove accepts either collection through its existing selectors,
including pending installations. It rechecks service executable and plist
ownership before acting. Unknown inspection results preserve recovery; a
permission error is never interpreted as service absence.
runner reconcile is report-only. It reports recorded state, observed service
and plist presence, and separate ownership classifications (owned, unowned,
ambiguous, or unknown when inspection fails). It scans user LaunchAgents and
readable system LaunchDaemons for com.policywitness.* labels or PWRunner
executables missing from both collections. A prefix identifies a reporting
candidate; it does not authorize cleanup. Reconcile creates no registry or lock
file and performs no machine changes.
The launcher does not speak NSXPC directly. It drives the Swift client helper embedded in the app bundle:
dist/PolicyWitness.app/Contents/MacOS/pw-runner-client
The Swift client is responsible for NSXPCConnection wiring; the Rust launcher owns run orchestration and evidence capture.
steps[].sandbox_check.pid is the spawned worker PID, or explicit null when no
worker exists. It never substitutes the host PID. Typed readers must accept
null; replies before schema 6 carry an integer PID and remain decodable. The
top-level legacy PID convention is unchanged. Request schema and worker ABI are
separate contracts.
Per-step native_rc is authoritative for native returns. A received diagnostic
without a native return retains result_source="validator", native_rc=null
and compatibility rc=-1; this is not a synthetic validator record or a claimed
native failure. Missing replies use synthetic rc=0, outcome="error" with a
missing reason. outcome="error" alone does not identify a native call failure.
See the query and receiver contract for immutable query planning, query association, independent pipe collection, and exact-byte controller capture semantics.