Provisioning files into a run
What --provision accepts, what travels, what the record says afterwards, and when a citation refuses.
What --provision accepts, and what the record says afterwards
--provision <source>=<workdir-relative-dest> is repeatable, and <source> may be either
a file or a directory. Whichever it is, the bytes leave this host in exactly one
way: a signed provisioning pack, streamed to the execution host and unpacked into the
workdir there. There is no second transport and nothing about your invocation selects one —
in particular it no longer matters whether the far worktree already existed or was created
by the same dispatch, which used to decide it silently and gave the two cases different
size limits.
A directory is walked recursively in name order and every regular file inside it lands
under <dest>, nested paths included.
Modes. One bit travels: the owner-execute bit. An executable source arrives 0755, a
non-executable one arrives 0644. Everything else is normalised, on purpose — group and
other bits are an artefact of this host's umask and are not a policy for the leg's host,
and setuid/setgid/sticky are dropped unconditionally, because provisioning writes as the
far user and minting a privileged file on another machine is not a provisioning act. If you
need a specific mode beyond "executable or not", set it in the leg's own work, not here.
⚠ Normalisation can WIDEN, not only narrow. A 0600 source arrives 0644: it is not
executable, so it lands at the non-executable mode, which is readable by group and other on
the far host. If a file's restrictive mode is what protects it, --provision is the wrong
carrier for it — scp it and set the mode yourself on the far side.
Symlinks refuse. A symlink named as a <source>, or found inside a provisioned
directory, is refused by name (provision-source-kind) — neither followed nor carried. A
followed link would land another file's bytes under a name meaning something else on the far
host; a carried link would dangle or point outside the workdir over there.
Destinations are confined, lexically. A destination that normalises outside the
workdir refuses by name (provision-dest-escape), naming both the normalised path and the
workdir, and nothing in the set is written — not even the entries that were fine. An absolute
destination and one with a .. path component are refused before that. The channel namespace
(_*) and the engine's own .saphan-kickoff-brief are likewise not legal destinations, and
two entries naming one destination refuse as provision-dest-collision.
⚠ "Confined" here means joined-and-cleaned, not resolved. The engine does not follow symlinks when it makes this decision, and it cannot: your leg's workdir is on the execution host, so resolving this machine's filesystem would answer a question about the wrong one. Do not rely on this check to contain a destination you did not write yourself.
A symlink in the destination's path refuses, and your link is left alone. That decision is
made on the execution host, where the workdir actually is, and it is made before anything is
unpacked: if any component of a destination under the workdir is a symlink — including the
final component, the destination itself — the apply refuses, names that path
(SAPHAN-PROVISION:DESTSYMLINK <path>) and surfaces as provision-carry-failed, with nothing
written and nothing removed. What to do when it refuses: the engine will not choose between
your link and your bytes. Either name a destination that does not cross the link, or move the
link aside yourself on the execution host and re-dispatch. Re-running unchanged refuses again,
identically.
This paragraph used to promise that tar stopped such a destination, and tar did
something else. Measured, GNU tar 1.35, on the apply's own invocation: it stopped a
destination whose symlinked parent pointed out of the workdir (exit 2, "Invalid cross-device
link"); it followed a relative link that stayed inside; and where the destination itself
was a symlink it exited 0 having deleted the link, leaving a regular file where the
operator had put a pointer. Nothing escaped the workdir in any of the three, so the confinement
claim held — but a link placed deliberately was destroyed, and the document said it was
protected. The refusal above replaces that promise.
⚠ Which lane this covers, and which it does not. The guard runs in the far-side apply, so it
covers every dispatch to a machine whose transport is ssh — every execution host reached over
the network, which is what --provision is for. It does not cover a dispatch to a machine
whose transport is local (the engine's own host): that lane writes each member with
os.MkdirAll + os.WriteFile, both of which follow a symlinked component, so a destination
crossing one lands the bytes wherever the link points — outside the workdir if that is where it
points — with no error, and the run record cites a workdir path that holds nothing. That is
long-standing behaviour, not new here, and closing it means giving the two lanes different
confinement rules — a decision above this change. It is named rather than left silent, because
silence about one lane reads as a claim about both.
Bounds. A provisioning set is capped at 32 MiB and 4096 members, all entries summed.
Past either, the dispatch refuses as R38 naming the size it measured and the cap, and the
row records that refusal — it is never an unexplained error. Your sources have been read into
memory by then, but nothing has been packed, dialed or written on either host. The remedy is
not a bigger cap: scp the bulk material into the worktree's .provision/ on the execution host
before dispatching, and keep --provision for what the order actually cites.
Why the cap is about your set and not about your command line. Provisioned bytes ride the transport's stdin; the command the engine composes for the far shell names the pack file and the workdir and nothing else. So that command's size does not vary with what you provisioned — it is the same at 1 KiB and at 4 MiB, while the carrier this replaced grew from a few kilobytes to several megabytes over the same range and broke through the far kernel's argument limit. The property is that invariance under payload; it is not a constant, and no fixed byte figure for that command is worth quoting, because its length does move with your workdir's path and the pack file's name.
What the run record carries. A file entry is cited as <dest>@sha:<12 hex> — the
destination and the sha256 of the source bytes on this host. ⚠ It is not a far-side
re-measurement: the per-file comparison the far host used to echo back went with the argv
carrier that echoed it. What replaced it is stronger in aggregate and weaker per file — the
whole pack's sha256 is verified on the execution host before its bytes are renamed
into place, so one comparison covers every member and the manifest declaring them, but no
line in the record says "this individual file measured the same over there". A directory
entry is cited as
<dest>@tree:<12 hex>:<member count>, where the digest is over the pack manifest's own
declaration of that tree (each member's path, mode and sha256). A directory has no single
content digest, so the record does not pretend to one; sending the same tree twice produces
the same digest.
When a provisioned citation refuses
A run that was given a file with --provision <source>=<dest> — a permission class, a
canon document — carries a citation on its run record: the destination plus the sha256 of
the bytes. Teardown refuses to record the decision unless it can still find those bytes,
because a teardown is the moment they stop being reachable. It looks in four places, in
this order:
- the archive this teardown just wrote from the live worktree;
- a known-good copy you supplied with
--provisioned-from <dest>=<absolute path>; - the stream's own repository history;
- the source the run record remembers — the path the bytes were read from at spawn, and the commit they stood in.
Place 4 is why an ordinary teardown of a stream provisioned with a permission class needs no flag from you. Three things it deliberately does not do, each of which you will see named in the refusal when it happens:
- A source that stood in no commit when it was read is never resolvable from place 4, even if the file is still on your disk. Uncommitted bytes are not a durable place to point at, and that is the case this invariant was written for.
- A remembered source that is there but no longer hashes to the cited digest refuses by sha, and no further place is consulted. The engine will not pick whichever copy happens to pass.
- A run record written before the engine recorded sources at all (anything older than
schema migration 48) has nothing to look for, refuses exactly as it always did, and points
you at
--provisioned-from. The fix does not reach backwards, and the refusal says so.
--provisioned-from remains the corroborated way out of all three: the bytes you supply are
hashed and must match the cited digest, so it satisfies the invariant rather than bypassing
it.
Finally, closed-without-merge exists as a first-class decision: a stream can end
honestly without landing — the record then says so, rather than the stream lingering as a
ghost or being quietly deleted.