Changes touching this path
- CLI: machine error channel — coded CliError + RepoError::code() under --json (#430)
Give the binary a machine error channel so its error taxonomy travels as data
instead of the SDK regex-matching stderr prose. RepoError::code() (in loot-codec)
maps each of the 10 variants to a stable slug and is the source of truth. A new
CliError { code, message } (loot-cli/src/error.rs) carries it: From<RepoError>
(code from the variant), From<String>/&str (code "error"), and to_json() emitting
{"contract":N,"error":{"code","message"}}. Verbs now return Result<_, CliError>;
the dispatcher's fail() emits the JSON error object to stderr under --json and the
byte-for-byte-unchanged `loot: <message>` otherwise. CLI-level failures carry
codes: no_repo (open_repo) and unknown_flag (the flag gate).
Scope note: Workspace already stringifies RepoError internally (its methods return
Result<_, String>), so no live verb path yet propagates a typed RepoError to the
dispatcher — engine failures emit code "error" (message intact) while no_repo and
unknown_flag flow as real codes. From<RepoError> is in place and unit-tested;
threading typed engine errors through Workspace is a larger follow-up.
cargo test --workspace green.
97b9becb · dbf3dbe6… - CLI: thread typed engine errors through Workspace so slugs travel (#430 follow-up)
#430 laid the CliError plumbing but the taxonomy stayed inert on live verb
paths: workspace.rs methods returned Result<_, String>, stringifying RepoError
before the verb's `?`, so every engine variant collapsed to code "error" at the
--json boundary. Convert that boundary: impl Workspace / Snapshotted / lanes
methods return Result<_, CliError>; engine (self.repo.*) sites use
.map_err(CliError::from) so code() is preserved; the MisSeal/SealWip guards
propagate the typed RepoError instead of .to_string(). Non-RepoError sources
(io, format!, bare strings) stay code "error", messages byte-for-byte intact.
Workspace::open/init stay Result<_, String> (open_repo() stamps the CLI-level
no_repo slug), keeping the loot-first ripple to two boundary conversions. New
emit_snapshot test proves a real RepoError::UnsupportedFormat reaches --json
stderr as {"code":"unsupported_format"} on `loot apply <garbage>`, with the
non-json `loot: <message>` line unchanged.
Also: share loot_core::verdict::json_string (drop error.rs's byte-identical
twin) and collapse the emit_snapshot run_with_code/run_stderr_with_code helpers
into one run_streams. Known residual: the ferry.rs bridge path still flattens
engine errors to "error" (deliberate scope boundary — git2 errors ride its
String contract). cargo test --workspace green.
627897cb · dbf3dbe6…diff - a ferry refusal reaches a machine consumer as itself, not as a generic error (#867)
Every seam inside `ferry::run` returned `Result<_, String>`, so a pass — which
is mostly other people's failures — flattened all of them before the CLI could
emit them. A typed `RepoError` the engine had already coded, and the reconcile
executor's `Plan::Refuse`, both reached a `--json` consumer as the generic
`error` code. The prose was fine; the machine channel was the loss.
The nine phase signatures now carry `CliError` end to end, which restores the
engine's own slugs for free: engine calls propagate with a bare `?` instead of
`.map_err(|e| e.to_string())`, which is precisely what was flattening them.
Exactly three slugs join the frozen ADR 0023 contract (amendment recorded):
`undescribed_parent` and `foreign_paths` from a new `reconcile::Refusal::code`,
built alongside the unchanged wording by BOTH executors — `apply_plan` and
#866's adopt catch-up — so two verbs sharing one table cannot become two
taxonomies; and `git_mirror` where ferry lifts git2, deliberately coarse
because the bit a consumer acts on is retry-the-environment versus obey.
`git_mirror.rs` stays stringly inside: inventing a loot taxonomy for git2's
errors would be fiction.
`rollback_note` keeps the abort's code and grows only its message — a rollback
is context about that failure, not a different one.
Every refusal's wording is byte-identical; the human channel does not move.
Ferry's own handful of refusals stay honestly generic, and so do the tier's
remaining flatten sites, which graduate one at a time when a consumer needs
them.
The one loot-first line is the seam back out: its orchestrator keeps a String
channel, so `From<CliError> for String` makes that a `?` rather than a
hand-written `.to_string()` at every call.
Found while pinning the codes: the uncaptured-WIP ferry test refuses with
`foreign_paths`, not `undescribed_parent` as it reads. Both wordings offer
`describe -m`, which is why prose was never a taxonomy.
4fc73479 · dbf3dbe6…diff - a disclosure invariant stops being written down with a reason its own test disproves twelve lines earlier, because what keeps a live delta from printing withheld content is two gates and neither is about the disk — `content_side_of` seals a recorded path outside `visible_paths_at`, and `classify` refuses rung 1 unless both sides hold bytes — which matters because `DagRepo::get` never checks grant expiry at all, so an expired grant is one decrypt away and only the seam refuses it (#1597)
bc388741 · dbf3dbe6…diff - loot seek carries less when asked and its refusals carry a code: under --text, -l keeps the P rows of the paths that hit and nothing under them, --count puts one N row under each P row with how many lines hit, and --max-line <bytes> cuts a hit line at a character boundary into a C row of T arity, clipped true under --json, each emitted only under a flag that did not exist before, the rule the R row set in #1522, so a consumer that never passes them reads the bytes it always read. the three are one scan rendered three ways, so skipped_sealed, truncated and the row order never move, and an uncut hit carries no clipped key so every hit shipped before renders byte for byte as it did. measured on this repo, a search for fn over crates/loot-cli/src is 259,793 bytes in 3,888 rows whole, 9,923 bytes in 92 rows under -l and 10,369 bytes in 183 rows under --count. the refusals join the #430 coded channel: a refusal under --json is one object with contract first and a code the CliError was built with, never a parse of its sentence, declared beside the verb in seek::code as conflicting_flags, read_is_bytes, no_identity, multi_head, bad_revision and no_such_remote, while a directory that is neither repo keeps no_repo and an unusable --limit or --max-line keeps bad_flag_value because a second spelling of one fault is the #1597 defect; there is no unreadable slug because --read refuses every machine format before it reads and a listing or a search counts a sealed path rather than refusing. the multi-head refusal names each head with its id prefix and subject so the next call pins one with --at in one round trip; the ticket first asked for the newest head by default and that cannot be honest from a depth-one cache, where every head reads generation 0, and authored_at is never an ordering input (ADR 0043), so the ticket was corrected before the lane opened and ADR 0023 records why. error.rs stops listing the three CLI slugs as the whole set and names what defines it, and loses a two-raiser census that was stale before this change. the pin on that one object found a defect #2092 shipped: every remote loot seek printed the dispatcher shallow note after its answer, because the body-less cache is refreshed at depth one by design and the width it records is the process-wide one main reads, so the note was wrong twice, about a cut nobody can close and naming a loot fetch --deepen that runs where the operator stands; the cache open and its object rounds now run outside_shallow_notice and a remote answer leaves stderr empty. the two-axis review before landing also refused --max-line beside -l or --count where it was silently inert, mapped the selector door stringified refusals to bad_revision on a loot target so a typo in --at carries the same code on both backends, pinned the multi-head refusal through the verb and the prefix it prints as a selector the verb accepts, pinned the cut through the scan on a multi-byte line and on an invalid byte the lossy decode widens, read the three remaining driven refusals back under --json, and narrowed the code claim to the refusals the verb decides, a stringified failure staying error as #430 says. pinned in the shape tests on every format, on a multi-byte line for the cut, on the code of each parse refusal, and on two signed heads over a spawned relay, and through the spawned binary on the seek fixture with every refusal it drives under --json read back as one object with its code. red under mutation, counts read each time: -l still writing the T rows (0 passed, 1 failed), --count off by one (0 passed, 1 failed), the JSON hit_count dropped (0 passed, 1 failed), the clip ignoring character boundaries (0 passed, 1 failed), the clip marking nothing in JSON (0 passed, 1 failed), the clipped row keeping the T mark (0 passed, 1 failed), --max-line 0 accepted (0 passed, 1 failed), the shaping flags riding a listing (0 passed, 1 failed), -l with --count accepted (0 passed, 1 failed), a parse refusal on the generic code (0 passed, 1 failed), the read refusal losing its code on the wire (0 passed, 1 failed), the multi-head refusal naming no head (0 passed, 1 failed), the multi-head refusal on the generic code (0 passed, 1 failed), the git bad-revision code generic (0 passed, 1 failed), --max-line beside -l or --count accepted and inert (0 passed, 1 failed), a bad --at on a loot target left on the generic code (0 passed, 1 failed), the multi-head guard unwired from the verb (0 passed, 1 failed), the clip through the scan measuring raw bytes (0 passed, 1 failed), and the cache refresh recording its width for the shallow note (0 passed, 1 failed). no migration, no wire or format byte moves, and no forge or relay byte moves, so this owes no deploy. the workspace suite is green (3984 passed over 124 binaries, 7 ignored) (#2105)
25d946e7 · dbf3dbe6…diff - loot seek is easier to drive: --name <glob> matches a basename at any depth, spelled into the same pathspec as --path three ways, the glob for the root, **/glob for everything below it because the dialect **/ crosses one directory or more and never none, and **/glob/** for what a directory of that name holds because the bare-directory affordance reaches a root-level pattern only, joined by the group any-match rule, because the dialect stops * at a slash and every Rust file was a --path pair agents get wrong, with a glob carrying a separator refused as a path by code; the ambient target resolved is the root lexical absolute path rather than a dot, the same root --in <dir> prints, with no verbatim prefix on Windows, a value that moves where the key does not because a dot was never a location a later call could use, which ADR 0023 says; and loot seek --schema prints the verb machine contract as one JSON document from anywhere, contract first, derived from the code rather than typed: the flags off SPEC and the attached group, the marks and their columns off MARK_COLUMNS, the codes off code::ALL, and three examples the verb renders itself, an answer carrying every optional key, a counted answer and a coded refusal, with --json accepted and any question beside it refused. the codes come in two sets that say which is which, verb_codes off code::ALL and shared_codes off CliError::SHARED defined in error.rs where those slugs are minted, and the tables that are lists are pinned against what the code does: every mark constant read off the file is in MARK_COLUMNS with the arity a rendering under every Detail emits, code::ALL is every pub const in the module read off its own source, SHARED is every slug error.rs mints, and every key Answer::json can write is in the examples, the refusal example rendered by the multi-head raiser itself. the two-axis review before landing renamed the codes key before it froze under a name that read as the whole set, moved the --name refusal ahead of the target open so a bad glob costs a remote no round trip, said that --name is not rebased by the argv door because a basename is not a path and pinned it from a subdirectory, pinned the git-local and relative --in resolved, and corrected the ADR to the spelling the code makes. pinned in the unit tests on a nested fixture and the refusal, on the schema derivation, and through the spawned binary on --name equal to the pair it spells and not to **/ alone, unioned with --path, the ambient resolved absolute and equal to --in <root>, and --schema from a directory that is no repo. red under mutation, counts read each time: --name not spelled at any depth (0 passed, 1 failed), a --name with a separator accepted (0 passed, 1 failed), the ambient resolved staying where the verb ran (0 passed, 1 failed), --in <dir> resolved staying as typed (0 passed, 1 failed), --schema taking a question beside it (0 passed, 1 failed), MARK_COLUMNS forgetting the count row (0 passed, 1 failed), MARK_COLUMNS with an arity wrong (0 passed, 1 failed), code::ALL forgetting a slug (0 passed, 1 failed), the schema example carrying no refresh (0 passed, 1 failed), the schema omitting the bare flags (0 passed, 1 failed), SHARED forgetting a slug (0 passed, 1 failed), the schema omitting the shared codes (0 passed, 1 failed), the schema refusal example not the raiser (0 passed, 1 failed), a --name refusal waiting for the target to open (0 passed, 1 failed), and --schema answering a question instead through the binary (0 passed, 1 failed). no migration, no wire or format byte moves, and no forge or relay byte moves, so this owes no deploy. the workspace suite is green (3999 passed over 124 binaries, 7 ignored) (#2115)
ca25f385 · dbf3dbe6…diff
Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.