"Unprovable is not clean" and "diagnostics never undo a commit" were the two rules every repeated defect violated, each on a different boundary. They belonged in the principles the rest of the document is written against, not only in the code comments at the sites that enforce them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
Zapret2 2.2.0 — Architecture Redesign
Status: implemented in 2.2.0. This document records the analysis findings that motivated the redesign, the design principles adopted, and the concrete changes.
1. Why
Eleven independent code-analysis passes over 2.1.5 (module shell layer, Android app, state files, tests) converged on the same diagnosis: the architecture had accreted layers that re-prove facts already proven, and kept machinery for data formats nothing produces anymore.
Key numbers from the analysis (2.1.5 baseline):
| Finding | Measured |
|---|---|
common.sh size |
3802 lines, 164 functions |
Legacy direct-rule migration (legacy_*) |
~665 lines, executed unconditionally on every start and stop |
| Track-journal WAL validator/reaper | ~324 lines with zero producers in production code |
firewall-teardown.wal reader |
reader/deleter only — no writer anywhere |
| Dead function cluster | 5 functions / 82 lines |
| Boot: lifecycle lock acquire/release cycles | 2 (recovery pass + start pass) |
Boot: getprop sys.boot_completed busy-wait |
1 fork/second, typically 10–40 forks |
| Warm start: compiled-artifact full parses | 5–6 per start |
Warm start: sha256sum invocations |
~7 (preset ×3, runtime.ini ×3, artifact ×1) |
Warm start: install-generation.meta reads |
up to 4 (4 stat forks + wc -c each) |
Stop: restore_status_facts calls |
3 reads of the same status file |
| Stop: owner-state reads | ~6 × ≈7 forks ≈ 42 forks for one 35-line file |
Stop/start: full /proc scans |
2–3 per operation |
Full rollback: global sync calls |
8 — violating the contract documented in common.sh itself |
Purge: recovery-artifact audits in uninstall.sh |
5 |
| Preset change from the app | 11 root round-trips, 6 full runtime.ini parses by 4 parser implementations |
Two correctness defects were also found:
- Crash residue blocks uninstall. Ephemeral files (
firewall-batch.*,z2-ports.*, …) written directly into$STATE_DIRare not covered byenumerate_recovery_artifactsnor the uninstall cleanup loop; after a crash they trip the "unknown child" guard and block uninstall instead of being swept. - Purge ordering.
zapret-purge.shpublished theremovemarker after runninguninstall.sh, forcing the long uninstall branch and leaving the destructiverm -rffenced by nothing.
2. Principles
P1. A fact is proven once per transaction. A transaction is one lock cycle. Within it, each state file is read once and cached in shell globals; verification of an invariant happens at exactly one layer. Postconditions proven by a callee are not re-proven by the caller.
P2. No consumers for formats nothing produces. Code that only validates, classifies, or retires a file format with no live writer is deleted, not hardened.
P3. Reboot is the migration barrier. Magisk/KernelSU/APatch activate updated module files only after a reboot, and iptables rules do not survive a reboot. Therefore the running module never coexists with firewall state from an older module version, and no runtime migration machinery is needed.
P4. Ephemeral state is disposable by construction. Scratch files live in
$STATE_DIR/tmp/; staging files (<target>.tmp.<pid>) live beside their
target. Neither is ever authoritative, so recovery never has to reason
about their contents: boot recovery and uninstall drop the scratch
directory wholesale, and every lifecycle audit retires entries whose
creating process is gone — by liveness rather than wholesale, because a
preset preview from the app runs without the lifecycle lock.
P5. Root-manager canon. customize.sh stages and validates; service.sh
waits for boot and delegates; action.sh performs the user action;
uninstall.sh cleans owned state. Destructive removal is fenced by the
remove marker published before teardown begins.
P6. Unprovable is not clean, all the way to the user. A firewall family
whose frontend exists but cannot answer is never reported as absent. Some
devices ship without ip6table_mangle, so this condition repeats every
boot: refusing outright would fence stop, uninstall and purge forever, and
a reboot would refuse the same way. The family is skipped instead, and the
uncertainty travels with the result rather than being resolved locally —
cleanup_owned_firewall raises it, the committed receipt withholds its
verification claim instead of contradicting it, the status projection
preserves that withheld claim rather than restoring it on the next read,
the purge receipt downgrades itself to partial, and the app names the
reservation beside the result. Every place that turned "we could not look"
back into "it is clean" was a defect, and each one sat on a different
boundary — which is why the decision is made once, by
resolve_ipv6_ownership_expectation and one probe budget spent where the
baseline is captured, and never re-derived downstream.
P7. Diagnostics never undo a completed operation. Once an effect is durable, a failure to record it may be reported but must not roll it back or fence the next operation. A rollback that cannot write its receipt still retires its journal; a purge that cannot remove a staging workspace still reports the module directory and private state it did remove, measured rather than assumed, because the same receipt is what the app reads to decide what the device still holds.
3. Changes in 2.2.0 (module)
- Removed the legacy direct-rule migration (
legacy_*family, its snapshot transaction, markers, and the calls in start/stop/full-rollback). Justified by P3.uninstall.shstill deletes the old marker file if present. - Removed the track-journal apparatus (grammar validator, liveness
classifiers, reapers) and the
firewall-teardown.walreader — P2. The firewall layer is WAL-free by design (firewall-reconciler.shheader); interrupted publications are recovered by re-running cleanup under the lock. - Removed the dead cluster:
republish_owner_ipv6_inactive,owner_load_generation_fields,owner_loaded_generation_for_write,trim_config_value,retire_installer_ephemeral_track_journals. - Ephemeral workspace (
$STATE_DIR/tmp/, 0700 root): the PID-suffixed scratch files (firewall-batch.*,firewall-cleanup.*,firewall-restore.*.error,preset-preflight.*,preset-preview.*, the dry-run capture) moved there; the port union no longer uses a file at all. Uninstall removes the directory and any staging residue outright, so a crashed operation can no longer fence it as an unknown child. Fixes defect 1. - Single boot pass: when autostart is enabled,
service.shno longer runs its own lock+audit recovery cycle —zapret-start.shperforms the same audit under its own lock moments later. The standalone pass runs when the module is disabled, when autostart is off, and as the retry path when a start is refused by recovery state (the only case where discarding an unsafe state generation wholesale is still allowed, and it happens before the start has mutated anything). Boot wait blocks onresetprop -wwhen available instead of a 1 Hzgetpropfork loop. - Read once per transaction:
read_install_generation_metaparses the installer record once per process while still re-checking the path identity on every call, and the duplicated status/owner reads on the stop path were collapsed (P1). - Boot-bound status snapshot: the committed snapshot records the boot it describes, and a reader rejects any other — leaving no facts behind. A snapshot describes processes and netfilter objects that a reboot destroys, so one from an earlier boot is not stale data to reconcile.
- Unprovable is not clean: a firewall family whose frontend exists but cannot be queried is never treated as absent. Teardown skips it only when an authenticated owner record — or a snapshot committed with a verified ruleset — proves this generation published nothing there.
- Warm-start dedup: one compiled-artifact binding check at entry plus one
TOCTOU re-check immediately before daemon launch (was 3); the artifact is
parsed twice instead of five or six times, because the compiler publishes
the metadata it just wrote and the launcher trusts the proof from the
binding check;
prepare_private_runtime_fileruns once per file; pidfile wait polls at 100 ms. (nfqws2.cmdlineis still produced: the shell layer never reads it back, but the Android app renders it on the logs screen.) - Stop dedup: status facts restored once instead of three times, the owner
record read once for both the queue number and the ownership decision,
/procscanned once, the per-family firewall-absence postcondition ofz2_fw_apply_cleanuptrusted instead of re-proven, and the unusedruntime.iniparse plus the deadaudit_owned_firewall_for_cleanupargument gone. - Purge/uninstall:
zapret-purge.shpublishes theremovemarker before invokinguninstall.sh— the fence commits the twice-confirmed purge, blocks new starts throughout the destruction (a start already holding the lifecycle lock is serialized against by the lock the removal branch now takes), and routes uninstall onto the short manager-remove branch (so the tombstone long path with its repeated audits no longer runs during a purge); the duplicate status-snapshot deletion is removed andpm clearfailure after a successful purge is a warning, not a failure exit. Fixes defect 2. - Full rollback: the duplicated firewall audit collapsed and the retired
legacy phase became a pure journal advance kept for resume compatibility.
The
syncbarriers were kept: they implement the write-ahead ordering of the hosts-preservation transaction, which thecommon.shdurability contract explicitly assigns to the dedicated mutation scripts (the analysis finding that they violated the contract was a misreading). customize.sh: one validation loop and one filesystem traversal for permission normalization (was two loops and three traversals).
4. Changes in 2.2.0 (app)
- Main-thread root I/O fixed: environment reconciliation now runs on
Dispatchers.IO; previously a cold start could block the main thread on the root transport for up to 30 s. - Status protocol negotiation is cached: the controller remembers the highest protocol version the module answered with instead of re-walking v6→v1 on every refresh.
4a. Known limitation: one ambiguous full-rollback receipt
A rollback that finishes while unable to re-read the IPv6 ruleset reports
Z2_RB_STATUS=partial with Z2_RB_FIREWALL_CLEAN=0 and every other field
affirmative. A rollback interrupted by a signal inside the final commit window
on the same device reports the identical tuple, so the app cannot tell them
apart: the first retired its journal, the second did not.
Distinguishing them needs an eleventh field, and the ten-field receipt is
exactly what a 2.1.x app parses — an extra line makes it reject the whole
payload. The ambiguity is bounded: in both cases the rollback's effect has
already happened (module disabled, autostart off, hosts preserved), and a
surviving journal surfaces on the very next lifecycle observation as
RECOVERY_BLOCKED, which names the remedy. The field is worth adding the
next time the receipt version is raised for other reasons.
4b. Older defects found during review and left for a later change
These predate the redesign and were confirmed but not changed here, because each alters a fence or an on-disk format and the redesign was already large:
- The uninstall tombstone is never retired.
uninstall.shinvoked directly (without the root manager'sremovemarker) succeeds and leavesuninstall.tombstone, whichuninstall_tombstone_allows_startrefuses on sight — so a module that stays installed can never start again. Removal through the root manager is unaffected: it deletes the whole state tree. The user-facing remedy is documented inUSER_OPERATIONS_RU.md. - The compiled argv is not bound to the install generation. The binding
covers the preset name, the preset digest and the
runtime.inidigest, so a module update whose preset and runtime are byte-identical reuses the argv compiled by the previous release. The validation receipt is generation-bound, so the artifact is re-validated by a fresh dry-run — but it is not recompiled.
5. Deferred (designed, not shipped in 2.2.0)
- Single-endpoint preset application. The app currently performs the
preset transaction step-by-step (11 root round-trips, duplicate parsers on
both sides of the boundary). The target design is one module endpoint
(
apply-preset <name>) returning a typed receipt, with the app reduced to a pure client. This moves the Kotlin/shell boundary and is scheduled separately to keep 2.2.0 reviewable. - App decomposition: splitting
ServiceLifecycleController/UpdateManager/ControlViewModel, DI for the privileged layer, moving long-running installs to WorkManager, replacing source-grep policy tests with lint rules and behavioral tests.
6. Compatibility
- Update path from 2.1.x:
owner.meta(v8),runtime.ini, presets, hostlists andinstall-generation.metaare unchanged. The status snapshot gained aboot_idkey and a 2.1.x snapshot is therefore rejected — which is the correct outcome, since activating a new module package requires a reboot and every snapshot predating it describes processes and rules that no longer exist. Obsolete files from older versions (legacy-direct-rules.migrated,firewall-teardown.wal, track journals, legacy rollback snapshots) are deleted on sight by boot recovery and uninstall. - Status wire protocols (v1–v6), the error envelope, and all
Z2_*machine outputs are unchanged; the Android app from 2.1.x keeps working against a 2.2.0 module and vice versa.