Saphan StudioDocs
Getting started

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:

  1. the archive this teardown just wrote from the live worktree;
  2. a known-good copy you supplied with --provisioned-from <dest>=<absolute path>;
  3. the stream's own repository history;
  4. 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.

On this page