Status: accepted (2026-07-28) Relates to: #124, #135, ADR-0013 (which this corrects), ADR-0015 (the sibling mitigation)
A macOS guest under Virtualization.framework keeps serving stale file data from a virtiofs share after the host edits the file. Every share is affected — read-only and read-write alike.
A 105-arm experiment (2026-07-26, results in #124) established:
| Question | Answer |
|---|---|
Is it specific to :ro shares? |
No. claude-profile (:ro), claude-agents (rw) and the workspace (rw) went stale and fresh together, with no measurable difference |
| Is it a timeout? | No. One arm stayed stale 904.9 s with zero natural refreshes |
| What ends it? | A guest vnode reclaim. Every arm went fresh 10.3 s after one was forced (recycled 4,019,385 → 4,383,507) |
| Are metadata and data consistent? | No. A file 14 minutes stale reported the new size and mtime while read returned the old bytes, inode unchanged |
| Does atomic replace help? | No. temp + rename behaved identically to an in-place overwrite |
| What about deletions? | A phantom remains: readdir stops listing the entry while lstat/open still succeed |
The guest's vnode table is permanently saturated (kern.num_vnodes == kern.maxvnodes, measured
129,950 with an idle recycle rate near zero), so nothing evicts the stale vnode on its own.
It is the guest-side client, not the mechanism. A Linux container guest on the same host reading
the same directory over a mount that is also virtiofs sees current content live. There is no knob
to turn: VZSharedDirectory exposes readOnly and URL and nothing else, mount_virtiofs takes no
cache options, sysctl -a | grep -ic virtio is 0 in the guest, and Apple's documentation does not
mention caching at all.
At every point where augur attaches to a macOS guest, the host computes which shared files have
changed since the last sweep and the guest invalidates exactly those, using
msync(addr, len, MS_INVALIDATE) on a PROT_READ mapping of each file.
Implemented as refresh_macos_shares(), called immediately after sync_macos_guest_clock() at the
same four sites: cmd_up_macos's fresh path and its already-running reconcile branch,
cmd_claude_macos, and cmd_shell_macos.
Two further entry points were added after the first cut and this enumeration no longer describes the
whole of it: the host-side loop (§4) sweeps on its own every few seconds, and augur refresh --macos (cmd_refresh_macos) sweeps once on demand. Counting them, the sweep has six call sites —
four automatic and attach-driven, one automatic and unattended, one manual — and only the manual one
is exempt from the --share-refresh mode gate. See §4 for why.
Measured in a live guest: it drops the guest's cached pages for the file and the next ordinary
read() refetches from the host. Its properties are what make it usable here — no privilege, no
write permission (O_RDONLY + PROT_READ is enough, so :ro shares are reachable), the file is
not modified (ino/size/mtime unchanged), and it does not force a mass vnode reclaim (+7
recycled). It also works while another process holds the file open and mmapped, and — measured — the
holder's own existing mapping sees the fresh bytes afterwards.
POSIX specifies close to exactly this behaviour: MS_INVALIDATE shall invalidate cached copies "that
are inconsistent with the permanent storage locations" such that subsequent references obtain
consistent data. Apple's man page reduces it to four words ("Invalidate all cached data") and the
client is a closed kext, so the behaviour is intended rather than contracted — see §5.
Detection belongs where the truth is.
- A host-side
find -newerover this repo measures 0.31 s (12,626 files). The same walk from inside the guest costs roughly 15× that over virtiofs. - More decisively: the guest's own metadata is not reliably fresh either. A grown file was measured reporting its OLD size while its data was stale. A guest-side mtime scan would be deciding what is stale using values that are themselves stale.
That stale-size case is a trap with a silent failure mode. If the guest's cached size is the old,
smaller one when the call is made, mmap covers only that many bytes, only those pages are
invalidated, and the subsequent read is EOF-clamped — returning the first N bytes of the new
content. The file then looks valid and is quietly truncated, which is worse than being stale.
Measured: a 40,972-byte file grown to 262,156 read back as HEAD-B with an empty tail after one
msync, and matched the host's sha256 only after a second. The msync itself refreshes the size, so
the loop re-stats afterwards and repeats while the size keeps moving (_MACOS_SWEEP_TRIES, 3).
The cache is keyed by name, not by inode: msyncing a file through one name leaves a hardlink
alias to the same inode stale (measured). A symlink is fine, because it resolves to the same name.
So the sweep covers names, which is what find yields anyway — and an "optimisation" that deduped by
inode would silently reintroduce the defect.
The list crosses to the guest NUL-separated, and is accumulated in a file rather than a shell
variable because bash strings cannot hold NUL. Every path augur shares lives under
/Volumes/My Shared Files/, so any whitespace-separated protocol is broken by construction. This is
not hypothetical: the 105-arm experiment lost ten arms to exactly this, and the first draft of the
implementation shipped the bug until a test caught it.
| Measured | |
|---|---|
| Host-side detection | 0.31 s (find -newer, 12,626 files) |
| Host-side list accumulation | ~130 µs per changed file (2026-07-28 snapshot, item 37) |
| Guest-side invalidation | 0.23 ms per file |
| Full blind sweep, all five shares | 2.87 s for 12,600 files, zero failures |
| Realistic incremental sweep | ~10 ms for a few dozen files |
There is deliberately no cap on the changed set. A legitimate one can be large (a host-side build, a branch switch), and silently truncating the list would be the same class of failure the surrounding work exists to remove.
The second row was added after the fact and it corrects this section. The original four rows did
not measure the shell loop that builds the path list, which appends once per changed file and reopens
the list file each time — ~99 % of the host-side cost. Adding it to the guest's 0.23 ms, both paid
serially, one sweep costs ~0.36 ms per changed file end to end, so past roughly 14,000 changed
files a sweep outlasts the default 5 s interval and the loop below degrades into a rising duty cycle
on one host core for the lifetime of the VM. That is reachable with no adversary — npm install, a
large git checkout, a full build — and augur's own repo is 12,626 files. So "the worst case is
bounded at a few seconds" was true of the sweep and false of the loop, and the answer is the operator
dial in §4, not a cap.
How firm is 14,000, and why it does not contradict the blind-sweep row. It does not, but only
because the table has three independent measurements in it, not four: 2.87 s ÷ 12,600 = 0.228 ms, so
the "guest-side invalidation 0.23 ms per file" row is the blind-sweep row, divided. Whatever
host-side accumulation that one run paid is already inside both, and neither can be added to the
other. That leaves a band rather than a number — 5 s ÷ 0.23 ms ≈ 22,000 changed files if the
accumulation cost is already included, 5 s ÷ 0.36 ms ≈ 14,000 if it is additive — and 14,000 is the
conservative end, which is the one quoted in the code, --help and the README. The snapshot's own
figure of 30–40k is a third thing again: it is the host-side term alone, with the guest's share
excluded. Nothing here rests on the exact crossing; what it rests on is that a crossing exists at a
count no adversary is needed to reach, which all three readings agree on.
The refresh runs before the ensure_macos_* wiring, not after. ensure_macos_claude_profile
cps settings.json, CLAUDE.md and keybindings.json out of the profile share: a wiring pass
that runs first copies stale bytes into the guest, and that copy stays wrong until the next attach.
The tests assert this ordering on line numbers within each function, not by searching for a mention.
It runs after sync_macos_guest_clock for consistency across all four sites, and the two do not
interact: the sweep reads host mtimes only, so the guest clock is not an input to it.
On the fresh and reconcile paths it also precedes verify_macos_egress_locked. That costs a wasted
sweep on the rare path where the tripwire tears the VM down — tens of milliseconds — in exchange for
one identical rule at four call sites.
-
It is not coherence. The guarantee is "fresh within one refresh interval", not "fresh at the instant of read". A host edit is invisible to the guest until the next sweep picks it up. That turns "stale until a vnode reclaim, which may never come" into a bounded window — a large practical difference, and still not a coherent filesystem.
The attach-time sweep alone would not even buy that: it gives "fresh as of the last attach", which does not fix the symptom #124 actually reports — an edit made on the host while the agent is running. So a host-side loop re-runs the sweep every
AUGUR_MACOS_REFRESH_INTERVALseconds (default 5) for as long as the VM is up, started on bothuppaths and stopped bydownanddestroy. Its pidfile is keyed byworkspace_path_hash, like every other per-project host-side process, because a same-basename sibling sharing one pidfile is the collision class PR #127 removed. An idle tick costs one host-sidefindand zero SSH round trips, because a sweep that finds nothing never reaches the guest.The loop's exit condition is the guest's own liveness, checked before every tick, rather than trusting anyone to stop it: a crashed augur, a killed VM or a host reboot must not leave a process SSHing at a guest that no longer exists.
Because that cost scales with a count nobody bounds (§Cost), the three mechanisms are dialled at launch with
--share-refresh <continuous|attach|off>:continuous(default) is all three,attachkeeps the attach-time sweep and the tripwire and stops the loop,offstops all three — including the tripwire, because verifying a refresh that did not happen is a misreport, not a check.attachis the answer to "this repo is too big for a 5 s loop": the loop is the unattended, repeated cost, while the attach sweep runs once in front of an operator who can read what it says. Anything other thancontinuouswarns on every attaching command, names #124/#135, and says how to restore the default — a silently disabled freshness mechanism is the defect this series removed from the refresher's own warnings. Because it warns on all four of those commands, all four also stop a loop an earlierupleft running:cmd_claude_macos,cmd_shell_macosandcmd_setup_token_macosskipcmd_up_macosentirely against an already-running VM, so without a reconcile of their own they would print "the loop is off for this run" over a loop that kept sweeping — the misreport shape, not the cost, is what makes that unacceptable.--share-refresh-intervalsets the period (flag >AUGUR_MACOS_REFRESH_INTERVAL> 5), and both layers are validated as positive integers:0used to reachsleep 0and spin, and it is now refused rather than read as "off", because it would be a second spelling of a disable that leaves the sweep and the tripwire running. The env layer is checked only on the commands that can reachsleep(up/claude/shell/setup-token) — the same rule §5.3's teardown inventory depends on: a stale export in a shell profile must not be able to refuseaugur down --macos, which is the one command that stops the loop it is spinning.The dial left the two non-default modes half-built, and
augur refresh --macos(cmd_refresh_macos) is what finishes them. With the loop stopped, the only remaining ways to get a host-side edit into a running guest were to attach —up/claude/shell, every one of which puts the operator inside the guest as a side effect — or to wait, which §1 measured as not working. An operator who turns the loop off because one sweep outlasts the interval had no replacement and was pushed back tocontinuousor to reading stale data, so the dial's most useful setting was also its least usable one. The manual command sweeps once, against the VM that is already running, and reports normally (noquiet) because a human typed it and wants the count and the named failures.It deliberately does not boot a VM, unlike
claude/shell, whose job is to put the operator in the guest: a stopped guest holds no virtiofs cache to invalidate, and theupthat would start one sweeps on the way in anyway — so it refuses and names that command. It also pre-checks the SSH transport before sweeping, the #137 remedy in the shapeverify_macos_egress_lockedestablished:ssh_macosexits rather than returning when it cannot name a host, and although the sweep contains that exit (it happens inside a command substitution) it converts it into a warning under a success exit status — right for a bring-up, wrong for a command whose only job is the round trip, and invisible altogether when nothing changed and the guest is never contacted.And it runs under
off, which is the one part of this worth arguing about. The gate lives insiderefresh_macos_shares— one chokepoint for all five automatic callers rather than anifat each — so the manual path reaches the sweep through a second, ungated entry point,refresh_macos_shares_now, whichcmd_refresh_macosalone calls. That is not a hole in the gate; it is what allows the gate to be absolute for everything else.offmeans "do not refresh on your own", not "never refresh".Be exact about how much that bypass is carrying, because a first draft of this section was not.
_MACOS_REFRESH_MODEhas no env layer — only the interval does — and is re-derived from each command line, so an operator who launched the VM with--share-refresh offand later types a plainaugur refresh --macosis incontinuousfor that command and sweeps through the gated entry anyway. The ungated entry is therefore load-bearing for exactly one command line,augur refresh --macos --share-refresh off, and the argument for it is consistency rather than rescue: a flag that describes what the mechanism does on its own must not silently cancel the operator's explicit act, and a command that accepted the flag and then did nothing would be the worse of the two failures. It is explicitly not true that refusing would leave an operator who choseoffwith no remedy short ofdown/up; that argument was drafted and is wrong, for the reason above.A
forceargument on the gated function was rejected for a testability reason — a bypass must be visible in the call, so thatgrep refresh_macos_shares_nowis the complete list of code that skips the mode check, and a bypass never reads like the five ordinary call sites around it. tests/41 counts that name against a bare needle, notname ": an unquoted sixth caller (refresh_macos_shares_now $project_vm) is idiomatic-looking, is what shellcheck would catch if shellcheck could run here, and left the suite fully green when it was measured. Underoffthe command prints one run-scoped line saying the automatic refresh stays off, so a successful manual sweep cannot be read asoffhaving lapsed; it claims nothing about a live loop (status --macosmeasures that half) and, unlike the attaching commands, does not stop one — it makes no claim that a surviving loop would falsify, and killing a background refresher as a side effect ofrefreshis the same class of surprise as booting a VM would be.The manual command reports the outcome in its exit status; the automatic callers do not. This is the one behavioural difference between the two entry points beyond the mode gate, and it exists because the best-effort contract that is right for a bring-up is wrong for a command whose only job is the round trip. The sweep returns 0 on every path — a stale share is degraded, not uncontained, and a non-zero return would abort
up --macosbetween "SSH is up" and the egress tripwire, which is the failure shape the I1 series removed. Sorefresh_macos_shares_nowhands back what happened (_MACOS_SWEEP_BUSY,_MACOS_SWEEP_FAILED,_MACOS_SWEEP_NOWORK),refresh_macos_sharesmaps every one of them to 0 for its five automatic callers, andcmd_refresh_macosturns them into lines and a status:outcome augur refresh --macosautomatic callers swept, guest confirmed every file reports the count, exit 0 reports, continues nothing had changed "No shared file has changed…", exit 0 silent, continues another sweep held the lock "Another sweep is already in progress", exit 1 silent skip, continues round trip failed, or state dir unwritable the sweep's warning, exit 1 warns, continues guest reported msyncfail/nomapthe named files, exit 1 warns, continues guest reported only unstablethe named files, exit 0 warns, continues The lock row is the one worth spelling out. Skipping a tick you cannot lock is correct for the loop — the holder is scanning the same trees against the same marker, so the work is covered — and that reasoning does not survive the move to a one-shot command: the holder stamped its pending marker and ran its
findbefore the operator's edit, so that edit belongs to the next sweep, and underattach/offthis command is the next sweep. Reporting success there would be the #147/#151 misreport class in the very command written to remove it.unstableis excluded from the failing set on the same evidence-first grounds: a file whose size keeps moving across every retry is a live host-side writer, which is expected during a build and is not this mechanism failing.It polls rather than watching. An FSEvents watcher would be event-driven and strictly cheaper, but there is no FSEvents binding in the shell or in the stdlib python this uses, and
fswatchis not a dependency augur has — so it would mean a compiled component. That is the obvious next optimisation and is deliberately not in the first cut.The second sweeper is also what forces a lock. Two concurrent sweeps each stamp a pending marker, scan against the shared one, and promote — and the loser's promotion can carry a timestamp taken before the winner's scan, silently dropping every file changed in between.
mkdiris the atomic primitive; a skipped tick is correct because the holder is scanning the same trees against the same marker. A lock older than two minutes is stolen, so a crash cannot wedge the refresh permanently. -
Directory entries are out of reach. A directory cannot be
mmaped at all (EINVAL — also true on local APFS, so it is a POSIX property, not a virtiofs quirk). The measured residue is thatstatsucceeds on a host-deleted path and reportsnlink=0, which is the direct cause of augur's own[ -d "$src/$d" ]checks and dangling-link removal never firing. Creations and deletions did propagate promptly tolsandopenin the 2026-07-28 run. -
The platform bug is untouched. This is a mitigation. The only route to a real fix is Apple.
This mitigation exists because of a defect in a closed guest-side component. It must be cheap to remove, and its removal must be detectable rather than guessed at.
- No invariant asserts freshness.
INVARIANTS.mdis deliberately untouched by this change. If a later change wants to depend on "the guest sees the host's current content", that is a contract change and needs the dated-snapshot ceremony — precisely so that this mitigation cannot quietly become load-bearing for something else. Nothing outside the list in §5.3 reads its state. - The self-test carries two controls, because one cannot tell the two futures apart:
- content does not match after msync → the mitigation is broken on this guest OS (warn);
- content already matches before any msync → the mitigation is no longer needed, i.e. the
platform has been fixed.
up --macosprints "Shared files were already current before the refresh — this guest OS may no longer need it (ADR-0016 §5)".
- One such observation is a signal, not a proof. Confirm it across a few
ups and, if possible, more than one guest image before acting: a single run can be fresh by luck if nothing was cached. - Re-verify whenever the guest base image is rebuilt from a new IPSW.
augur update --macosdoes not change the guest OS; a base rebuild does. That is the moment the behaviour this rests on can change in either direction.
The mitigation is inert when it is not needed — an unnecessary msync costs 0.23 ms and changes
nothing — so there is no pressure to rip it out the day the signal appears. Prefer:
- Stop the loop first.
augur up --macos --share-refresh attachdoes exactly this: the loop stops (and a loop left running by an earlierupis stopped too, onclaude/shellas well as onup), while the attach-time sweep and the tripwire stay. Note that the mode is run-scoped: it has to be passed on each attaching command, so the observation window is the runs you pass it on, not "until further notice". Run normally for a while; if nothing goes stale between attaches, the platform really is fixed. This is the cheapest and most reversible step. Do not use--share-refresh offfor it — that also stops the tripwire, which is the instrument this observation depends on. - Keep the tripwire longest. It is three SSH round trips on
upand it is the only thing that would notice a regression in a later macOS. Delete it last, or keep it permanently as a cheap canary. - Then delete the mechanism per §5.3.
Not "one function". As shipped, in augur:
| Sweep | refresh_macos_shares (the mode gate, which also maps the outcome statuses back to 0) and refresh_macos_shares_now (the ungated entry), _refresh_macos_shares_locked, _macos_msync_program, macos_share_roots, macos_share_sweep_marker, _MACOS_SWEEP_TRIES, _MACOS_SWEEP_BUSY/_MACOS_SWEEP_FAILED/_MACOS_SWEEP_NOWORK |
| Tripwire | verify_macos_share_freshness, _MACOS_FRESHNESS_PROBE |
| Loop | start_share_refresher, stop_share_refresher, share_refresher_running, share_refresher_pidfile, share_refresher_logfile, _MACOS_REFRESH_INTERVAL (and its AUGUR_MACOS_REFRESH_INTERVAL override) |
| Dial | _MACOS_REFRESH_MODE, share_refresh_enabled, share_refresh_loop_enabled, macos_refresh_interval_valid, validate_macos_refresh_interval, warn_macos_refresh_mode, the --share-refresh / --share-refresh-interval arms of the global flag loop and the $MACOS_MODE block below it, the share_refresh_loop_enabled || stop_share_refresher line in each of cmd_claude_macos / cmd_shell_macos / cmd_setup_token_macos, and the two Share refresh: lines in cmd_status_macos |
| Manual command | cmd_refresh_macos (including the require_safe_workspace call it makes itself — it is the one command guarded from inside rather than from the shared dispatch tail, and the gate's comment records the exception), its refresh) arm in the macOS dispatch and the macOS-only refusal arm of the same name in the container dispatch (which cites §1: a Linux guest over the same kind of mount sees host edits live), its augur refresh --macos line in cmd_help's macOS command list and the bullet in that function's NOTES |
Call sites: refresh_macos_shares ×4 (cmd_up_macos fresh + reconcile, cmd_claude_macos,
cmd_shell_macos) plus once inside the loop, and refresh_macos_shares_now ×2 — once from the gate
itself, once from cmd_refresh_macos, which is the only caller that may skip the mode check;
verify_macos_share_freshness ×2 and
start_share_refresher ×2 (both up paths); stop_share_refresher ×2 (cmd_down_macos,
cmd_destroy_macos) plus once inside start_share_refresher itself and once in each of
cmd_claude_macos / cmd_shell_macos / cmd_setup_token_macos — those three attach to an
already-running VM without going through cmd_up_macos, so the mode reconcile has to be repeated
there or it never happens on the commands an operator uses most. The reaping block in
cmd_destroy_macos goes too.
Host state to stop creating — and to clean up once from existing installs, since nothing will remove
it afterwards: $AUGUR_DIR/vm-state/<vm>.shares-swept (plus .pending and the .lock directory),
$AUGUR_PROXY_DIR/<slug>-<hash>-refresher.{pid,log}. Guest state: the
.augur-freshness-probe dotfile in each per-VM claude-agents share.
Tests: delete tests/41_macos_share_refresh.sh, tests/42_macos_share_freshness_selftest.sh,
tests/43_macos_share_refresher.sh; drop the start_share_refresher/stop_share_refresher stubs
from tests/34, tests/36 and tests/38 (left in place they are harmless, but they would stub
functions that no longer exist); and remove the "Shared-file refresh" section from
tests/e2e_macos_vm.sh.
Docs: this ADR is retired (status → superseded, with the observation that retired it), its index row
in docs/decisions/README.md goes, and the macOS caveat in README.md's operator-profile section is
rewritten — not deleted. A reader on an older guest image still hits the defect.
Two changes rode along in the same series and are independent of whether Apple ever fixes this:
- The documentation corrections.
README.mdand ADR-0013 used to attribute the staleness to read-only sharing and to claim it resolved "before the 10-minute mark". Both were measured false. That is historical fact about the platform, not a consequence of the mitigation. - The removal of the
gh-configshare from macOS mode. That share was mounted and never wired to~/.config/gh, so it carried the host's realconfig.ymlandhosts.ymlinto a guest that never read them. It is exposure without a feature and stays removed regardless. If it is ever wired properly it must reappear in both the--dir=argv andmacos_share_roots.
Measured on macOS 26.5.2 (build 25F84, xnu-12377.121.10 RELEASE_ARM64_VMAPPLE), guest page size
16384. Everything above rests on that one guest.
Measured on macOS 26.5.2 (build 25F84, xnu-12377.121.10 RELEASE_ARM64_VMAPPLE), guest page size
16384. Everything above rests on that one guest.
Forced vnode reclaim. Measured to work, and it fixes directory entries too, which msync cannot. Rejected as the primary mechanism: it cannot be aimed. There is no syscall to request it — you flood the cache until the kernel evicts, and the guaranteed sweep needed +364,122 recycles against a capacity of 129,950 (~2.8 full turnovers), which discards every other process's cached lookups. It also offers no confirmation that the file you cared about was actually evicted. Retained as a possible operator-facing escape hatch, not as the mechanism.
Sync the files instead of sharing them (Mutagen or similar, over SSH or vsock). Investigated in
depth. The failure asymmetry decides it: if this mitigation stops running, the guest is back to
today's behaviour; if a syncer stops running, the guest reads local files that are silently and
permanently stale, with no error. Beyond that, Mutagen forbids a symlink as a sync root and three of
augur's guest paths are symlinks today; conflicts leave no on-disk marker; .gitignore is not
honoured; and :ro would stop being kernel-enforced and become a per-session flag. Recorded here so
the option is not re-litigated from scratch.
Push content over SSH instead of sharing it. Not rejected — it is better than this mitigation where it applies, because it removes the dependency instead of managing it: no share means no cache to invalidate. It does not apply to the read-write workspace. Tracked separately for the small read-only shares.
A config file for the refresh mode, instead of the --share-refresh flag. Both candidate layers
were considered and rejected, and this is recorded because it will be proposed again. The second of
the two has since been adopted — see §6.1 — and the first has not.
./.augur/allowlist.conf— the project layer — lives inside the mounted read-write workspace, so the guest can write it. A mode read from there would let a compromised guest, or a prompt-injected agent, switch off the mechanism that keeps the operator's view of the guest's own filesystem honest. It composes badly with the 2026-07-28 snapshot's item 39(a), where the sweep's round trip invokes an unpinnedpython3: the guest could fabricate the refresh report and disable the tripwire that would notice. The resources file is guest-writable on purpose, but that asymmetry is deliberate — inflating your own VM is a self-serve request the operator sees applied, whereas silencing a freshness mitigation is invisible by construction.- A host-side per-project file keyed by
workspace_path_hash(the pattern every other piece of per-project host state already uses) has no such hole. It was rejected on cost and on fit: it needs a wholeaugur configsurface before an operator can use it, and the right value depends on the host's CPU as much as on the repo's file count, so a value committed with the project is wrong on the next machine. (Both objections are answered in §6.1; this bullet is kept as written so the reversal can be read against what it reversed.)
A launch-time flag has neither problem — the value comes from the operator, in the clear, per run —
and it is where this repo already puts run-scoped options (--egress, --no-egress, --gui).
AUGUR_MACOS_REFRESH_INTERVAL keeps its env peer because it predates the flag and only tunes a
period; no AUGUR_MACOS_REFRESH peer was added for the mode, because the one place an env var is set
persistently is a shell profile, i.e. per host and across every project, which is the one scope
at which "this repo is too big for a 5 s loop" is never the right answer.
Waiting. The previous record advised down --macos && up --macos and described the delay as
resolving "before the 10-minute mark". There is no timeout; waiting is the one thing that does not
work. Corrected in ADR-0013 and README.md.
The second bullet above was rejected on cost ("needs a whole augur config surface") and on
fit ("a value committed with the project is wrong on the next machine"). The cost has been paid:
augur config exists. The fit objection was answered by the same fact that removes the security
hole — the file is host-side, so it never travels with the project, and a value set on one machine
is a value set on that machine only. Neither objection ever applied to the location.
~/.augur/project-settings/<slug>-<workspace_path_hash>.conf
# augur project settings — written by `augur config`. Host-side; never read from the workspace.
# workspace: /Users/hidemune/GitHub/big-monorepo
share_refresh=attach
share_refresh_interval=30
Line 2 is not decoration. The filename carries a 12-character hash, which identifies nothing to a
human; ~/.augur/project-hashes/ already has that problem, and this comment is the only place the
hash → path mapping exists.
The first bullet stands, unchanged, and this is the paragraph to read before proposing ./.augur/
again. It is the obvious guess — every other per-project knob in augur lives there — and it is
wrong for one reason that has nothing to do with convenience: ./.augur/ is inside $WORKSPACE_DIR,
which is mounted read-write into the guest, so the guest can write it. A share_refresh read from
there lets a compromised guest, or a prompt-injected agent, switch off the mechanism that keeps the
operator's view of the guest's own filesystem honest — and it composes with the 2026-07-28
snapshot's item 39(a), where the sweep's round trip invokes an unpinned python3 in the guest: the
guest could fabricate the refresh report and disable the tripwire that would notice the report was
fiction. .augur/resources.conf being guest-writable is not a precedent for it: inflating your own VM
is a self-serve request the operator sees applied on the next up, whereas silencing a freshness
mitigation is invisible by construction, which is the whole reason the mode warns on every run (#148).
That ~/.augur is out of the guest's reach is a property of the code and was verified against it
rather than assumed: it appears in none of cmd_up_macos's four --dir= shares and none of
cmd_up's six -v mounts, and require_safe_workspace refuses any $WORKSPACE_DIR that is,
contains, or lives inside it. tests/44_macos_project_settings.sh re-derives the mount argv and
tests containment, so a fifth share cannot land silently.
Precedence: --flag > settings file > AUGUR_MACOS_REFRESH_INTERVAL > built-in default. The
env-vs-file half is a real decision, and it inverts the only existing precedent in the codebase:
resolve_macos_vm_cpu puts AUGUR_MACOS_VM_CPU above .augur/resources.conf's MACOS_CPU. That
ordering is a trust ordering, stated at clamp_resource_int — the resources file is
guest-writable and the export is the operator's, so the trusted layer must win. This file is
host-side and written only by an augur command, so it is exactly as trusted as the export, and what
is left to order on is scope:
- the export's one persistent home is a shell profile, i.e. per host and across every project. §6 above already gives that as the reason no env peer was added for the mode: it is "the one scope at which 'this repo is too big for a 5 s loop' is never the right answer". A layer that reasoning rejects as wrongly scoped for the mode cannot outrank a per-project file for the period either.
- the file is per project and deliberate: it exists only because someone ran
augur configin that directory, and it records that directory's path. - the concrete case is the one §2's dispatch-tail gate already complains about — a stale
AUGUR_MACOS_REFRESH_INTERVALin a profile. Under env > file it would silently override the value set for the one repo that needed it, in every shell the operator owns.
Neither direction is silent: augur config --show names the winning layer for both keys, and
status --macos names it for the mode.
Two traps had to be closed before any of this could work, and both were invisible with only two
layers. _MACOS_REFRESH_MODE=continuous was a plain assignment, so --share-refresh continuous and
an empty command line were indistinguishable — a file layer would have overruled the explicit flag
for exactly the value used to escape a persisted off. And
_MACOS_REFRESH_INTERVAL="${AUGUR_MACOS_REFRESH_INTERVAL:-5}" collapsed the env layer into the
default, which additionally made the validator's AUGUR_MACOS_REFRESH_INTERVAL attribution right only
by accident: with a file layer above it, a bad file value would have sent the operator to unset an
export that was not the problem. Both are closed by a per-setting provenance variable, which the
--show output, the status line, the launch warning's remedy and the validator's blame all read
from — so no two of them can name different layers.
A broken file warns; it does not refuse, and it does not fall back silently. Silence is #148 with
a different cause: someone who wrote a setting and sees no effect concludes the feature is broken. A
refusal is worse than it is for a bad export, in the one direction that matters — unset fixes an
export, while this file is deliberately not reaped by destroy --macos, so a refusal would be
permanent and would take down --macos with it. Rejected values are therefore dropped in favour of
the next layer down, named, and paired with the augur config line that clears them. The hard
refusal keeps its existing up|claude|shell|setup-token gate untouched.
destroy --macos does not delete it. Everything that command reaps is VM state — the clone, its
pinned host key, the profile and sweep markers, a lock, the refresher logfile — and several are
actively harmful if they outlive the clone. This file is operator intent about the project, recorded
because the project's changed-file count made the default loop too expensive, which a re-clone does
not change. Reaping it would also undo the operator's configuration on the exact command line these
warnings recommend (destroy --macos && up --macos). augur config --unset is the only thing that
removes it.
augur refresh --macos changes size, and the paragraph in §5 that sized it is now wrong. That
reasoning was: the mode has no env layer and is re-derived per run, so an operator who launched
with off and later types a bare augur refresh --macos is back in continuous for that command
and would sweep through the gated entry point anyway — leaving the ungated entry load-bearing for
exactly one command line, the one that repeats the flag. A persisted mode removes the "per run"
premise the whole argument rested on. share_refresh=off written once is resolved by the dispatch
tail for every macOS command in that project, so a bare augur refresh --macos is in off too,
share_refresh_enabled is false, and the gated entry sweeps nothing. The ungated entry is therefore
load-bearing for every manual refresh in a configured project — the ordinary use of the command,
not an edge case — and it is what keeps augur config --share-refresh off a setting rather than a
one-way door. Its warning names the layer for the same reason the launch warning does: "off for this
run" describes a flag, and a mode out of the file was never on the command line. This is the one
command an operator reaches while already asking what is off, and where did that come from, so it
answers both, and points at augur config --unset share_refresh.