Tags · backthread/backthread · GitHub
Skip to content

Tags: backthread/backthread

Tags

redact-v0.1.6

Toggle redact-v0.1.6's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #183 from backthread/arp-1764-1758-oss-hygiene

Keep the cli tests out of the home directory, and give the fence a release path

v0.26.0

Toggle v0.26.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Measure a relative path from the directory its own tool call ran in (#…

…180)

A relative path means something different in every directory, and the fence
only knew the directory the session started in. When a command moved, the
shell read one file and the fence measured another; where it moved into a
link out of the repo, or into another repository, that is another project's
file recorded under this one's name.

The agent stamps a working directory on every record and a cd sits in the
command text already being scanned. Both now travel with the path.

The load-bearing half is knowing when NOT to trust that reading. Three
rounds of adversarial review found twenty-five ways to move a shell that a
literal search for "cd" does not see -- pushd, cd -P, { cd x; }, env -C,
bash -c, cd -, source, c\d, c"d", c${EMPTY}d, CDPATH, a function body that
runs at its call site, and a failed cd after a successful one. So detection
now runs over the command as the SHELL will see it, with quoting removed and
expansions collapsed, and anything the reader cannot account for makes the
command unplaceable: its relative paths are dropped rather than attributed
to a directory nobody can name.

91 spellings across five probe rounds, each first run under a real shell and
scored only where it genuinely reads the other repository. All refused; the
same probes leak them on 0.25.0. Everything closed by 0.24.0 and 0.25.0
stays closed and the laundering trade-off is intact.

C1 IS NOT CLAIMED. Deciding whether a command changed directory from its
text is undecidable against a shell, so the residual -- an alias, or an
expansion whose value is not in the transcript -- cannot reach zero. The
absolute fix, dropping every relative shell path, was refused: it discards
~84% of the paths shell commands contribute, and harvesting those is what
moved anchoring from 5.3% to 93.0% on a measured machine.

Cost: 8.1% of relative shell paths dropped as unplaceable, plus ~1.2% for
resolving outside the repo. Two over-detections were removed after measuring
them -- a bare -C is an everyday flag of grep/git/tar/sort, and source as an
argument is not a command -- recovering ~1,800 commands.

v0.25.0

Toggle v0.25.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Resolve a path against the filesystem before normalizing it (#179)

* Resolve a harvested path against the filesystem before normalizing it

The fence resolved the path it was about to EMIT. Two mechanisms walk straight
through that, and they are one shape: the filesystem was asked a question whose
answer had already been thrown away, or was not asked at all.

`..` CANCELLED ON THE STRING. `dlink/../src/secret.ts`, where `dlink` is a link
into another repository, reduces arithmetically to `src/secret.ts` -- a name this
repo genuinely has -- while the kernel opens the other repo's file. Cancelling
`..` is only valid against a real directory; against a symlink it is simply
wrong, because the link does not go where its name says. By the time the escape
predicate was called the `..` was gone, so no predicate at that position could
have seen it, however it was written.

A SEGMENT THE FILESYSTEM WILL NOT RESOLVE, CLIMBED PAST. Resolving the deepest
ancestor that exists treats "cannot say" as "keep looking upward", lands on an
ancestor that IS inside the repo, and keeps the path. The note left in the code
last time named a dangling link as the one case. It is not one case: an escaping
link behind a chmod-000 directory does it with EACCES, and a symlink loop does it
with ELOOP. The class is anything that makes the filesystem unable to answer at
the point the fence asks, and it is written for the reasons nobody has measured
yet, not the two that have been.

So the raw spelling now crosses the package boundary -- absolute or relative,
`..` and `./` and either separator intact -- and the caller walks it through the
filesystem one segment at a time. Everything to the left of a `..` has already
been followed on disk when the `..` is reached, so its parent is the true one.
Only a positive ENOENT continues the walk, and then only on ground we know is not
a link, which is what keeps the deleted file's path. Everything else fails
closed. That is a second signature change to a published interface, and it is the
point rather than the cost: a half-resolved path crossing that boundary IS the
defect.

The leaf is still not followed. What leaks is a path descending THROUGH a link
into somebody else's directory structure; a symlinked file at a name this repo
really has is our own metadata, and git tracks it.

Two shapes are refused before any of that. A URI scheme, because `file://...` is
not POSIX-absolute and so was treated as relative and emitted as `file:/...` -- a
machine-absolute path, out of a module whose stated contract is that it never
emits one. And a percent-escaped separator, which is two different paths
depending on who decodes it, so the fence would measure one and the kernel would
open the other. That check sits beside the NUL refusal rather than in the
relative branch, because an absolute spelling never reaches the relative branch
and `<root>/vendor%2Fsrc/secret.ts` was getting through on exactly that.

Measured on real git repos, real symlinks and real files, with `cat` confirming
that each spelling genuinely opens the other repository's file: 23 re-spellings
of the escape, every one refused, and the four properties that had to survive it
-- our own file, a sibling worktree's real file, a file the session deleted, and
our own name for a file that is a link out -- all still reaching the wire. The
same probe run against the previous release leaks 21 of them.

The `..` cases needed a fixture nobody had built: a link that stays INSIDE the
repo, with the `..` after it doing the leaving. Every earlier fixture had the
link itself land outside, so a mutant that skipped `..` altogether survived the
whole end-to-end battery until that case existed.

* Pin the trade-off that comes with resolving a place instead of judging a name

The previous release dropped a path as soon as ANY checkout said that name leaves
the repo. That inversion is what closes the laundering case -- roots are checkouts
of one repo, so every tracked directory exists in all of them, and a rule that
keeps a path as soon as SOME root resolves it inside lets a sibling worktree vouch
for an escaping link at a name the repo genuinely has. It also had a cost nobody
had measured: an honest file living at that same name in a sibling worktree went
down with the escape.

Resolving the candidate rather than judging its name splits that in two, and the
split is worth writing down because either half alone reads as a bug. A RELATIVE
spelling is a name and nothing more, so the inversion still applies and the honest
file is still lost. An ABSOLUTE spelling is a place: it can be followed on its own,
no other checkout gets a vote, and the honest file comes back. Measured: it is
dropped on the previous release and kept here, while the escape through the very
same name is refused in both spellings.

One end-to-end case, on real repos and real links, carrying all four legs at once
so nobody can "fix" one of them and quietly reopen the other.

The changelog says the same thing to the reader, along with the three limits that
are still true rather than fixed: the shell harvest still requires the file to
exist, an already-relative path with no resolvable root is still kept as-is, and a
path deeper than the walk cap is dropped rather than measured.

* A backslash in a FILENAME was the same escape wearing a different costume

Independent verification of the previous commit found a spelling it does not
close, and the mechanism is the one this whole change exists to remove: the
fence measuring a different path from the one the kernel opens.

This module read `\` as a second path separator, so that a Windows spelling could
be reduced like a POSIX one. On POSIX it is not a separator -- it is an ordinary
character in a filename. So a symlink genuinely NAMED `x\y`, pointing at another
repository, was split here into the segments `x` and `y`, neither of which exists;
the walk found nothing on disk, read that as empty ground inside the repo, and kept
the path. The kernel resolves `x\y` in one hop into the other repository. Proven by
reading the file through that spelling: it returns the other repo's bytes, and the
path reached the wire as `x/y/src/secret.ts` -- a path this repo does not have.
Present on the previous release too, in the absolute spelling and with a `..` in
it as well.

There is no reading of `\` that is correct on both platforms, and nothing here can
know which one produced the string, so the ambiguity is refused rather than
guessed at -- the same answer already given to a percent-escaped separator. A
candidate carrying a backslash is dropped at the fence, and `/` is the only
separator anywhere below that line. The two `\`-aware splits are gone with it:
having two answers to "what is a separator" in one module is how this happened,
and the remaining mutants against them are equivalent precisely because nothing
carrying a `\` can reach them any more.

The cost is stated in the changelog rather than left to be found: a Windows
RELATIVE spelling (`src\thing.ts`) is now dropped. Windows absolute spellings were
already being dropped before this.

Two more from the same round.

AN UNRESOLVABLE ROOT COULD VOUCH. The resolved-root set drops a root that no longer
resolves -- it cannot confirm anything, and a logical spelling kept beside physical
ones confirms by prefix instead. The code said so in a comment and nothing could
fail on it. Now two roots, one live and one gone, with the live root's own file
asserted alongside so a green cannot mean nothing was sent.

AND THE NUL REFUSAL IS NOW EVERY C0 CONTROL CHARACTER. A transcript-supplied
`file_path` containing a newline was reaching the wire as a "path" that renders as
two lines wherever it is later stored, joined or shown. Also removes a raw NUL byte
that had been committed into the source as a cache-key separator: it made grep treat
the file as binary and silently return nothing, which is a poor thing for the one
line deciding containment. The key does not need it -- `absolute` is a pure function
of `raw`, so the two kinds could never have collided.

* Kill the last two survivors, and stop two comments claiming more than they hold

A second independent round put 32 mutants through the fence. Five survived; three
were provably equivalent and two were real coverage gaps. Both are now dead.

A TRAILING `..` IS NOT A LEAF. The walk deliberately does not follow the last
segment of a path -- that is what keeps our own name for a file that happens to be
a link out -- but a spelling ending in `..` has no leaf to skip, and popping it
walks one segment DEEPER than the spelling names. `sub/rootlink/..`, where
`sub/rootlink` points back at the checkout root, then stops at the root and reads
as inside the repo, when what it actually names is the directory the repo sits in.
Every other spelling in the suite ends in a filename, so nothing had ever reached
that branch.

AN UNRESOLVABLE ROOT MUST BE STEPPED OVER, NOT READ AS A VERDICT. The relative
route asks every root in turn and stops at the first contradiction. A root that
fails to resolve answering "no escape" means the first dead root in the list
acquits every path behind it, before any root that could contradict is consulted.
Reached with an injected root set: the real resolver verifies every root it
returns, which is what keeps this defensive rather than live, and also why nothing
could fail on it.

Two comments were also overstating their subject, which the same round measured.

The walk's `/`-only split and the backslash refusal are MUTUALLY redundant, and
the comment implied a one-way dependency. Measured: either alone closes the `x\y`
escape -- the `/`-only split because it makes the walk measure the path the kernel
opens, the refusal because the candidate never arrives. Only removing BOTH puts it
back on the wire. So a mutation of either line alone will not go red, and now the
next reader knows that is redundancy rather than a missing test.

And the control-character refusal is not quite "refuses every control character":
a path-named tool input is trimmed before it arrives, so a tab or a newline at
either END is removed rather than refused, and the trimmed path is kept. Nothing
carrying a control character is emitted either way, which is the property that
matters, but the comment now says which case is which instead of implying one rule.

* Release 0.25.0: resolve before normalizing, and refuse what cannot be measured

The four lockstep-checked version declarations, the rebuilt bundle, and the
lockfile -- which the bump helper does not touch, and which would have failed
`npm ci` on a version it no longer matches.

* The input trim was erasing the first segment, and the first segment is the fence

An independent review of this branch found one more spelling that escapes, and it
is the third costume of the same defect: the fence measuring a different path from
the one the kernel opens.

A path-named tool input was pushed as `v.trim()`. Trimming touches the ENDS of a
string, and the FIRST SEGMENT of a path is an end. So a symlink named ` vendor`
-- leading space -- pointing at another repository arrived at the fence spelled
`vendor`, which this repo does not have; the walk found nothing there, read that
as empty ground inside the repo, and kept the path. The kernel, given the spelling
the agent actually used, opened the other repository in one hop. Reproduced
independently before fixing: the file really opens, and `spacevendor/src/x9.ts`
reached the wire. A non-breaking space works identically, because `trim` strips
every Unicode whitespace character, so this was not one spelling but a family.

Only the tail is trimmed now. That half is safe and worth keeping: the walk never
follows the last segment, so nothing the tail does can move where a path resolves,
and a trailing newline in a tool-input field is common enough that refusing it
would cost real paths. A LEADING control character is no longer trimmed away
either -- it now reaches `CONTROL_CHARACTER` and is refused, which is the better
answer than silently repairing it.

The rest of the review was comments claiming more than the code does, which on
this file is not cosmetic -- every one of these bugs was survivable because a
comment said the line was already handled.

  - `realPathImpl`'s contract still said "null when it names nothing on disk".
    That is precisely the belief this change exists to destroy, and it is what a
    test author reads before writing a stub. A stub built to it returns null only
    for absent paths and silently stops exercising the dangling, EACCES and ELOOP
    cases that `isAbsentImpl` was added to separate.
  - The memo comment carried both its old and new rationale stacked, so it read as
    if the key still included the candidate's kind.
  - The "one root saying outside is enough" paragraph appeared twice, verbatim,
    nine lines apart in one function.
  - Three more pointed at deleted lines: a backslash check that moved to the loop,
    "either separator" on a field that can no longer carry one, and an inline
    comment describing only NUL above a check that now covers every C0 character.

`isAbsent` is memoised now, like `realOf` already was. The relative route walks
every root, so a name present in only one of them asked the filesystem once per
root per path -- and on a machine running parallel worktrees that is the ordinary
case, not the rare one.

Two accepted costs go in the changelog rather than staying folklore: a file of
ours behind an unreadable directory or a symlink loop is dropped (the same rule
that refuses an escaping link hiding there, which cannot tell them apart), and the
path we EMIT is still reduced arithmetically, so an in-repo `..` can name a file
that was never opened. Neither can cross a repo boundary. The second is worth
fixing properly -- the walk already knows the physical destination -- but that is
another change, not a line in this one.

* A relative path is resolved from the session's cwd, and the fence walked from the roots

Third independent round, fourth escape, same shape as the other three: the fence
measuring a different path from the one the kernel opens.

A RELATIVE candidate was walked from the repo ROOTS. The kernel resolves it from
the directory the session is running in. Those are the same place only when the
session sits at the top of the checkout -- and an agent working inside a monorepo
package does not. With the session in `<repo>/sub` and `sub/dlink` a link to
another repository, `dlink/src/secret.ts` was resolved from the root to
`<repo>/dlink/...`, which does not exist, which the walk reads as empty ground
INSIDE the repo, so the path was kept. Resolved the way the kernel actually would,
it opens the other repository. Reproduced before fixing, with `cat`: the file
really opens, and `dlink/src/secret.ts` reached the wire under this repo's name.

The same missing base defeated `exists`, and that form is worse. `exists` also
joins against the roots, so with `pkg/src` linked out and the session in
`<repo>/pkg`, the shell token `src/keep.ts` names the OTHER repo's file while this
repo has its own `src/keep.ts` -- existence confirmed it, and it shipped spelled as
one of ours. Nothing downstream could ever have told the two apart. Both forms
close together, because both were one missing base.

The session cwd is now a base a relative spelling is measured from, ahead of the
roots, and only when it resolves INSIDE the repo -- a cwd that resolves elsewhere
is not something this repo's paths can be measured from, and admitting it would
contradict everything and empty the harvest rather than protect it. The
"any base saying outside is enough" rule is unchanged; this adds a base to it
rather than changing the question.

The test carries both forms and one more thing: a genuinely repo-relative path,
reported from that same subdirectory, still survives. Without that leg a fix that
simply refused everything relative would pass, and it would have been wrong.

This was not a regression. The previous release leaks the identical rows; this
change had not closed it. Present in the changelog as the one most likely to have
affected a real repository, because working inside a package directory with a
symlink in it is an ordinary way to work, not a contrived one.

`resolveWalk`'s root argument also stops being called `realRoot` in the loop --
the bases are resolved once now, in one place, and a root that does not resolve is
skipped there rather than inside the walk.

* Pin the memo key, and say where the fence still does not hold

A fourth verification round found one real coverage gap and one more way through.
The gap is closed here. The way through is not, and this commit is mostly about
saying so precisely rather than letting the changelog imply otherwise.

THE MEMO KEY. Containment is memoised on the whole raw spelling. The obvious
cheaper key is the parent directory -- it is what the previous release used, and
paths really do cluster into a few directories -- and it is order-dependently
unsafe. `walkableSegments` strips `.` segments BEFORE popping the leaf, so
`sub/dlink/.` walks to `sub` while `sub/dlink/x.ts` walks to `sub/dlink`: two
spellings that share a parent walk to different places, one inside the repo and
one through a link into another. Under a parent key whichever arrives first
decides both, so the ORDER of two tool calls decides whether another repository's
file ships. The shipped key was already right; nothing pinned it, so a plausible
optimisation back would have reopened a cross-repo leak with the suite green. The
comment above it argued only that an absolute and a relative spelling cannot
collide -- true, and not the collision that matters.

The comment on `walkableSegments` was wrong in the same place, which is what made
that mutant reachable: it said a trailing `.` is preserved "like `..`". It is not.
`.` is filtered out before the pop; only `..` survives to be walked.

WHAT IS STILL OPEN, AND IS NOT A REGRESSION. The bases a relative spelling is
measured from are the SESSION's cwd and the roots. The kernel resolves it from the
working directory of the TOOL CALL, and those differ in three measured ways: a `cd`
inside the very command string being scanned, a shell tool input carrying its own
`cwd` field (which the harvester reads as a path CANDIDATE and never as a base),
and a Codex transcript stating one in `session_meta.payload.cwd` (used as a root
fallback, never as a base). With the session at the repo top and a link at
`sub/zlink`, `cd sub && cat zlink/src/secret.ts` still records the other checkout's
path. The identical spellings get through on the previous release, so nothing here
made them worse -- and the comment that claimed this family was closed now says
which half is closed and which is not.

Closing it means carrying the originating directory alongside every candidate, a
third change to the shape crossing the `sessionPaths` boundary. That is its own
release. The changelog says the same thing to the reader, in the same place 0.24.0
said it about the `..` hole this release went on to close.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

v0.24.0

Toggle v0.24.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Harden the session-path fence: symlink escape, dropped-path visibilit…

…y, NUL bytes (#178)

* A symlink out of the checkout was carrying another repository's paths in

Containment was decided by string prefix, and a string prefix does not survive a
symlink. With a link like `vendor -> ../other-repo` inside a checkout, a file
belonging to the other repository reads as in-repo by every rule the harvest can
apply, relativizes against this repo's root, and is sent as one of its paths.

The pure fence cannot see that -- only the filesystem knows where a link goes -- so
it gains an `escapesRepo` seam alongside `exists`, and the CLI, which already holds
a filesystem and the roots, answers it by resolving the path and asking where it
came out. Any root that resolves it inside settles it, which keeps a file that lives
in only one of several worktrees. A contradiction -- it resolved, and every
resolution landed outside -- is what drops it. Resolving nowhere is neither: a file
the session deleted is gone from disk, and requiring existence here would quietly
impose the shell-token rule on path-named tool inputs, which have never carried it.

Also refuses a path carrying a NUL byte. The syscall layer stops reading at it, so
such a path names nothing on any filesystem this runs on, and it was metadata we
went on to store, join and render.

* Capture had one thing to say and was saying it down a pipe to nowhere

Past 64 linked worktrees the root resolver stops checking, and the paths in the
rest go back to being dropped as foreign. It announced that on stderr -- and the
shipped hook re-spawns its worker with stdio ignored, so in the delivery mode
everybody actually runs, the announcement reached nobody. Sixty-four is not a
number most machines pass, but the ones that do are precisely the ones running
many worktrees, which is the workflow the root set exists to serve.

The notice now goes to a file in the config directory, written synchronously
because a detached process can exit before an awaited write lands, and
`backthread doctor` reads it -- the surface somebody goes to when they notice less
is being captured than they expected. Both channels are used, since the terminal
is the right one for a capture run by hand. Nothing clears the notice and nothing
can: a live condition is re-recorded on the next capture, and one that stopped
ages out of the window on its own.

The sentence itself changed twice over. It reported the constant ("more than 64")
where the reader needs their own numbers, and it ended by telling them to run
`git worktree prune` -- which drops stale registrations, shows no skipped path,
recovers none, and on a machine whose worktrees are all live does nothing at all.
Following it produced no visible change and taught nothing. It now says how many
were listed, how many were checked, how many were left out, and that which ones
is git's listing order rather than anything about the session. There is no command
that fixes this, so it prescribes none.

* Release 0.24.0: follow the directory, not the file, and raise the floor

Three corrections from an independent read of the fence work.

The escape check followed the whole path, which dropped more than it meant to. A
symlinked FILE at a name this repo really has -- src/linked.ts, tracked by git,
pointing anywhere at all -- was refused along with the leak, and that is our own
metadata lost to a rule aimed at somebody else's. What leaks is a path DESCENDING
THROUGH a link: only 'vendor' exists here, so 'vendor/src/secret.ts' is another
repository's directory structure wearing our name. So the question asked is now
where the containing DIRECTORY comes out. It admits our own leaf names and still
refuses every path whose parent chain leaves. The end-to-end case pins both halves
against real repos and real links, and the memo key moves to the parent, which is
also where the cache hits are.

The committed bundle had been built against a stale install and was carrying a
DOWNGRADE of an inlined dependency alongside the fix -- reinstalling from the
lockfile and rebuilding leaves a diff that touches nothing but this change. That
would have failed the bundle-sync gate at exactly the moment it is irreversible.

The test floor had 48 tests of slack, which is a ratchet that has stopped
ratcheting.

* Stop the suite writing to the developer's home, and pin two boundaries

Two tests run the REAL capture pipeline against a path that exists on the author's
laptop, with no config-dir override and no stub for root resolution. They therefore
shelled out to real git in a real checkout: what they did depended on how many
worktrees that directory happened to have, and once capture gained something to say
about worktrees they began writing a content-bearing file into the developer's home
directory as a side effect. Both now pin the root set -- these tests are about the
redaction fence, not worktree discovery -- and point the config dir at a temp dir.
Proved by redirecting HOME and listing what the suite writes there.

Two guards were thin enough to admit real mutants.

The over-cap notice was tested at one worktree and at five hundred, which left the
boundary open: the condition could fire at exactly the cap, announcing that zero
worktrees had been left out. A line that appears on a machine that lost nothing is
how people learn to ignore the line. Now pinned at 63, 64 and 65.

And nothing covered a directory reached through a symlinked PARENT, so the escape
check could compare a resolved path against unresolved roots with every test still
green. The case that catches it has no git in it at all: the root set is then the
session cwd alone, carrying the symlinked spelling with no physical counterpart, so
the comparison matches nothing and every path reads as having left a repo that does
not exist. A git checkout behind the same kind of link is covered too, since that is
a property the previous release is supposed to have bought.

* The fence had two more ways through, and one guard could not fail on the platform it protects

An independent review broke the first version twice.

A SIBLING WORKTREE COULD VOUCH FOR AN ESCAPING LINK. Keeping a path as soon as some
root resolved it inside looked like the safe reading and was the opposite: roots are
checkouts of one repo, so every tracked directory exists in all of them. Put the link
at a name the repo genuinely has -- the ordinary trick of pointing packages/foo at a
local checkout of another project -- and the sibling, telling the truth about its own
directory, launders the escape. It is never asked about the one the path came from.
The question is now inverted: any single root saying the path left is enough to drop
it.

A LINK INTO A MISSING DIRECTORY STILL LEAKED. Asking only about the immediate parent
made the answer depend on what happened to sit on the far side: vendor/src/secret.ts
was refused because vendor/src resolved and landed outside, while vendor/gone/secret.ts
was kept, because vendor/gone resolved nowhere and nowhere is an absence rather than a
contradiction. Same link, same escape, opposite verdict. We now climb to the deepest
ancestor that exists, which reaches the link itself. The deleted-file property is
re-asserted inside that same case, because the climb is exactly the change that could
have taken it away.

THE CONTAINMENT COMPARISON WAS SPELLED WITH A LITERAL SLASH. realpath and join both
answer in the platform separator, so on Windows a root is one spelling and everything
below it is another, and every path would have read as an escape -- not a crash, a
silent empty harvest. No test on this machine can tell sep from a slash, and the
end-to-end case duly passed while the mutant lived. The comparison is now a named
function with the separator injected, so the win32 spelling is exercised directly.

And prunable worktree registrations are skipped before the cap. Git still lists a
registration whose directory was deleted, so stale entries were spending capped
verification slots -- pushing LIVE worktrees past the limit -- and inflating the count
reported to the reader into a count of registrations. That was the one case where the
advice this release removed was right, so it is now done automatically rather than
asked for, which is what makes the new message true when it says no command fixes
this.

* Guard three claims that were written down and defended by nothing

A verification round mutated the comments rather than the code, and found three
sentences this change asserts with no test behind them.

The escape check is documented as receiving the roots the harvest ACTUALLY used.
That matters because the harvest falls back to a root it finds in the transcript when
the caller supplies none, and a predicate closed over the caller's empty list is
measuring against no directories at all -- so it can only ever answer that nothing
left the repo, which is the second weaker route the documentation promises does not
exist. Passing the caller's list instead left the suite green.

And both doctor lines. An absent notice is also what a machine looks like when the
hook has never fired, is misconfigured, or cannot write to the config directory, so
the wording has to report the state of the mailbox and claim nothing about a capture
that may never have happened. The file says so in a comment; the comment could be
replaced with the exact sentence it forbids and nothing failed.

* Say where the fence still does not hold, in the release that tightens it

Independent verification measured 23 spellings of the escape that leak in 0.23.0 and
are refused here, and six that still get through unchanged. None is a regression and
none is fixed by this release. A reader who finishes the changelog believing
containment is now complete would be worse off than before, so the limitation is
stated beside the fix rather than left to be discovered.

All six need a `..` segment or a URI scheme in a path handed to a file-reading tool,
and they share one cause: `..` is cancelled arithmetically on the string before
anything consults the filesystem, and cancelling `..` against a symlinked directory
gives the wrong answer. The code comment gains the same correction plus the dangling-
link case, where the ancestor climb walks past a link that resolves nowhere -- the
paragraph above it was true only while the link's target existed.

The predicate cannot close either one: it receives the post-normalization path, so by
the time it is asked the evidence is already gone. The fix is to resolve against the
filesystem before normalizing, which changes what crosses a published interface, and
that belongs in its own release rather than bolted onto this one.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

v0.23.0

Toggle v0.23.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Release 0.23.0, because a fix nobody can install is not a fix (#177)

* Release 0.23.0, because a fix nobody can install is not a fix

The two changes that make capture see a multi-checkout or symlinked working
tree merged and then sat on main with no version anyone could pull. Everyone
running the plugin or the CLI is on 0.22.0 or older, which still measures
session file paths against the single directory the hook fired in — so a linked
worktree's files are discarded as another project's, and a checkout reached
through a symlink loses its paths outright. That second case needs no worktrees
at all, which makes it the more common one.

This is the release commit for those: the four version declarations that are
lockstep-checked, the CHANGELOG entry, and the committed bundle.

The bundle rebuild also carries a change nobody made here. `fast-uri` moved
under us in the last dependency update, and the bundle was not rebuilt with it,
so the in-sync check has been failing on main since — every push and every pull
request. It is a vendored dependency's own URI-parsing hardening, inlined by
esbuild the same way the rest of the bundle is; the only reason it appears in a
release diff is that a release is the next thing that rebuilt.

* Say what the truncation warning actually offers, since it is not what was written

Review finding on the release entry. The changelog said the cap warning "names the
command that shows you what was skipped". The command it names is
`git worktree prune`, which removes stale administrative registrations. That is the
right fix when the surplus is stale, and it shows you nothing — `git worktree list`
is what shows you. The distinction matters to the reader who has more live worktrees
than the cap, for whom pruning changes nothing and some of whose files are quietly
not being measured. The entry now says both halves.

CHANGELOG.md is not in the published tarball, so this is repo-facing prose and the
bundle is untouched.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

v0.22.0

Toggle v0.22.0's commit message
backthread 0.22.0 — a path found in a command is a guess until the fi…

…lesystem agrees

v0.21.0

Toggle v0.21.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
The wire has said whether to try again for a while; now somebody read…

…s it (#173)

* The wire has said whether to try again for a while; now somebody reads it

A rejected request printed `grounded-ask rejected (502): retrieval_failed` — an
internal slug that names one of our call sites and answers nothing a person
actually wants to know. The server had already stopped doing that: every relayed
failure carries `reason` (`overloaded` = the database was busy, retry is worth it;
`unavailable` = we cannot promise that) plus a machine `error` and, when there is
one, a real SQLSTATE. The client half was never built, so the wire got strictly
more actionable while the terminal stayed exactly as useless.

It reads as a sentence now, on every endpoint that sends one:

  the answer didn't come back. The database was busy — try again in a moment.
  the answer didn't come back. It failed on our side, and retrying is unlikely to help.

The slug and the SQLSTATE are OPERATOR fields and are hidden by default. A
SQLSTATE obviously is one; `lesson_persist_failed` is no less one, and printing it
was the whole defect. `--verbose` (or BACKTHREAD_VERBOSE=1, which the MCP tools
read because they have no command line) appends
`[status=502 error=retrieval_failed reason=overloaded code=57014]` — it adds, it
never replaces the sentence.

WHY THIS IS A MODULE AND A REGISTRY RATHER THAN SIX EDITS. Three modules each held
a private copy of the same six-line `serverMessage()` helper (`message ?? String(
error)`), so fixing the route that was reported would have fixed one of five and
left four. There is one renderer now, plus `CLI_ENDPOINTS`: every endpoint this
package can reach, each with a stated disposition — rendered, keeps its own slug
map, or never shown, and the last two have to say why in a sentence.

What stops the next recurrence is three guards, each of them mutation-killed:

  * the registry is derived-complete against the `build*Url` / `build*Link`
    declarations found in source, so a new endpoint is RED until somebody says what
    a person sees when it fails;
  * a conformance battery drives all six real entry points through a stubbed
    rejection and reads the string they actually produce — a unit test of the
    renderer proves the renderer works and proves nothing about whether a route
    calls it, which is the exact shape of the original defect;
  * origin helpers are allow-listed to urls.ts/claim.ts, so an endpoint cannot dodge
    the registry by inlining `new URL('/x', workerBaseUrl(env))` at a call site.

Judgement calls. /infer-decisions is wired through the renderer although it does not
send the contract today: it is a worker route carrying the identical helper, and
leaving it out is how this recurs. The claim exchange and the login poll keep their
own slug maps — they map slugs to specific recovery instructions, which is strictly
more actionable than a retry verdict and cannot key off a `reason` they never send.
The 409 "a lesson is already being prepared" and the 426 upgrade text are
worker-AUTHORED sentences, not relayed diagnostics, and still render verbatim; the
narrow `readServerMessage` reader exists so that path can never degrade to the slug.

Test floor 715 -> 766: nothing was removed. Three existing tests changed their
expected STRING, which is the point of the change.

* Two entries said nobody sees this failure, and two commands were printing it

An independent read of the previous commit found the registry lying. It classified
`read-decisions` and `ingest-decisions` as "a failure here is never shown to
anybody", with a plausible sentence each — and both were false. `backthread sync`
prints one and `backthread capture --manual` prints the other, so a person was
still reading:

  backthread sync: read-failed — read-decisions rejected (403): not_a_member
  backthread capture: failed (persist-failed).
    ingest rejected (502): ingest_persist_failed

That is the defect, on routes the registry claimed were out of reach of it, in the
same commit that added the registry to stop exactly this. A registry whose
justifications are unverified is the failure mode it was built to end, so the fix
is the wiring, not the prose.

Both are now `failure-body` entries with drivers in the conformance battery. They
speak an OLDER vocabulary than the worker — no `reason`, and an `error` that is
sometimes a slug (`not_a_member`, `plan_limit`) and sometimes plain English
(`token expired`) — so relaying it verbatim was never right either. The strings
those two functions actually emit, enumerated from their source, are now a small
closed table mapping each to the ACTION it implies:

  backthread sync: read-failed — you're not a member of the account that owns that
  repo — ask one of its owners to invite you.

  backthread sync: read-failed — this device is no longer authorized — run
  `backthread login` again.

A code that is not on that table degrades to the plain HTTP status, never to
itself. `client_too_old` is deliberately absent: it ships a server-authored
`message` with the exact upgrade instruction, and the message branch renders that
verbatim.

Two guards also got sharper, both driven by measurement rather than by taste:

  * a BARE 410 on the in-flow answer path had no test. The status check that
    catches it is defended in a comment as load-bearing, and deleting the line was
    invisible to the whole suite — a surviving mutant. It now reads back the
    "nothing is owed and nothing is missing" reassurance instead of "HTTP 410".
  * the centralisation guard knew about `workerBaseUrl()` and `functionsBaseUrl()`
    but not about the exported ORIGIN CONSTANTS beside them, so
    `new URL('/x', DEFAULT_WORKER_URL)` built an endpoint the registry would never
    have seen. Both spellings are covered now, verified by breaking it.

The CHANGELOG claim that capture was covered is corrected: only its infer leg was.
Both legs are now.

Test floor 766 -> 780. Nothing was removed; the pre-existing persist-failure test
had been asserting `/500/`, which the new sentence satisfies by accident, and now
pins what a reader actually gets.

* "Nobody prints it" is a fact about the caller, so the word now means something else

The last never-shown entry standing was onboarding-state, justified on the
grounds that its one caller discards the detail. That is true today and it is the
wrong kind of true: the module was still BUILDING `onboarding rejected (401):
invalid token`, complete and ready, and the only thing between that string and a
reader was a caller choosing not to print it. A second caller inherits a leak
nobody wrote.

So it renders like the rest, through the same table — an expired credential now
says to run `backthread login` — and `never-shown` is down to one endpoint: the
pre-send capture-scope preflight, which reads a verdict and never a diagnostic.

That one member is what makes the category checkable, which it was not before.
`never-shown` is the single disposition the registry cannot verify by driving
code, so it was a promise — and an outside read found the promise false on three
entries at once, each carrying a plausible sentence. It is now held to the strong
reading of the word: a module serving a never-shown endpoint must not read `error`
or `message` off a response AT ALL. Then there is no sentence to leak, whoever
prints what. A source-level property rather than a behavioural one, which is the
honest limit here — but it is checkable, and a promise is not.

Both halves are mutation-proved: adding a diagnostic read to captureScope.ts goes
red, and blinding the caller-finder goes red on the companion test rather than
passing vacuously over a list it failed to build.

Test floor 780 -> 786.

* The error field was never only slugs, and hiding all of it made three failures worse

A second independent read caught the founding assumption of this module being
false. `error` carries TWO kinds of thing. `retrieval_failed` is a machine code
and belongs behind --verbose. But `repo not found or not connected to
Backthread`, `not authorized to read this repo` and `no repo given and none
connected — pass repo:"owner/name"` arrive in the SAME field, and those are
sentences somebody wrote for a reader. Hiding them made the three most common
non-transient failures of how / learn / ask-me read worse than before any of this
started:

  main   query: read-failed — grounded-ask rejected (404): repo not found or not connected to Backthread
  before query: read-failed — the answer didn't come back. The server rejected it (HTTP 404).
  now    query: read-failed — the answer didn't come back — repo not found or not connected to Backthread

The predicate that tells them apart is the server's own, mirrored: lowercase,
digits and underscores is a machine code; the first space, colon or capital means
prose. It also stops --verbose dressing a readable sentence up as
`error=repo not found or not connected to Backthread`.

THE SLUG TABLE IS NOW A DEFAULT, NOT AN ARGUMENT. It was passed in by the two
Supabase-Functions call sites only — but the worker's deviceAuth emits the same
auth vocabulary, so one expired token gave two answers depending on which command
you ran: sync said "run `backthread login` again", how said "the server rejected
it (HTTP 401)". This ticket's own words — "or this recurs one endpoint at a time"
— reproduced inside the fix, by its own mechanism: something correct that each
call site had to remember. A default cannot be forgotten. All four commands now
agree.

EVERY DEAD END NAMES A NEXT STEP. "Retrying will not help" and then silence leaves
the reader with nothing to do, which is the state the ticket asks us not to leave
them in — and cli/README.md was already claiming otherwise. Which step depends on
who is reading, and the verbose switch already knows: a reader is told how to GET
the detail, an operator is told where to SEND it.

THE ENDPOINT GUARD LET SEVEN THINGS PAST. `export const`, `export async
function`, a builder not named build*Url, `base + '/path'`, an intermediate
const, a hardcoded workers.dev URL, and a driver that called describeFailure
instead of the route it claimed to test. Every one an ordinary spelling. Chasing
shapes was the mistake: all of them start by naming an origin, so the ORIGINS are
the allow-list now — outside urls.ts, claim.ts and doctor.ts (which probes bare
hosts and constructs no path), do not name one. A builder is told from a consumer
by whether it fetches, not by what it is called, and the driver table must name
its registered entry point. All seven verified caught.

Two mutants that had survived are dead. `ask_expired` is only ever sent as a bare
410, so its override entry was unasserted redundancy a server-side status change
would have silently activated wrong — it is now driven as the non-410 it is
defended against being. And the capture-scope skip printed
`capture skipped (not_a_member)`: a closed client-side enum on a 200, so not a
server diagnostic at all, but a slug in front of a person however respectable its
provenance. It reads as a sentence now, and the table is held exhaustive against
the enum declaration.

Test floor 786 -> 798.

* A refusal is not a bug, and five more ways past the guard that was supposed to be one

A third independent read. Every finding was real.

THE BUG-REPORT TAIL WAS APPENDED TO REFUSALS. Naming a next step after
"retrying will not help" was right; appending it to everything was not. A lesson
rate limit, an answer that belongs to somebody else, a repo this device cannot
write to — all deliberate answers from a working system — ended with "report what
that prints at github.com/…/issues". Confidently wrong is worse than silent. The
tail is now gated on the status: a 5xx is plausibly ours, a 4xx is the server
answering. And it said "the detail above" while the operator suffix is appended
to the END of the same line, so it now says where the detail actually is.

Thirteen more slugs are mapped, enumerated from both servers rather than guessed:
the repo gate, the four bad-request codes (which are OUR bug and now say so
rather than inviting the reader to look for their own mistake), the deliberate
refusals, and the auth vocabulary. Measured, three of them:

  0.20.0  backthread learn: lesson start rejected (429): lesson_retry_too_soon
  now     a lesson for this repo was built very recently — give it a while before asking for another.

  0.20.0  ingest rejected (500): duplicate key value violates unique constraint "decisions_pkey"
  now     the write didn't complete on our side. Nothing was saved; try again.

  0.20.0  ingest rejected (403): repo_not_writable
  now     this repo is connected to an account this device can't write to — ask one of its owners to invite you.

The second of those also kills a claim this module made about itself. It said a
relayed body can never carry `message`, because the worker's allow-list drops the
key. True of `failureBody` — and two of the routes rendered here do not go
through it, and pair a slug with `(e as Error).message`. So a Postgres string was
arriving as product copy. The fix is not a cleverer reader: both slugs are on the
table, which is consulted first.

FIVE MORE ESCAPES FROM THE ENDPOINT GUARD, and one from the guard on the guard:

  * doctor.ts could host a complete unregistered endpoint — it was on the origin
    allow-list, so it could name an origin, and outside the builder scan, so
    nothing asked what it did with one. Permission to name an origin was being
    read as permission to skip the registry;
  * reading BACKTHREAD_WORKER_URL straight off env dodged a token list that only
    knew the helpers;
  * a private fetch helper appended AFTER a new builder in claim.ts hid it,
    because the scan skipped any part containing a fetch call and both landed in
    the same part. A builder is now told from a consumer by its own RETURN TYPE,
    which belongs to one declaration and cannot be smuggled in from a neighbour;
  * own-slug-map was never-shown with a different label: 61 characters of prose
    and nothing checked. It is now DRIVEN — hand it a slug its map never heard of
    and watch whether the slug comes back out;
  * the never-shown diagnostic guard was dot-notation over a fixed list of
    variable names, so rec['error'] walked past;
  * and the check that each driver drives its registered entry point was
    satisfiable by naming the function in a trailing comment.

FOUR SURVIVING MUTANTS, all now dead. The lesson 409's fallback was untested
because its only test sent `message` AND `error`, so reverting to the old helper
still picked the message — while a 409 without one printed `lesson_in_progress`
to a person. The caller-written `lead` could be deleted from two of four branches
in silence, and the lead is the entire mechanism behind different commands naming
different things. And the two capture-scope sentinels were type-kills only, one
of which is reachable the day the server adds a scope reason.

Test floor 798 -> 809.

* A prototype is not a sentence, and advice about owners who do not exist

A fourth independent read. Four user-visible defects and six more guard escapes.

THE LOOKUP WAS A BARE INDEX INTO AN OBJECT LITERAL, so a server sending
`error: "toString"` made the ENTIRE line the reader saw:

  backthread learn: function toString() { [native code] }

No lead, no verdict, no status, no next step. Eight `error` values did that —
constructor, __proto__, valueOf and the rest — and --verbose did not rescue any
of them. The module's own doc promises it never trusts the payload's shape; an
inherited property is exactly the shape it forgot. Own-properties only now, on
both tables, and the eight are a named test.

TWO SENTENCES GAVE ADVICE ABOUT PEOPLE WHO DO NOT EXIST. `repo_not_writable` and
`not_readable` fire on exactly one condition in every producer — the repo has no
owning account — and both said "ask one of its owners to invite you". The scope
table said it a second time, and the changelog a third. Read the producer before
writing the sentence: they now say the repo has no owning account and what to do
about that.

THE MCP SURFACE WAS TOLD TO PASS A FLAG IT HAS NO COMMAND LINE FOR. The next step
said "run it again with --verbose" everywhere — including the one surface this
module's own doc names as the reason the switch is an env var. It now names both
spellings, which is true on every surface. Same class as "the detail above",
which this branch already fixed once.

`ask_bad_signature` is mapped. The token verifier has three verdicts and emits
`ask_${reason}` for each; two were covered, so its identical sibling on the same
server line read "the server rejected it (HTTP 400)" with no next step.

SIX MORE ESCAPES, and the pattern in them is the same one every round:

  * the file walk was a FLAT readdir, so everything under cli/src/bin/ — and any
    subdirectory a future author creates — was invisible to every guard here;
  * a new module could derive its endpoint from an existing builder
    (`buildLessonStartUrl(env).replace('/lesson/start','/lesson/next')`) or invent
    a host on an unlisted domain, and neither names an origin, so the origin ban
    never fired. Both start by CALLING FETCH, which is the property — a request
    leaving this package — rather than any of the shapes a URL can be spelled in.
    So the fetchers are named, and adding to that list is a deliberate act;
  * three ways past the builder scan's return-type test: no annotation at all, an
    alias for string, and returning a URL. All ordinary spellings. The scan now
    uses brace-matched extents and asks whether the declaration FETCHES, which is
    what actually separates a builder from a consumer;
  * the fake-driver guard, having been closed against comment placement, was
    reopened by dead code. Text is arguable, so the drivers are now OBSERVED: the
    fetch each is handed counts its own calls, and a driver that never made a
    request cannot have driven a route whatever its source says.

Three survivors are dead. The "every entry is reachable" test iterated the table
and asserted things about what it found — a restatement, not a check, and seven
entries were deletable in silence. The vocabulary is now a CLOSED written-down
contract asserted set-equal to the table, which is the best a package that cannot
import the server can do, and it fails in both directions. The override-beats-
global direction is asserted. And the ban on private copies of the old helper
looked for `function serverMessage(` — which saw two of the seven modules that
had it; the other five inlined the same nested ternary, which is the form a future
author is likeliest to reach for. The SHAPE is banned now, and a test proves the
pattern still recognises the exact text that stood in those seven modules.

Also: four dead SLUG_COPY keys removed (routes this package never calls, so no
test could reach them and "exhaustive" quietly meant nothing), the dead
`status === 0` clause removed, and three doc claims corrected — "five routes"
(seven, five reachable), "three modules" (seven), "byte-identical copy" (it
wasn't), plus the changelog's "a 4xx never does", which is now true by
construction rather than by nothing-sends-that-yet.

Test floor 809 -> 817.

* Nobody writes copy for a 500, and four sentences described conditions their servers do not have

A fifth independent read.

RAW POSTGRES TEXT WAS STILL REACHING A PERSON. The previous fix mapped the two
slugs that pair themselves with an upstream string — slug-specific, and the
general path was wide open. Three Edge Functions this package calls do
`json({ error: message }, 500)` with the caught string, so it arrives in the
CODE's own field, fails the machine-code test, and rendered as a sentence
somebody wrote for a reader:

  backthread sync: read-failed — the decision log didn't sync — could not
  serialize access due to concurrent update

"Not a machine code" was never the same question as "written for a person", and
the status answers the real one: nobody writes reader-facing copy for a 500. A
4xx `error` string is the server answering the caller's own request and still
renders verbatim — that was a measured regression once and stays fixed. A 5xx
`error` string is a relayed diagnostic and is treated as one: the reader gets "it
failed on our side" plus where to report it, and the operator still gets the
string, because on that path they are the only one who can use it.

FOUR SENTENCES DESCRIBED CONDITIONS THEIR PRODUCERS DO NOT HAVE:

  * ask_bad_signature said "not issued to this device" — but a token issued to
    another device verifies fine and comes back as ask_not_yours. The two
    sentences described each other's conditions;
  * lesson_retry_too_soon said "a lesson was built very recently" — the opposite
    of the truth. It fires when the last few paid builds left NO lesson behind;
  * plan_limit said "decision limit", which is the wrong noun (it is a capture
    SESSION allowance) and echoed a pricing model that was retired;
  * not_readable fires on the identical condition as its sibling and was the only
    one of the pair left with no remedy to name.

`ask_material_moved` is on the global table now — it is global on its route, and
sitting only in the route-local overrides made the "absent on purpose" list wrong.
`forbidden`, `repo_not_connected` and `unauthorized` are gone: no reachable route
sends any of them, and `forbidden`'s sentence had re-committed the "ask an owner
who does not exist" defect in a sibling of the entry written to fix it.

THREE MORE ESCAPES, and the pattern is the one every round has had — a guard
aimed at a spelling rather than at the property:

  * `const send = deps.fetchImpl ?? globalThis.fetch` walked past a ban on
    `fetch(`. A module that cannot NAME fetch cannot call it however it spells the
    call, so the identifier is the allow-list now (string literals excluded — a
    log line reading "state fetch failed" cannot send anything, and flagging it
    would teach the next author that the ban is noise);
  * `not-a-fetch-target` was the one category with NO check at all, not even the
    prose bar. A real worker endpoint registered as "just a link we print", then
    fetched, was entirely green. A link is a claim about behaviour, so it is asked
    as one;
  * `const { error: slug } = rec` walked past a diagnostic-read guard that knew
    dot and bracket notation.

THE COPY IS NOW ASSERTED SENTENCE BY SENTENCE. Fifteen of these were pinned only
by "longer than twenty characters, doesn't name itself, isn't the fallback", so
rewriting one to say something wrong stayed green — and two mutants that reverted
the LAST commit's headline fix were invisible. Same for the per-route leads: the
test asserted six of nine were distinct while its own comment claimed a lead
deleted anywhere would collapse the set, and four collapses survived. Both are
named tables now.

--verbose also means something again on the two paths that answer before the
renderer is reached — the 409 and the bare-410 — which are right to write their
own sentence but were making the switch a lie on the two paths somebody is most
likely to re-run.

Doc claims corrected: the changelog quoted a fenced "example output" line that
exists nowhere but the changelog (the wording the previous commit removed for
being unfollowable on MCP), "verbose adds, never replaces" (it replaces the
next-step clause), "a dead end always names the next step" (a mapped refusal
names its own remedy instead), and the --help + README line claiming the HTTP
status is hidden by default when the fallback prints it.

Test floor 817 -> 826.

* Endpoints are born in one module now, because chasing spellings never ends

A sixth independent read. One ship-blocking finding, and it is the same finding
as every round before it wearing new clothes.

TWO ORDINARY SPELLINGS STILL ADDED A COMPLETE UNREGISTERED ENDPOINT, green on the
whole suite, rendering a raw slug to a person:

  * route the origin through a one-line private helper inside urls.ts and the
    builder's own brace-matched extent is clean, so the scan does not call it a
    builder. The comment two lines above claimed "a const base = workerBaseUrl(env)
    hop" was closed. It was closed at module scope only;
  * write ONE function that both builds a URL and fetches it, in claim.ts or
    doctor.ts. A declaration containing fetch( was classified a consumer and
    skipped — and that is the codebase's own house style, just with the URL built
    inline.

Three shipped claims said otherwise, including the release notes.

The fix is to stop discriminating. Every previous version of this scan tested a
SHAPE — the name, then the return type, then whether an origin appeared in the
body — and every version was walked past by something an ordinary author would
write. So `buildExchangeClaimUrl` moved into urls.ts, and urls.ts is now the only
module where an endpoint can be born. It is already forbidden to fetch, so "built
here" and "used there" can never be the same declaration. Every export of it that
is not an origin helper must be in the registry, with no test of what its body
says or what its signature returns. There is nothing left to spell differently.

doctor.ts keeps permission to name an origin because it probes bare hosts for
reachability — and now must construct no path, which is a stronger statement than
the scan it replaces. claim.ts loses the permission entirely, so the build-and-
fetch shape there trips the origin ban.

One more raw diagnostic, found in the same read and outside this ticket's literal
scope: `backthread login --claim` printed

  Exchange failed (HTTP 500) — permission denied for schema private

That endpoint's 500 arm pairs its slug with the caught upstream string, and the
default branch relayed `message` unconditionally. Same rule as everywhere else now
— a 4xx message is the server answering the caller, a 5xx message is a diagnostic
— and the line gained the recovery instruction its siblings already had.

Also: the poll endpoint's states were misnamed in its justification (pending /
ready / expired / consumed, not "claimed"); the exchange entry's justification
described only its mapped-slug branch and was silent about the default one; the
two-sentence rationale contradicted the prose branch, which deliberately joins
with a dash because a server-authored sentence is an appositive and somebody
else's words; and the order of the two authored-text readers was unasserted, so
swapping them survived. All named tests now.

Test floor 826 -> 830.

* The guard could not see a URL, because its comment-stripper ate every one of them

A seventh independent read. Five more spellings past the endpoint guard, and the
root cause is one line I wrote six rounds ago.

`stripComments` was `src.replace(/\/\/[^\n]*/g, '')`. That eats from the `//` in
'https://app.backthread.dev'. So every guard built on it — including the one
that bans hardcoded URLs — was reading truncated source and structurally could
not see the thing it existed to find. It is a scanner now: a comment and a string
can only be told apart by walking the file. That alone is why the "no hardcoded
URL" rule had never once fired.

The five escapes, each a complete unregistered endpoint the CLI requests and
whose failure it prints as a raw slug:

  * `buildLessonStartUrl(env).replace('/lesson/start', '/lesson/next')` in
    lesson.ts. Naming the fetchers stops a NEW module; inside one already on the
    list nothing asked what the request was pointed AT. And /lesson/next is a real
    worker route that emits the contract, so this was the ticket's own defect one
    ordinary line away;
  * a literal host on a domain the host pattern did not list;
  * a builder named DEFAULT_LESSON_NEXT_URL — the exemption for origin helpers was
    a NAME SHAPE, `/^…|DEFAULT_\w+_URL$/`, which is the exact mistake every prior
    version of this scan made. Six names, written down, is what it should always
    have been;
  * `function buildX(){…}` plus `export { buildX };` — an ordinary spelling the
    export matcher never looked for;
  * an origin interpolated in front of a path in doctor.ts, which the broken
    stripper erased before the guard could look.

The property is "every URL a request is handed traces to a registered builder
call", and it is now checked as that rather than as a list of shapes: no literal
URL in any module that can fetch, and no surgery — replace, concat, slice, `+` —
on a registered builder's result ANYWHERE, since the edited URL only has to reach
a network module to be requested and does not have to be edited in one.

A LITERAL URL IS STILL FINE WHERE NOTHING CAN FETCH. This module points a reader
at an issue tracker and `sweep` builds a GitHub clone URL; banning those would be
a vocabulary rule rather than a safety one, which is how a guard earns the
reputation that gets it deleted.

Two things a person reads:

  * the changelog said "Only the first is hidden" of the two kinds of thing
    `error` carries — false since the previous commit, which hides prose on a 5xx.
    It now says which kind and on which status;
  * `login --claim` printed `…Upgrade to connect a capture device.. Generate a
    fresh code at … and try again.` — a double stop and "try again" twice with
    different antecedents. When the server's sentence already ends in one, it
    speaks alone.

And one for whoever is fixing things: a 5xx `message` was withheld from the reader
(right — it is upstream text) and from the operator too, which just lost it. It is
in the --verbose suffix now.

Test floor 830 -> 835.

* Follow the value, not the syntax — and a 500 is never the reader's fault

An eighth independent read. One regression I caused last round, eight more ways
past the guard, and two invariants that rested on a table staying exhaustive.

THE REGRESSION IS MINE. Last round I gave `login --claim`'s unmapped arm the
recovery instruction its siblings had. On a 500 that is three wrongs in one line:
it points the reader at their own claim code as the cause of a server fault, tells
them to do work that cannot help, and — because the same commit had stopped
relaying `message` there — loses the diagnostic for the operator too. That arm now
goes through the shared renderer, which already knows how to say all of it:

  0.20.0  Exchange failed (HTTP 500) — permission denied for schema private
  now     this device could not be authorized. The server rejected it (HTTP 500).
          If it keeps happening, set BACKTHREAD_VERBOSE=1 … and report what that prints
  now -v  … [status=500 error=exchange_failed message=permission denied for schema private]

TWO INVARIANTS RESTED ON A HABIT, which is what this ticket exists to stop:

  * `readAuthoredError` was status-gated and `readServerMessage` was not, so "no
    raw database text" held only while SLUG_COPY stayed exhaustive over every 5xx
    slug that pairs itself with an upstream string. One new server slug re-opened
    it. Both fields are gated now — nobody writes reader-facing copy for a 500, in
    either of them;
  * the global slug table outranked the wire's own retry verdict, and two of its
    sentences end "try again" — the CLI overruling the server about the one thing
    the server knows better. `unavailable` now outranks the global table. A
    ROUTE-LOCAL override still wins, because it exists precisely because that one
    route knows something the wire does not.

EIGHT MORE ESCAPES, and I have now been told four rounds running that the guard
was a list of shapes. It was: `replaceAll` where `replace` was banned; one `const`
hop before the edit; `new URL(path, base)`; URL-object `pathname` surgery; the app
origin plus a path (app.backthread.dev really does serve /api/*); an address parked
in a non-network module and imported into one; `export let`; and an origin bound
to a local then interpolated.

The rule follows the VALUE now. Every identifier in a file that traces — through
any number of bindings — to a registered builder or an origin helper is collected
first, and then editing or interpolating ANY of them is what is banned, not a list
of method names next to a call. Plus: no `new URL(` in a module that can make
requests, and no literal address written down ANYWHERE, since where a URL is
written and where it is requested need not be the same file. Two files keep a
literal because it is a link and not an endpoint — the issue tracker a dead end
points at, and the GitHub clone URL sweep builds — and both are named, so a third
is a decision somebody makes rather than a side effect of where they typed it.

The eight spellings are fixtures in a vacuity test, so if the rule ever stops
matching one of them it has quietly become the pattern-matcher it replaced.

Also from that read: the last unpinned copy table. SCOPE_REASON_COPY is printed by
`capture --manual` and was guarded only by non-empty and not-self-mapping, so three
of its sentences could be swapped for each other's in silence — four surviving
mutants. It is asserted sentence by sentence now, like SLUG_COPY. The appositive
dash is pinned. `ask_material_moved` no longer exists twice. A single word with no
space is no longer treated as a sentence somebody wrote for a reader. And the
worker's RESERVED_KEYS is a deny-list, which is what this file now calls it.

Test floor 835 -> 840.

* The discriminator was never the status, and a guard cannot prove its own soundness in prose

A ninth independent read. One regression, more escapes, and a claim I should have
stopped making rounds ago.

THE REGRESSION IS MINE AGAIN, AND IT IS THE SAME MISTAKE IN THE OPPOSITE
DIRECTION. Last round I sent every 5xx on the claim exchange through the shared
renderer, on the grounds that nobody writes reader-facing copy for a 500. That
endpoint has TWO 500 arms and they are opposites: `exchange_failed` pairs its slug
with the raw upstream string, while `mint_failed` pairs it with a sentence
somebody wrote on purpose — "… — the claim code is spent; generate a fresh one." —
and by then the code really is burned, so a fresh one is the only recovery there
is. I replaced the one true instruction with an invitation to retry and file a bug.

  0.20.0  Exchange failed (HTTP 500) — could not allocate token — the claim code is spent; generate a fresh one.
  now     Exchange failed (HTTP 500) — could not allocate token — the claim code is spent; generate a fresh one.

The discriminator is the SLUG, not the status. Both arms are now pinned, in
opposite directions, so neither generalisation can be made again.

TWO MORE ESCAPES, and I have been told five rounds running that the rule was still
a list of shapes:

  * a RENAMED import. `urlBindings` followed `const|let|var` and not `import { X
    as Y }`, so one rename stopped the trace at the door;
  * `\bfetch\b` does not match `fetchImpl`, so a module taking the sender as an
    injected dep never tripped the network allow-list at all. That is a plain bug
    in a regex I wrote, and it made the "declared fetchers" rule optional for
    anyone using this codebase's own dependency-injection style.

Plus seven shapes that were plainer than anything in my fixture list: `String(b)
.replace`, bare parentheses, `(b ?? '')`, an object property, a function-return
hop, `new globalThis.URL`, and `${base}${'/path'}`. The rule follows the value
through bindings, imports and function returns now, and asks whether an edit verb
appears anywhere in the same STATEMENT rather than adjacent to a name.

AND THE RULE AND ITS OWN PROOF WERE TWO TRANSCRIPTIONS OF ONE IDEA. The "not
vacuous" test carried a copy of the regexes, and they drifted — so the fixture
list went on passing against a version of the rule the package no longer used.
One implementation, called by both.

WHAT I HAVE STOPPED CLAIMING. Four rounds running, an outside read has falsified
some absolute form of "nothing can get past this", and each time I closed the
spelling and re-made the claim. The claim was the defect. What is true, and is
what the file and the changelog now say: the ordinary ways to add an endpoint are
red at three independent points — an unregistered builder, an undeclared fetcher,
and a URL that was invented or edited. A determined author can still dodge a
source-level trace, and a reviewer should still read a diff that adds a request.
What this stops is FORGETTING, which is what actually happened: the defect this
whole change exists for sat on five routes at once because nothing ever asked.

Test floor 840 -> 842.

* Half that sentence was written for a reader and half was a Postgres error

A tenth independent read, and the last three findings are small enough to name in
one line each.

`mint_failed`'s server message is `${lastFail} — the claim code is spent; generate
a fresh one.` I relayed the whole thing last round because the suffix is
deliberate copy. It is — but `lastFail` is `error.message` from the register RPC,
raw Postgres on one of its two arms, so a reader got:

  Exchange failed (HTTP 500) — permission denied for schema private — the claim
  code is spent; generate a fresh one.

The recovery is the half that matters and it never varies, so the client writes
it now and the server's string goes behind --verbose with everything else:

  That claim code is spent — it was consumed even though the device could not be
  authorized. Generate a fresh code at https://app.backthread.dev and try again.

I ALSO SAID I HAD STOPPED CLAIMING THE GUARD WAS UNEVADABLE, AND THEN DID NOT.
The narrowing went into a commit message; the shipped comment still ended "there
is nothing left to spell differently", and a test was still named "nothing can
dodge the registry". Both are now what is actually true: the ORDINARY ways to add
an endpoint are red at three independent points, it is a source-level trace rather
than a type system, a determined author can still dodge it, and what it stops is
forgetting — which is what actually happened, because the defect this change
exists for sat on five routes at once with nothing ever asking. The changelog says
the same. `export default` is closed as well, since it cost one alternation.

Two surviving mutants, both on lines this branch added and both reader-visible:
deleting the "generate a fresh code" tail from the unmapped-4xx arm left a status
and no next step; and turning the verdict's full stop into a dash gave a line
three em-dashes, which is the thing the module argues against by name. Pinned.

Also: the poll endpoint's states were misnamed in its justification again
(pending / ready / expired / consumed), and a comment counted two
answer-before-the-renderer paths where there are more.

Test floor 842 -> 844.

* A regex literal with one quote in it blinds every guard below that line

An eleventh read cleared this to ship and left one thing worth doing anyway.

`stripComments` walks strings but knows nothing about regex literals — and it
cannot cheaply be taught to, since telling `/['"]/g` from a division needs a
parser. So one unbalanced quote inside a regex desynchronises the scanner and
everything after it reads as string content, which blinds the hardcoded-URL check
for the rest of that file. Measured: a literal address placed after such a regex
in `lesson.ts` went green; the same address above it was caught.

No file trips it today. What makes that a fact rather than a hope is a probe:
append a sentinel address to each file's source in memory and assert the check
still sees it. A future edit that desynchronises the scanner turns THAT red,
wherever it happens, without anybody having to remember why — which is the only
kind of guard this change has any business shipping. Mutation-proved by adding
`const __Q = /['"]/g;` to a network module.

Two changelog counts corrected: "three" routes put the upstream string in the
code's own field on a 500 (five do; three of them reached a reader, which is what
I meant and not what I wrote), and "every export must be in the registry" rounds
off the origin helpers that the source comment names precisely.

Test floor 844 -> 845.

* Inspecting the tarball found the ticket's own example string still in it

The pre-publish check was to run the packed artefact rather than the worktree, and
grepping the bundle for the strings this change was supposed to remove turned up
`grounded-ask rejected (` still in there. Not a stale bundle — a path I had never
covered.

`describeFailure` renders every failure that came back WITH A BODY. A timeout, a
dead socket, or a retry loop that gave up have no body by definition, so those
paths kept their original wording and nobody had looked at them:

  0.20.0  query: read-failed — grounded-ask request failed: fetch failed (after 2 attempts) — try again.
  now     query: read-failed — the answer didn't come back. Backthread could not be reached — check your connection and try again.
  -v      … [route=grounded-ask attempts=2 cause=fetch failed]

That is a bare internal route name in front of a person, on the very command the
ticket names, and "no slug came off the wire" was never the same as "this is
product copy". Four sites had it: the grounded ask, the decision-log sync, the
capture write-up and the setup check. The route and the transport error are
operator fields like every other diagnostic and go where those went.

TWO KINDS, NOT THREE. A first draft had a `server` kind for "every retry returned
a 5xx" — and that state does not exist. The retry loops break on the LAST attempt
with the response still in hand, so a run of 5xx ends at `describeFailure` with a
real body, which is strictly more informative. An unreachable branch is exactly
what this change keeps finding in other people's code, so it is gone and the fact
that makes it unreachable is now a test.

Verified from the packed artefact rather than the tree: `npm pack`, extract,
install into a clean directory, run that binary. The bundle in the tarball is
byte-identical to a fresh build of the final src (sha 57d3e22f…), and the only
occurrences of the old strings left anywhere in it are two comments quoting the
defect they document.

Test floor 845 -> 847.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

v0.20.0

Toggle v0.20.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
The symlink-escape guard now fails when it stops working (#172)

* The symlink-escape guard now fails when it stops working

A verifier mutation-tested the extractor and one mutant survived in the worst
place: `lstatSync(p)` → `statSync(p)` in `enforceSourceBudget`'s walk — making
the symlink-escape guard FOLLOW symlinks, precisely what its own comment says it
must never do — left the whole suite at 2175 passing, exit 0. The path-traversal
guard had no test that failed when it broke.

It matters more than a normal coverage gap. `safety.ts` runs inside the ephemeral
container before extraction, against untrusted repositories, and is the only
thing between a hostile repo's symlink and the container filesystem outside the
clone — a container that also holds a GitHub token. It ships in the public MIT
package whose selling point is that source never leaves the machine, so it is
exactly the file an evaluating engineer reads to check whether we mean it. A
silently-broken traversal guard undercuts the claim the package was published to
make auditable.

REAL TEMP TREES, NEVER A MOCKED `fs`. The guard's whole job is what the
filesystem actually reports — `lstat` versus `stat`, what `realpath` resolves a
link to, whether a dangling link throws — and the surviving mutant is a one-word
difference between two of those calls. Every fixture is built on disk, including
actual symlinks out of the root, one of them relative (`../../`), which is the
shape a repository can really commit.

`realpathSync` on the fixture root is load-bearing rather than tidy: on macOS
`os.tmpdir()` sits under `/var`, itself a symlink to `/private/var`, and the
guard compares resolved targets against a resolved root. A fixture keeping the
unresolved path would make every in-tree symlink look like an escape — passing
for the wrong reason, and still passing with the guard removed.

TWELVE MUTANTS, TWELVE KILLS, against a baseline verified green first:

  lstat→stat (symbol-preserving, so the kill is behavioural and not an
  unresolved-symbol crash: 8 failures) · the escape rejection deleted · the
  separator dropped from the prefix match, which is the `/tmp/clone-12345-evil`
  bypass · maxFileBytes raised · each of the three budget comparisons inverted
  `>` → `>=`, which a single "too big throws" test cannot see · a SKIP_DIRS entry
  removed (twice — it must not throw, so the budget is the instrument) · every
  directory skipped · the dangling-link catch widened to swallow escapes.

The skip-list block carries its own negative control: without one, a mutant that
skipped EVERY directory would satisfy all six entries by counting nothing at all.
The symlink block carries two: an in-tree link must still be ACCEPTED, and a
dangling one ignored, or a guard that threw on every symlink would pass the
escape tests and break every repo that vendors a link.

And the harness asserts its own preconditions: that `symlinkSync` really produced
a symlink (a runner without the privilege could silently make a copy, and every
escape test would then pass over a plain file) and that the "outside" tree really
is outside the root.

Floor for @backthread/extractor: 2175 -> 2197.

* Four mutants survived the suite that exists to catch survivors

Review mutation-tested this PR's own tests and found four of its guarantees
unpinned. A change arguing "a guard with no failing test is not guarded" shipping
four surviving mutants is the same defect one level up, so all four are closed.

THE ORDER OF THE TWO CHECKS IS LOAD-BEARING, and nothing said so. `lstat` and the
symlink branch run BEFORE `SKIP_DIRS`. Hoist the skip check above them and every
existing test still passed — while an attacker gains six names they choose
(`.git`, `node_modules`, `dist`, `build`, `.next`, `coverage`) that walk straight
past the traversal guard. `ln -s /etc node_modules` is a one-line repository.
Pinned now for every name on the list, not one of them.

THE DEFAULT BUDGET WAS THE ONE PATH NOBODY TESTED, and it is the only one that
ships: nothing calls `enforceSourceBudget` with a budget, so every cap comes from
the default parameter — which could be widened to Infinity with the suite green.
Covered now by one file a byte over the real `maxFileBytes`, called the way the
container calls it, with its at-the-cap pair so it is the cap biting rather than
"a big file throws".

THE SELF-LINK BRANCH: deleting `target !== rootReal` makes `ln -s . self` fail the
ingest of an otherwise innocent repository, silently. Repos really do commit that.

AND THIS FILE'S OWN REASONING WAS WRONG. It claimed fixtures must pre-resolve
their root or in-tree symlinks would look like escapes. False —
`enforceSourceBudget` calls `realpathSync(root)` itself. Because every fixture
pre-resolved anyway, replacing that call with `resolve(root)` survived the whole
suite. A test resting on a false premise is a test nobody can maintain, so the
note is corrected and an unresolved-root case now covers the mutant on both
halves: an in-tree link still accepted, an escape still caught.

Three smaller ones, all real: the only negative control was the file's only bare
`toThrow()`, so it went green on any error including a broken fixture — it names
its error now. A precondition described an `lstat` check in prose and never made
the call, which is precisely the defect under test. And cleanup moved off a
top-level `afterEach`: under `--experimental-test-isolation=none` that registers
on the shared ROOT context and fires around every test in the run rather than
this file's, so each test now owns and disposes of its own fixtures in a
`finally` — no reasoning about the runner's context model required.

Nine mutants re-run against a green baseline, nine kills, including all four
survivors. Floor 2197 -> 2201.

* A guarantee pinned only on the author's machine is not pinned

Verification ran 36 mutants and found the resolved-root test platform-dependent.
It relied on `os.tmpdir()` being reached through a symlink — true on macOS,
where `/var` links to `/private/var`, and FALSE on the Linux runner CI actually
uses, where it is plain `/tmp`. Measured: with the `realpathSync(root)` →
`resolve(root)` mutant applied under a non-symlinked TMPDIR, the suite stayed at
26 of 26. The test was not flaky; it simply stopped testing anything.

So the fixture now builds its OWN symlinked route to the root and passes that as
the root. The symlink is ours rather than the operating system's, so the mutant
dies everywhere.

AND ONE GUARANTEE IS RECORDED AS UNPINNED RATHER THAN QUIETLY MISSED. The
docstring promises a throw on the FIRST violation, and for a zip-bomb guard
aborting mid-walk is most of the value — but hoisting the two count checks out of
the loop leaves every test here green, because the same error is still raised,
just later. No honest deterministic test exists: `readdirSync` order is
unspecified, so every "the walk stopped before reaching X" fixture depends on
which entry the filesystem returns first, and the only order-independent
instrument is a timing assertion, which is flaky by construction. A guard that
fails on a fast machine and passes on a slow one is worse than a stated gap.
Pinning it needs an injectable readdir counter — a change to safety.ts's
signature for testability, which is a decision for whoever needs the guarantee.

Two further survivors are proved EQUIVALENT rather than missed (the `continue`
after the symlink branch; `walk(resolve(root))` → `walk(root)`), and a third —
`else if (st.isFile())` → `else` — is reachable only by a FIFO, socket or device
node, none of which git can check out, and makes the guard stricter. All three
are named in the file so the next mutation run does not re-discover them as
findings.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

v0.19.1

Toggle v0.19.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Say what a lead can see, at the moment we ask someone to supply data …

…about themselves (#170)

* Say what a lead can see, at the moment we ask someone to supply data about themselves

The `ask-me` promise said "your lead sees what the team understands, never who
replied". Both halves were false.

X was the team's AGGREGATE, and it is not the upper bound any more: a lead sees what
each NAMED person understands, as a split. Y was "who replied", which is inferable —
credit for a demonstrated answer exists only where that person's graded row does, so a
non-zero figure beside a login says they replied.

The server statement was corrected already; the CLI's three copies of it were not. This
aligns them, quoting the server rather than paraphrasing it:

  "Your lead sees what each person understands, never how much anyone took part.
   'Did not answer' and 'was not asked' are the same thing here, because neither one
   was recorded."

That version is checkable. Participation volume genuinely is not measured anywhere, and
the collapse of "did not answer" into "was not asked" is what makes the not-an-exam
claim falsifiable rather than reassuring.

The README is the sharpest case: it says the statement "comes from the server that
enforces it, so it cannot drift from the behaviour it describes the way a README can" —
and then its own one-line outline had drifted. So the three copies are now pinned
against each other by `promiseWording.test.ts`, which asserts the claim as a SHAPE (so
punctuation may differ), bans both retired wordings by name, and carries a negative
control proving the pattern rejects the old sentence and accepts the new one. Without
that control, a regex matching nothing would pass every assertion in the file.

Mutation-tested, 5 mutants, all killed: README reverted · ask-me.md reverted · fixture
reverted · README drops the sentence entirely · the falsifiable half dropped from the
fixture.

A false privacy promise is worst exactly here — at a consent moment, in the text someone
reads while being asked to supply data about themselves — and it is the easiest kind for
a reader to discover.

Version 0.19.0 → 0.19.1 via the lockstep script; test floor raised 723 → 729.

* Say in the file what a green run does not prove

REVIEWER [low] — the fixture comment calls the server "the only place this statement is
authoritative", and nothing in this repo checks the copy against it. The test pins the
three copies TO EACH OTHER, so all three can be consistently stale — a smaller version of
the failure this change fixes, where the README asserted the server was authoritative
while its own outline had drifted.

The alignment was verified by hand for this change (the fixture string and the server's
are byte-identical, 171 characters, same quoting), but that check lives in a terminal and
this repo cannot import from the private one.

So the limitation is now stated in the file header, along with what it would cost to
close it: fetching the live --promise output in CI means a network call and a signed-in
session on every run, which is worse than a documented manual step.

---------

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>

ci-v0.3.0

Toggle ci-v0.3.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
A claim that lives only in the payload cannot be refused cheaply (#169)

Review of the ingress found the shape this fixes. The ingress cannot read a
request body until it has authenticated, checked for a replay, and
decompressed up to 16 MiB — so a claim carried only in the payload puts
every refusal that depends on it below that read. Anyone with a GitHub
account could mint a token for a repository they own and post a small gzip
that inflates to 16 MiB, repeatedly, to be told the same thing one indexed
lookup already knew.

The code now travels in x-backthread-claim as well, from the same single
read of the environment, so the ingress decides before it inflates
anything. The payload keeps its copy: that is the published contract and
the auditable record of what was sent. The ingress cross-checks the two and
refuses a disagreement, so the body can only ever agree with what
authorised the request. Sending both is required, not redundant — which is
why the source guard pins ONE claimFromEnv call feeding both.

Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>