Skip to content

Descriptor backup crypto: both on-chain formats, with conformance tests - #2

Open
benjamin-jarvie wants to merge 54 commits into
touch-up-2026-09from
descriptor-onchain
Open

benjamin-jarvie wants to merge 54 commits into
touch-up-2026-09from
descriptor-onchain

Conversation

@benjamin-jarvie

Copy link
Copy Markdown
Contributor

Stacked on #1. When that merges, GitHub retargets this to main.

This is the encryption layer for the planned on-chain descriptor backup. No page uses it yet, so nothing a client sees changes.

Two formats, because they answer different questions

crypto/descriptor.ts crypto/bip138.ts
Who can open it any k of the wallet's extended public keys any one of them
Origin joshdoman/multisig-backup (MIT) draft BIP-138, bitcoin/bips#1951 at 5af62cb
Fallback tool multisigbackup.com decrypts our ciphertext any BIP-138 wallet, Liana today
2-of-3 size 345 bytes 591 bytes, or 655 with the draft's suggested decoy secrets

The relay research put the reliable OP_RETURN budget at 600 data bytes, so those sizes are pinned by a test rather than left to be discovered by a transaction that will not relay.

Why the tests are the point

The promise to a client is that recovery never needs Bitcoin Butlers. That only holds while our bytes match the tool they would fall back to, so both suites compare against a second implementation:

  • testdata/descriptor-vector.json was generated by running the upstream multisig-backup source with its 16-byte secret stubbed. Our encoder reproduces its encrypted block and lookup tags exactly, and our decoder opens its blob with any 2 of 3 keys. The Shamir share bytes are not reproducible because splitting draws fresh randomness, so those are covered by decryption instead.
  • testdata/bip138/ holds the draft's own vectors, including its deliberate-failure cases.

Two upstream quirks are reproduced on purpose and pinned by tests, because "fixing" either one would make our ciphertext unreadable in the fallback tool:

  • A share index of 0 encodes to zero bytes, so share 0 contributes no index to its key material.
  • Fingerprint pairs are ordered by JavaScript string comparison of the arrays, which is not byte order.

Checks

make test-ts type-checks and runs both suites, and CI now runs it on every push.

Check Result
make test-ts 32 tests pass
go test ./... all packages pass
make test-e2e 103 passed, 1 skipped
make lint exit 0

The lint fix in create-app.ts was already failing on this branch before these changes. It uses the same BlobPart cast the rest of that file uses.

Adds the two encryption schemes behind the planned on-chain descriptor
backup. Neither is wired to a page yet; this is the crypto and its checks.

- crypto/descriptor.ts: the k-of-n scheme from joshdoman/multisig-backup
  (MIT), reproduced byte for byte so a client can paste our ciphertext into
  multisigbackup.com and recover with k of their extended public keys, with
  no Bitcoin Butlers software in the path. Two upstream quirks are pinned by
  tests because copying them is the whole point: a share index of 0 encodes
  to no bytes, and fingerprint pairs are ordered by JavaScript string
  comparison rather than byte order.
- crypto/bip138.ts: the BIP-138 draft, where any ONE extended public key
  opens the backup. Written against bitcoin/bips#1951 at commit 5af62cb.
  Common account paths are dropped from the encoding, which is what the
  draft's own end-to-end vectors do.
- Vectors: descriptor-vector.json was produced by running the upstream
  multisig-backup source with its secret stubbed, so our tests compare
  against a second implementation rather than against ourselves. The
  bip138/ vectors are the draft's own.
- make test-ts type-checks and runs both suites, and CI runs it on every
  push. A client's fallback tool disagreeing with us is now a red build.
- Measured sizes for a 2-of-3, which the relay research says must stay
  under 600 bytes: k-of-n 345, BIP-138 591, BIP-138 with the draft's
  suggested decoy secrets 655.

Also fixes a type error in create-app.ts that was already failing lint on
this branch, using the same BlobPart cast the rest of the file uses.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2c9fc6c6-4c2d-4405-ac68-eb287cb0e569

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Measured on Bitcoin Core v31.1 with default policy: maxdatacarriersize is
100,000 bytes, and payloads of 345 up to 99,000 bytes are all accepted as
standard. The only cliff is at 83 bytes, where Knots and Core 29 and older
stop relaying, and every format here is already past it. So the size
difference between the two formats is a fee difference of about 2 sat per
byte, not a relay risk. The test now pins the sizes and says so.
…s work

Two decisions from Ben on 2026-09-10.

Fingerprint tags stay on by default in the k-of-n format. They are the
breadcrumb an heir uses when the written transaction id is lost. A new test
does what a scanner would do: take two of the wallet's fingerprints, hash
them the way the format does, and find the tag in the payload.

BIP-138 backups now pad to seven individual-secret entries, so an onlooker
cannot count the cosigners. Seven rather than the draft's suggested five,
because it covers every wallet up to seven cosigners without jumping to
ten, and the 128 extra bytes cost about 128 sat. The unusual bucket does
not make our backups stand out, because a BIP-138 backup already begins
with the ASCII text BIP138.

Sizes for a 2-of-3 are pinned: k-of-n 345 bytes, BIP-138 591 bare, 719 as
we ship it.
A multisig wallet needs its descriptor as well as its keys. Lose the
descriptor and the keys alone will not open the wallet. This page encrypts
the descriptor so the wallet's own keys unlock it, hands the client one
line of text to put on chain, and reads it back again.

- Protect: paste a descriptor, choose who can open it (the wallet's own
  threshold, or any single key), and get the text plus its size and fee.
  Two ways to publish it: opreturnbot.com, or a prefilled Bitcoin Core
  command. Both put the same text in one OP_RETURN output.
- Recover: paste the text, or a transaction id and let the page fetch it
  from a public explorer, then paste the keys. The page names which format
  it found and how many keys that format needs.
- The encrypt half never touches the network. The recover half touches it
  in one place, fetching by transaction id, and says so on screen.
- Seven Playwright tests drive the real page: both formats round-trip,
  one key is refused where two are needed, a stranger's key opens nothing,
  and a malformed transaction id is caught before anyone is asked.

The tests caught a display bug worth naming. The crypto layer's derivation
path list is filtered the way upstream filters it, which leaves a
fingerprint glued to the path when that fingerprint happens to be all
digits, and drops the path entirely when it is not. Harmless for
encryption, wrong on screen. The page now reads the origin fields itself.
The runbook, the estate insert and the annual drill now carry the on-chain
step, and the public guide explains it to a client.

- Placement runbook: a new session step. Read the descriptor back to the
  client before encrypting, let them choose who can open it, publish
  through opreturnbot.com with its Private box ticked, then read it back
  off the chain in front of them. A backup nobody has read back is a
  guess. A new rule says Butlers pay for it, never the client's coins, and
  never two clients in one transaction.
- Estate insert: a block for the transaction id, block height, block hash,
  date and which format it uses, with instructions that work through any
  block explorer if our page is gone.
- Annual drill: read the on-chain copy back every year. It checks the
  insert, the client's keys and the chain copy in one step, and it costs
  nothing.
- Public guide: a section that says what goes on the chain, what stays
  private, which of the two formats to pick, and how to recover without
  us.
- docs/descriptor-backup-vector.md publishes the threshold vector with the
  test mnemonics, so anyone can confirm our text decrypts at
  multisigbackup.com.

The URL allowlist test caught the three new external links, which is what
it is for. The new page is now covered by that test alongside every other
page, and the three URLs are listed with the reason each one is there.
Ben's call, and he is right about the business side. The client-facing
copy was handing a reader a rival's URL as a call to action at the exact
moment they are using our page. The property still holds and is still
stated: neither format is ours, both are open, and other software reads
them. What changed is that the guide and the page no longer act as a
referral.

Where the explicit fallback tool stays, and why:

- The estate insert keeps it. That page is read on the day Bitcoin
  Butlers is not here, by someone who has never met us. Naming the tool
  is the whole job of that document.
- docs/descriptor-backup-vector.md keeps it. That file is developer
  documentation for auditors and reviewers, not client-facing. It is the
  receipt behind the claim that recovery does not need us.

On the other objection: the vector contains no sensitive data. Those are
the BIP-39 test mnemonics published in the BIP itself and used by every
wallet's test suite. They hold nothing and never will.
Published through opreturnbot.com with the Private flag: 545 bytes in one
OP_RETURN output, 671 vB, 3,355 sat at 5 sat/vB.

Verified end to end against the chain rather than against ourselves. The
bytes fetched from a public explorer match the published vector exactly,
two of the three published keys rebuild the descriptor, and one key alone
does not. The policy and derivation path read in clear text without any
key, as designed, and the 12 bytes of lookup tags are present.

Block height, block hash and the mining pool follow once it confirms.
Confirmed in block 966450, mined by ViaBTC in the first block after
broadcast. That answers the last open relay question with our own
evidence: a 545-byte data carrier reaches a miner through the general
mempool, with no pool submission and no wait.
Publishing is not proof. The page had a note telling people to come back
and check; now it has a step that does the checking.

Step 4 takes a transaction id and does three things: reads the bytes back
off the chain, confirms they are the bytes this page produced, and
decrypts them to confirm they give back the original descriptor. The
reader types nothing except the transaction id, because the keys are
already in the descriptor they pasted at step 1. Then it writes a
copy-ready block for the estate insert: transaction id, block height,
block hash, date, and which format opens it.

Publishing itself stays a hand-off. The bot's API allows browser calls
only from its own origin, verified against its CORS headers, so our page
cannot create the payment in place. The copy-and-open button plus a four
line checklist is the honest version of that, and the checklist leads with
ticking Private, which is the step people forget. mempool.space returns
access-control-allow-origin for anyone, which is why the verify half works
from any origin, including a copy saved to disk.

Three tests cover step 4 with the explorer mocked: it goes green on the
real bytes, it refuses loudly on different bytes, and it says so while the
transaction is unconfirmed. A route glob bug in the first draft let the
status call escape to the real network, which the tests caught.
The critical one was mine, and I had waved it off in a comment. The
parser assigned its share offset instead of accumulating it, which I left
matching upstream and called harmless "because this tool does not produce
multi-group descriptors". The tool does not produce them; it accepts
whatever a person pastes. A descriptor with two multisig groups encrypted
without complaint and could then never be opened, by us or by the
fallback tool. That is a permanent, public, useless backup.

Fixed both ways: the encoder now refuses a multi-group descriptor
outright, because a backup we cannot promise to open should never be
written; and the parser accumulates, so we can still read such a blob if
one exists. For a single group the two forms are identical, so byte
compatibility is untouched, and a test pins that.

The rest, all in the page:

- Step 4 compared the rebuilt descriptor against the raw input. Encryption
  drops the checksum and Sparrow exports one, so a correct backup was
  reported as wrong. It now compares without checksums.
- The one-key path tried only the first key in the descriptor, which may
  be one the format excludes. It tries each.
- Step 4 depended on session state, so it could not work after a reload,
  which is exactly what the page tells people to do. It now checks the
  chain against the descriptor in step 1, and the card is always visible.
- The explorer field was ignored and mempool.space was hardcoded, so a
  testnet descriptor always 404'd and a self-hosted user was silently sent
  to a third party. Step 4 has its own explorer field and defaults to the
  right network.
- The page shipped with no Content-Security-Policy while its siblings
  have one. It now has one with a nonce.
- A descriptor missing its fingerprints produces no search tags, which
  quietly removes a recovery route the guide describes. The page says so.

Also: truncated text used to surface a raw DOM exception from atob. It
now says what is wrong.
The guide showed Rememory's screenshots: their paper theme, their
copy, and for the QR and manifest steps a browser permission bubble
and an OS file picker. Every image now comes from this build.

The ten guide images are regenerated by the existing generator. Three
steps needed new shots, added to the same spec so they stay
reproducible: the share step with the Scan QR code button, the
scanner itself, and the manifest drop zone. The two dialog images are
gone, because a page screenshot cannot contain a browser or OS
dialog; the captions now describe what is shown and say the browser
asks for permission next. The scanner shot launches its own Chromium
with a fake camera fed from a rendered QR code on paper, at phone
size, since the guide tells the reader to scan with a phone. The Open
Graph image is regenerated at 1200x630.

Taking the scanner shot exposed a defect: the modal was painted with
var(--text) and written on in var(--paper-light), upstream's dark ink
under light text, which on our palette inverted to a light box with a
white hint nobody could read. A viewfinder is dark in every theme, so
it now uses fixed colours.
Ben's call, 2026-09-11, for search and for a newcomer's understanding
of what the tool is. "Bitcoin Inheritance" in titles and headings,
"Inheritance" in short labels such as the page logo and the guide's
home link. The te reo Māori name stays on the repository, the CLI
binary, Go identifiers and code comments, where nobody a customer
would call a reader ever sees it.

Pages, guide, README, PDF bundle title and every translation string
carry the new name. The recovery URL printed on every guardian PDF and
the pages' own base URL move to /tools/inheritance, which the site
redirects to permanently from the old path. The guide screenshots are
regenerated, so the page logo in every image already reads
Inheritance. The paragraph on the about page that explained the old
name is rewritten around the fork's origin instead.

Go tests and the 117 Playwright tests pass with the new strings.
Ben, 2026-09-11: Kaitiaki is to be removed everywhere but the
repository name. The previous commit renamed what a customer sees;
this one renames what a Butler or a developer sees.

The CLI binary is now `inheritance`: `inheritance init`, `seal`,
`bundle`, `recover`, `serve`, `html`, `demo`, `status`, `verify`. Its
help text, examples, default data directory, man page title and the
Makefile, .gitignore and Playwright setup follow. The bundle metadata
key written into every README.txt and PDF is `inheritance-version`.
The service runbooks, the guide, the README, CONTRIBUTING, NOTICE,
AGENTS.md and every code comment say Bitcoin Inheritance. The
constant behind the BIP-138 decoy count is INHERITANCE_SECRET_ENTRIES.

Left alone on purpose: the repository name and its URL; the wayfinder
decision records under docs/wayfinder, which are history; and older
CHANGELOG entries, which describe releases as they shipped. A new
changelog entry announces the rename.

Go tests pass in all eleven packages; the 117 Playwright tests pass
with the renamed binary.
The guide's "What Is Public" said the script type, threshold and
derivation paths are readable. That is true of the threshold format,
whose readable prefix is the descriptor with keys and fingerprints
removed. It is false of the one-key format: BIP-138 encrypts the whole
descriptor and leaves only the BIP138 marker, seven key slots and any
uncommon paths in the clear. The section now names the OP_RETURN output
and lists what an observer can read in each format.

The descriptor page said "Two ways" above two collapsible options, one
of them closed, so a reader saw one way. The intro now names both.
… blocks scroll on phones

The guide (internal/html/docs-content/en.md) was upstream's text with the
name swapped. It is now Bitcoin Butlers' own: what the tool is for (the
instructions around the seeds, never the seeds), the two tools on this
site, the real page labels, the estate insert, the annual drill, and the
owner key and descriptor backup as full sections. Every h3 carries an
anchor, so the contents list is complete. All 30 existing anchors are
kept. Short sentences, no em dashes, no idiom.

The descriptor claim was too loose in four places: "lose the descriptor
and the keys alone will not open the wallet". With every key still in
hand, the descriptor can be rebuilt. The loss that locks a wallet is one
key together with the descriptor, even when the remaining keys are
enough to sign. Fixed in the guide, the descriptor page intro, the
estate insert template and the changelog entry.

A wide <pre> made the whole page scroll sideways at 390px: the owner-key
commands in the guide, and the Core command on the descriptor page. pre
now scrolls inside its box, and the owner-key commands sit on shorter
lines.
A two-axis review of the rewrite found statements that contradict the
code. Each is now aligned with the source:

- A browser-made bundle of 10 MB or less holds no separate MANIFEST.age;
  the archive sits inside recover.html (internal/wasm/create.go). The
  guide no longer tells guardians to keep or send a file they do not have.
- BIP-138 entries are derived from keys, never the keys themselves, and
  padding to seven applies only below seven keys (bip138.ts).
- The descriptor page reads back the threshold and the paths, not the
  script type (descriptor-app.ts).
- The browser time lock takes a number and a unit; a date is CLI-only.
- The recovery button reads "Unlock and Recover" at runtime (en.json).
- The CLI has eleven commands; the guide names the six that matter and
  points at --help for the rest.
- The page has no chain search of its own; a technical person can search
  for the fingerprint tag.
- descriptor.html's own "How this works" bullet claimed the policy stays
  readable in both formats. It now says which format keeps what readable.

Also from the review: instructions over 20 words split, passives given an
actor, future tense removed, the last idioms replaced, "descriptor backup"
used as CONTEXT.md defines it, "the chain" and "encrypted archive" used
as the one word for each thing, and the estate letter told apart from the
estate insert. The estate insert template carries the one canonical
descriptor sentence.
Reverses the fixed seven from 89f7e07, on Ben's standing order of 2026-09-22
to ride the standards wherever one fits.

BIP-138 tells an encoder to pad the secret count to a bucket, so that counting
the entries does not count the cosigners. We wrote a fixed seven. It covered
every wallet up to seven cosigners, which was the point, and it also marked
our backups as ours among all BIP-138 backups. The buckets do the same job and
blend in.

A 2-of-3 now pads to five rather than seven, which is 64 bytes and about 64
sat cheaper: 655 bytes rather than 719. A 3-of-7 pads to ten and keeps its
cover.

This also closes a leak in the old code. padWithDecoys returned the list
unpadded once the key count passed the target, so a wallet with eight or more
keys published its exact cosigner count. The guide said so in plain words.
Every wallet now lands on a bucket.

The vector document loses its reference to the unmerged pull request. BIP-138
has a number and the status Draft.

make test-ts passes 38 of 38, including the upstream byte-compatibility tests
and every BIP-138 conformance vector. go test ./... passes.
addSection drew the heading with CellFormat, which does not wrap. A heading
longer than the line was clipped at the right margin and the rest of the words
were dropped. On page 1 of every bundle the English heading "OTHER GUARDIANS
WHO HOLD PIECES (contact them to coordinate a recovery)" lost its closing
bracket.

MultiCell wraps and carries the grey fill onto the second line. The fix covers
every section heading in every language, not only the one that was visibly
broken.

Found while prototyping where the recovery steps land in a bundle. Verified by
regenerating a demo bundle and rasterising page 1.
The maker's WASM needs to write a chain payload, and the maker is Go. The two
formats lived only in TypeScript, so this brings them across.

New package internal/descriptorbackup:

- BIP-138, encode and decode. Passes all six of the BIP's own vector files,
  57 subtests: the decryption secret and every individual secret, derivation
  path encoding, individual secret encoding, content types, whole backups and
  the cipher itself. Entry counts pad to the standard buckets.
- The threshold format, encrypt only. The heir-side recover page stays in
  TypeScript so a grieving heir never loads the maker's WASM to read a backup.
  Reproduces the published mainnet vector: stripped descriptor, encrypted key
  block, lookup tags and total length.

The String content item needed a fix the round trip caught. The BIP says "For
all TYPE values except 0x01, TYPE_LENGTH MUST be present", and the first
version omitted it for 0x03. A TypeScript reader then failed to parse the
payload, and because its decrypt loop wraps parsing in the same try as the
cipher, it reported "none of the keys you supplied can open this backup". The
key was fine. See the note in the wayfinder ticket.

Two upstream quirks are reproduced on purpose and pinned by tests:
NumberToBytes(0) writes no bytes, and sortsBefore compares JavaScript's string
form of a byte array rather than its bytes. Both run on multisigbackup.com, so
changing either would make our text unreadable there.

make test-xlang is new and proves the two implementations open each other's
backups, in both formats, including that a String note does not stop a
descriptor-only reader. Run it after any change to either side.

golang.org/x/crypto moves from indirect to direct. No new dependency: Shamir
reuses hashicorp/vault, which was already here and was verified to be
byte-compatible with the npm library the TypeScript uses.

go test ./... and make test-ts both pass.
decryptBackup wrapped the cipher and the payload parser in one try, so a
backup that decrypted correctly with the heir's key, but held contents this
version cannot read, fell through every entry and ended at "None of the keys
you supplied can open this backup".

The key was fine. An heir reading that goes looking for keys they do not need,
at the worst possible moment, and may decide the backup is lost.

The two failures are now separate. A cipher failure still means a decoy or
another key's entry, so the loop moves on. A failure after a successful
decryption says what it is: "Your key opened this backup, but this page cannot
read what is inside it", followed by the reason.

Mirrored in the Go port, which propagated the parse error but did not say the
key had worked.

Found while porting the crypto, where a malformed String item produced exactly
this message and hid the real cause.

Three tests: contents this version refuses must not blame the key, a genuinely
wrong key must still say so, and the same pair checked across both languages
through make test-xlang, which now builds a backup whose key opens it and
whose contents it cannot read.

The threshold path in descriptor.ts was checked and needs no change. Its catch
wraps the cipher alone, and it reports how many shares opened against how many
are needed.
The maker is about to ask an owner for their recovery steps, their key
locations and a chain copy. This is the half of that with no pixels in it: the
config carries the three texts and each one lands where it was decided to go.

  recovery steps   README.txt and README.pdf, in the open
  chain copy       README.txt in full, README.pdf as a QR code,
                   with a blank line for the transaction id
  key locations    sealed inside the encrypted archive

The split is not arbitrary. README.txt is built to be forwarded: a guardian's
own copy tells them to send it to whoever asks for their piece. So the method
and the chain copy may sit in it, because a lone guardian may safely hold them
and an heir with one bundle and one key needs them early. Key locations may
not, so they become WHERE-THE-KEYS-ARE.txt inside the archive, which opens
only when enough guardians combine.

The chain copy is a QR code on paper because a 2-of-3 payload runs to about
876 characters and nobody types that back correctly. Verified by rendering the
page and scanning it: the code returns the payload exactly, and the guardian's
own piece QR still scans beside it.

Two layout fixes found by looking at the printed page rather than trusting the
code. A 70mm QR does not squeeze into whatever is left at the foot of a page,
and fpdf does not break for an image placed at an explicit y, so the block asks
for room first. It asks before the heading, not before the image, so the
heading, the explanation, the code and the transaction line stay together. A
heading stranded on the page before its QR is hard to follow, and this is a
document somebody reads on the worst day of their life.

New strings are English only. The lookup falls back to English, then to the
key, so every other language keeps working.

Six tests cover the placements, including the one that matters most: a README
must never carry key locations. One more checks the QR is a real image at a
size a phone can read.

No maker.html change and no create-app.ts change. That is the next ticket.
Step 3 of the maker, and the reason this whole effort started. A DM asked how
the method is preserved beside the key, because you cannot engrave the
instructions into steel.

Six named prompts in two groups, and each group says where its text goes.

  Recovery steps      chain and bundles
    what kind of wallet, what opens it, how to sign and send
  People and places   bundles only
    where each key is, who to call first, anything else (repeats)

Six small questions beat one big empty box. An owner given a single blank
field writes "the bitcoin is in the wallet" and stops. Each box carries a
worked example as placeholder text, which clears on the first keystroke and
comes back if the box is emptied. The example is never a pre-filled value: a
value gets left in, and an heir told "Hannah has key 2" when there is no
Hannah is worse off than one told nothing.

The split is not cosmetic. README.txt is built to be forwarded, and a
guardian's own copy tells them to send it to whoever asks for their piece. So
the method may sit in it and the key locations may not. The locations go to
createArchive instead and are sealed inside the encrypted archive, where they
appear only when enough guardians combine.

Empty boxes contribute nothing, so an owner who skips step 3 gets exactly the
bundle they would have got before it existed.

The permanence line sits under the group it applies to rather than in the
collapsed block at the top of the page, where it is read before the owner has
anything to say.

An e2e test asserts the thing that must never silently regress: the method is
in the README and the key locations are not.

Go suite, make test-ts 38 of 38, make test-xlang 5 of 5, and all 117 Playwright
tests pass.

The chain half is not here. The browser cannot make a chain payload at all
yet, because nothing in main_create.go reaches internal/descriptorbackup.
The last two commits taught the browser to carry the owner's recovery steps
and a chain copy. The command line could not, so a bundle made with
`inheritance bundle` silently lacked what the same project made in a browser
would hold. This closes that.

Prose lives in project.yml, because `bundle` is re-run and the text has to
survive:

  recovery_steps: |
    A 2 of 3. Any two of the three keys can spend.
    The older Coldcard needs firmware 5.1.
  chain_payload: QklQMTM4...

The transaction id is a flag, `--chain-txid`, because it only exists after the
chain copy is published, which is after the bundles were first made. The flag
beats the stored value: a stored id was true when it was written, one passed
on the command line is being told to us now.

Key locations get no flag and no field, on purpose. They must be sealed inside
the encrypted archive, and the command line already has a way to put something
there: it is a file you seal. The browser needed a textarea only because it has
no filesystem step. Putting them in project.yml would leave key locations
sitting in plain text next to the project, which is the thing the split was
drawn to prevent.

Verified with the real binary rather than a unit test: a demo project gained
the two fields, `bundle --chain-txid` re-ran, and the README carries the
method, the chain copy and the id from the flag. `verify-bundle` still accepts
the result.

go test ./... and make test-ts 38 of 38 pass.
The second thing Ben asked for. The landing page told a reader to try a drill
in four lines of text and showed them nothing.

The steps now match what the maker actually does, including the step that asks
what your heirs need to know, which the old four skipped because it did not
exist yet. A dropdown under them pairs each step with a picture, folded away
so the page stays short for anyone who does not want it.

Five figures, all generated from our own build by make screenshots. One is
new: the owner's-words step, captured empty on purpose. Empty is the honest
picture, because the examples live in the placeholder text and they are the
point of the figure.

The figures already changed under us. bundles.png, maker-overview.png and
recovery-1.png all moved when the maker gained a step, so this regenerates
them rather than shipping pictures of a page that no longer exists.
The runbook, the drill and the estate insert still described a service where
the only thing that went on the chain was a descriptor.

Runbook:

- Step 1 now asks the client what their heirs need to know, and says where
  each half goes. This is the part that dies with them, and it is the reason
  they are in the room. The Butler reads the examples aloud and does not write
  the answers for them.
- Step 4 checks the fee rate before publishing. Publish under 5 sat per vbyte.
  A chain backup is never urgent, so waiting a day costs nothing.
- A new step 5: write the transaction id onto every printed README after
  publishing, because the transaction did not exist when the bundles were
  made. It is proof, not the way in.
- The tradeoff paragraph no longer says "only their keys can open it". That
  sounds like "only you" and is not the same thing. If the client chose
  any-one-key, any ONE of their keys opens it, including one they later stop
  using.
- The budget now states its fee-rate assumption. About 2,700 sat for a 2-of-3
  with 250 bytes of method, AT 2 SAT PER VBYTE. The same transaction costs
  about 13,500 sat at 10. The old figure said 5,000 to 8,000 sat and never
  said at what rate, which quietly made every placement above roughly 4 sat
  per vbyte lose money on the publish.

Annual drill: two reads, not one. The chain copy and the bundle's own copy of
the same text, compared. If they differ the bundles are older, and the answer
is to say which is current rather than leave two answers in the world.

Estate insert: says plainly that the transaction id is not the only way in.
Every bundle carries the same text.

Prices are unchanged. The tool was always free and the service was always the
practice.
Pieter Wuille argued against this exact tool, in public, on the thread that
announced multisig-backup. A reader will find that eventually. Better they
find it from us, with the answer beside it.

The guide gains a section that quotes him, agrees with him, and then reads the
rest of what he said. He proposed hiding the data in backups you keep anyway.
That is what the guardian bundles are, and every bundle already carries the
same text the chain does. So the ordinary recovery has nothing to do with the
chain at all, and the chain copy answers only the question the bundles cannot:
what happens when every bundle is gone.

It also says the half of his position that a quote would hide. He argued in
favour of dropping Core's limit on this data and against node operators
deciding what belongs in a block. He thinks it is a poor use of the chain. He
does not think anyone should stop you.

Both quotations were checked verbatim against the research file rather than
from memory.

The runbook gains the line a Butler says before the client agrees, and an
instruction to wait afterwards. A placement is complete without the chain copy.
A client who was talked into a permanent public record did not consent to it.

No recommendation either way. Whether the risk is worth it is the client's
call, and the page says so.
An heir holding one bundle and one of the wallet's own keys could not read the
chain copy from the bundle they were holding. The text was in their README and
nothing on the page would open it.

The recovery page now has a panel for it, outside the numbered steps, because
it is a second way in rather than a later step of the first one. It takes the
text from the README, or a scan of the printed QR code, and one or more of the
wallet's public keys. It reads both formats: a threshold backup needs k keys, a
BIP-138 backup needs one.

The reader ships in EVERY bundle, not only in bundles made when a chain copy
already existed. The weight argument does not survive measurement: 24 KB
against 894 KB, under 3 percent. The argument that does survive is time. A
bundle made today must still read a copy published in five years, and
re-issuing bundles to guardians is the most expensive thing in this system.

Four browser tests, all with every non-file:// request aborted, so a page that
quietly depends on the network fails. They cover a threshold backup with two of
three keys, a BIP-138 backup with one, wrong keys, and missing text. The last
two check the page blames the right thing, which is the same failure the Go
port turned up in decryptBackup.

make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5,
make test-e2e 122 passed.
… stalls

The chain reader landed at the bottom of the page, collapsed and unnumbered,
underneath three numbered steps that assume bundles. So the heir it exists for,
the one who cannot reach enough guardians, met it below the dead end they had
just walked into.

Two changes, neither of which asks them anything.

The intro says there are two ways in, once, before they start. An heir who
already knows they hold a key does not have to discover that halfway down.

Then the page watches. Once somebody has gathered two pieces and still does not
have enough, the chain panel opens itself and says why it is there. It never
closes again on its own, because somebody reading it should not have it taken
away.

Two pieces rather than one, because a personalized bundle arrives with the
holder's own piece already loaded. One piece means "I opened my bundle" and
says nothing about whether the guardians can be reached. Two means somebody is
gathering and coming up short. The first attempt fired on every page load and
was wrong for exactly that reason.

A browser test builds a real demo project, opens one guardian's bundle, adds a
second guardian's piece against a threshold of three, and asserts the panel
opens and the line appears.

make test 0 failures, make test-ts 38 of 38, make test-e2e 123 passed.
The drill recovered a quorum every year and proved the wrong thing. A Butler
driving the recovery proves a Butler can recover. That is not what the client
is buying. They are buying the confidence that these people, on a bad day,
without a Butler, can do it.

So the same step, with the keyboard in a different pair of hands. The least
technical guardian present drives. The Butler does not touch it and does not
answer a question the first time it is asked, because a real recovery has
nobody to ask. Answer on the second ask, and write down that you had to.

This costs no extra minutes in an hour that was already full.

The list of places they hesitated is the drill's real output. Most of those are
fixed by a sentence in the estate insert or a clearer briefing sheet, not by
teaching the guardian. If the same pause appears two years running, the
document is wrong and the guardian is fine.

Pass/fail changed to match. A recovery the Butler drove is a demonstration and
does not count as a pass.

The written widow test stays. It tests whether the insert reads well, which is
a different question from whether a guardian can work the tool.
Nothing stopped an owner clicking past every prompt. The placeholders made the
boxes easy to answer. They did not make anyone answer.

Two halves, for two people, only one of whom is in the room.

The owner gets one line above Generate, and it never blocks. A gate on a free
tool is satisfied by typing a full stop, and then the heir holds a bundle that
passed the check and still says nothing. The line appears while the boxes are
empty and goes when one word is written.

The heir gets the truth. When the owner wrote nothing, the README says so, and
says the bundle is not missing a page. Somebody holding a bundle with no
instructions cannot otherwise tell whether that was a decision or a lost file,
and at the moment they are reading it that difference matters.

The first version gave the empty case the heading "HOW TO SPEND THE BITCOIN
(the owner wrote this)", which claims something untrue on a page an heir reads
in the worst week of their life. A test caught it. The empty case has its own
heading now.

Three tests: the empty README says so, a README with words does not, and the
browser warning appears, clears on one word, comes back when cleared, and never
disables the button.

make test 0 failures, make test-ts 38 of 38, make test-e2e 124 passed.
The last piece of what the DM asked for. Until now the browser could carry the
recovery steps into the bundles and nowhere else: nothing in main_create.go
reached internal/descriptorbackup, so the chain half existed in Go and was
unreachable from a page.

rememoryEncryptChainCopy exposes it. The format is chosen for the owner rather
than offered: recovery steps present means BIP-138, because only BIP-138
carries a String item beside the descriptor. A descriptor alone may use the
threshold format, which stays byte-compatible with multisigbackup.com.

The maker gains a destination choice, bundles only or bundles and the chain,
and it defaults to bundles only. Picking the chain reveals the descriptor field
and a live size worked out from the real payload, so the sat figure is what the
client actually pays rather than an estimate.

This also removes a promise the page could not keep. The recovery-steps label
said "goes on the chain and in the bundles" whatever the owner chose. It now
says "bundles only" until they ask for a chain copy. That label is why nothing
has shipped to the site for twelve commits.

A TYPECHECK HOLE, and it is the more important half of this commit.

make test-ts ran tsc against tsconfig.test.json, which includes only
src/**/*.test.ts plus two crypto files. create-app.ts and app.ts were never in
it, and esbuild strips types without checking them, so nothing in this repo
typechecked the code that actually ships in a page.

Running the real project turned up five errors, four of them mine from earlier
commits: rememoryCreateArchive called with two arguments against a one-argument
type, recoverySteps set on a config type that did not declare it, and
new Error(msg, { cause }) which needs the ES2022 lib against this project's
ES2020. All fixed, and the types now describe what the WASM exposes.

make test-ts now runs both projects, so this cannot come back.

make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5,
make test-e2e 125 passed.
Making a backup moved to Create Bundles when the maker learned to put the
owner's words on the chain. This page kept a protect half that duplicated it,
and an heir reading a transaction id off an estate insert had to load both.

The protect half is removed, not hidden. The tab bar, the panel, the protect
function, confirmOnChain, estateBlock, and every helper that only served them
are gone, along with the dozen browser tests that drove them. The page is
112 KB, down from 137 KB.

THE FILENAME DOES NOT CHANGE, and that is the point. The estate insert is
printed and filed with a will, and it names
bitcoinbutlers.com/tools/inheritance/descriptor.html. Somebody may read that
page twenty years from now. Keeping the name means no redirect has to survive
that long, and every printed insert keeps working. What changed is the page's
job, not its address.

The page now says where to make a backup, and points an heir holding a bundle
at recover.html, which reads the same text with no internet at all.

The service docs and the guide follow: the runbook sets the destination in
Create Bundles rather than opening this page to protect, and reads the backup
back here afterwards. The estate insert needed no change, because it always
pointed here for recovery.

The e2e suite was rewritten rather than deleted. Four tests that cover what is
left: the page is a reading page and says where to make one, two keys open a
threshold backup and one does not, a stranger key opens nothing, and a mistyped
transaction id is refused before anything leaves the machine. They use the
published mainnet vector, so they read the same bytes that sit at block 966450.

Removing the protect flow left a dozen dead declarations behind. The real
typecheck added in the previous commit caught every one.

make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5,
make test-e2e 115 passed.
Confirmed that Bitcoin Butlers pays whoever runs the session, now that other
Butlers can. Adds the booking-time alert, and the warning that no alert does
not mean go ahead, because fees move between the booking and the session.
An heir opening this page has never seen a backup come off the chain and has
no reason to believe it works. One button now fills in our own published
backup, fetches it, and leaves them one press from the descriptor.

Our own backup on purpose: published on mainnet on 2026-09-10 and recorded in
docs/descriptor-backup-vector.md. The seeds behind the keys are the BIP-39
test vectors every wallet ships with, so nothing here is secret and nothing
holds coins. The page says so.

It fills two keys, not three. The backup is a 2-of-3, and using exactly the
threshold shows that it opens with the minimum rather than needing every key.

Two friction points found when the flow was proved on production:

The transaction-id path is two steps and nothing said so. It now says so,
under the Fetch button.

Pressing Rebuild before fetching said "Paste the backup text, or fetch it by
transaction id", which states the position rather than pointing at the way
out. On the transaction-id path it now names the Fetch button.

The note after a recovery only appears for our published backup. An heir
recovering their own wallet must not be told it was a demonstration.

Verified in a browser against mainnet: 545 characters fetched, two keys
opened it, and the descriptor matches the published vector exactly.
An owner who has never seen a recovery cannot tell a good bundle from a bad
one. A test run makes throwaway bundles from a sample file so they can
practise, and marks every one so a drill can never be mistaken for the real
thing.

An offer, not a gate. A required gate was rejected on the test-recovery
ticket, because people fake a gate to get past it. The nudge appears once for
an owner who has generated nothing on this browser, it never blocks Generate,
and generating anything at all retires it.

What a test run does NOT carry. Not the owner's files: a throwaway bundle
holding real secrets could not be thrown away. Not the people and places
either, which is the most sensitive thing an owner writes.

The stamp is in two places. The project name is prefixed TEST, which is what
README.txt, the printed PDF and METADATA.yaml all print, so a guardian sees it
in the first lines. The saved filename is prefixed too, through one function
shared by the list on screen and the download, because a test bundle that
looked real in the list is exactly the mistake the stamp prevents.

Verified in a browser by pulling a generated bundle apart: the README reads
"recover files for: TEST recovery-2026-09-23", the file saves as
TEST-bundle-alice.zip, and a secret typed into the owner's words is provably
absent from the zip. Reloading shows no nudge and no test mode.

make test-ts 38 passed, 12 Go packages passed, make build and make html
clean.
Two real bugs the spec review found.

The try-it flag survived a real fetch. The fetch button fills the textarea
programmatically, which fires no input event, so the listener that clears the
flag never ran. A reader who pressed Try it and then fetched their OWN
transaction was told their recovery was a demonstration. The flag is now set
from the transaction id that was actually fetched.

The TEST filename stamp was read at click time. Generating a test run, turning
it off, then downloading gave a list saying TEST- and a file saved without it.
The stamp is now fixed when the bundles are generated and never recomputed.

Standards misses. AGENTS.md requires a Playwright test for any change to
maker.html and there was none: e2e/test-run.spec.ts covers the offer, the
stamp, the absence of the owner's material, and the stamp surviving the mode
being turned off. CHANGELOG.md was untouched and now records both features.
Two rhetorical questions in the new copy are gone, against the standing rule.

make test-ts 38 passed, 12 Go packages passed, 7 Playwright tests passed.
The chain copy cannot be rewritten, so an owner who revises their instructions
leaves an older copy on the chain for good. An heir holding a bundle and a
chain copy that disagree had no rule at all.

Ben decided it: the bundle wins. The chain copy is the last resort for the day
every bundle is gone, not the authority. Bundles carry created in METADATA and
print it in the README footer, so the heir has a date to check rather than a
rule to remember, and nobody is asked to compare a timestamp with a block
height.

Three places, each where a reader meets the second copy. The bundle README
beside the chain payload. A standing line in the recover page's chain reader.
And one line after a successful read, which appears only then: an heir holding
one copy has nothing to reconcile, and a conflict they do not have is noise at
the worst possible moment.

A bundle with no chain copy says none of it.

Two tests. A Go test covering the README in both states, and a Playwright test
covering the line appearing on a read and going away on a failure.

make test-ts 38, 12 Go packages, 119 Playwright, translations clean.
Step 4 had the diagnosis backwards. It assumed the bundles were the older
copy when the two differ. It is the other way round: a bundle is re-issued
whenever the owner revises anything, and a chain copy can never be re-written,
so the chain copy is the one that goes out of date.

Three outcomes now, and they are not the same problem.

The words differ and the descriptor matches. Expected, and no action. The
owner revised their instructions, the bundles carry the new ones, and the
chain copy holds the words of the day it was published. That is the design.

The descriptor differs. This is the failure. The chain copy names a wallet
that no longer exists, so on the day every bundle is gone it leads an heir to
nothing. Republish, write the new id on the insert, and say the old one stays
on the chain forever and is now wrong. The only case that triggers a
republish, and Bitcoin Butlers pays for it.

The copy will not read at all. Not stale, broken. Repair it before leaving.

Pass and fail now say which is which, so a Butler does not fail a client for
the expected state.

Step 5 gains the thing that bites: a re-seal re-issues EVERY bundle, because
the maker generates a fresh secret and splits it anew, so one guardian's old
piece and another's new piece cannot be combined. A client left holding a mix
has no working plan and nothing on the outside of the bundles would show it.
Collect and destroy the old media in the same session.
An owner whose plan changes had no service to book and no runbook to follow.

Priced the way every Butler service is priced: the Butler's hourly rate times
the session length. 120 minutes until enough have run to say otherwise, and
the real elapsed time goes on the insert each time so the number is set by
evidence rather than a guess. Shorter than a placement because the deciding is
already done; the sealing, printing, insert and live test recovery are not.

Ben settled the division of labour: Butlers help create the new bundles and
the owner places them. That is the same rule as a placement. We never hold a
bundle and never touch a guardian's media, the guardians are not in the room,
and they will not all be reachable on one day, so a Butler cannot collect or
destroy anything.

What a Butler owes instead is the list. Each guardian, what they hold now, and
what to swap it for, with the plain warning that an old bundle still opens,
still looks right, and counts for nothing toward the threshold until it is
swapped.

The failure this exists to prevent: four guardians swap and the fifth does
not, so a plan that reads 3-of-5 is really 3-of-4 and nobody finds out until
an heir needs it. The drill catches it, so the session books one.

The chain is republished only when the descriptor changed, per the revision
rule. A words-only revision leaves it alone.

The estate insert gains a line about struck-through rows, and the placement
runbook now says why the media column exists: it is the retrieval list an
owner needs the day they revise.
Ben, 2026-09-23: it is essentially the same as the first one, because you have
to go through and test everything again anyway.

I had written 120 minutes on the reasoning that the deciding is already done.
The deciding is not the work. Sealing every bundle again, printing every
README again, placing them again and test recovering them again is the work,
and none of it gets shorter because the guardians were chosen last year.

The runbook now says so, and says what a short revision actually costs: the
thing that gets skipped is the test recovery.
Three things were stale, and one of them was dangerous.

The guide's Creating Bundles had three steps. The maker has four, and the
missing one is the whole reason this feature exists: What Your Heirs Need To
Know. It is documented now, including why the two groups of questions travel
differently. The method goes into every bundle and onto the chain if asked.
Where the keys are goes into WHERE-THE-KEYS-ARE.txt inside the encrypted
archive, and never onto the chain.

How to Make One described a page that no longer exists. It walked through
descriptor.html's protect flow, which was removed when that page became the
heir's reading page. The descriptor backup is made in Create Bundles now, and
the guide says so and points at the try-it button for reading one back.

Keeping Bundles Current said old bundles cost nothing to leave lying around.
That is the dangerous one. Every generate makes a new recovery key and splits
it again, so an old piece and a new piece cannot combine. Three guardians
swapping out of five leaves a 3 of 3, and nothing on the outside of a bundle
shows it. An old bundle still opens, still looks right, and counts for
nothing.

It also conflated the two kinds of change. The chain copy is only republished
when the WALLET changes. A words-only revision leaves it alone, because the
chain copy still tells the truth about the method. And if a bundle and the
chain copy ever disagree, the bundle wins.

The README's bundle table never mentioned the owner's words or the chain copy,
and never said where the key locations live. It does now.

Adds the owners-words screenshot to the guide. It has existed since the
feature shipped and nothing referenced it.
Two screenshots framed the wrong step, and one of them proved it: owners-words
and tlock-setup were BYTE IDENTICAL. Both framed card index 2. When step 3
became What Your Heirs Need To Know on 2026-09-22, Generate Bundles moved to
index 3 and neither figure followed it.

So the guide showed the owner's-words card under the time lock heading, and
again under the bundles heading. Nobody would have caught it by reading,
because both pictures are of a real card.

The indices are fixed, with the reason written beside them so the next step
insertion does not repeat it. Regenerating also refreshed seven figures that
had drifted from the current pages.

The bundle contents section never mentioned the owner's words, so the guide
told a reader to write them and then described a bundle that did not carry
them. It now says README.txt holds the method and the chain copy, and that
WHERE-THE-KEYS-ARE.txt is inside the encrypted archive rather than among the
bundle's files, so a forwarded bundle reveals nothing about where the keys
live.

The README's maker caption named three steps when there are four.

Every internal anchor in the guide resolves, and every figure it references
exists.

make test-ts 38, 12 Go packages, 119 Playwright.
Three checks on committed files, so they are deterministic and ride the
go test ./... that CI already runs.

No two figures may be byte identical. Two distinct captures producing the same
bytes means they framed the same thing, which is exactly what happened when a
step was inserted into maker.html and two screenshot tests kept framing the
old card index. The guide showed the owner's-words card under the time lock
heading for a day, and a reader could not tell, because it is a picture of a
real card.

Every figure the guide references must exist.

The guide must document one step per step in the maker, numbered in order. It
described three steps for a four-step page for a day, so a reader looked for a
Generate button in the step that asks what their heirs need to know.

Regenerating the figures and diffing them is NOT checked, and deliberately:
five of the eleven differ between two runs with identical inputs, so that
check would be flaky. The test comment says so, to stop someone adding it.
Every bundle used to list the other guardians by name and email, in
README.txt, README.pdf and the personalised recover.html. One bundle in
the wrong hands named the rest of the people to approach, and a README
is built to be forwarded: its own text tells the holder to send it to
whoever asks for their piece.

Everything the owner writes now goes inside the encrypted archive, so it
opens only when enough guardians combine their pieces:
HOW-THE-WALLET-WORKS.txt, WHERE-THE-KEYS-ARE.txt and CHAIN-COPY.txt. The
chain copy's transaction id travels with its ciphertext, because the id
alone fetches the same payload off the chain.

Publishing now happens BEFORE sealing. The archive is encrypted and its
key split among the guardians at seal time, so an id discovered after
that can never be added. The maker gained a field for it, and nothing
had ever passed that argument, so every CHAIN-COPY.txt shipped blank.

The quorum stays visible. With no roster, a number names nobody, and it
tells an honest guardian when to stop asking.

Anonymous mode is gone. It existed to stop guardians seeing each other,
which every bundle now does by default. Removing it also fixed a bug it
was hiding: a key rename left the PDF printing the literal text
recover_anon_step3 where the recovery steps belong.

The structs that build a README no longer have a field that could hold
any of it, which is a stronger guarantee than a test. Sealing also stops
writing the owner's words into their own manifest/ folder, where a
re-seal used to overwrite an edit they made by hand.

Guardrails: disclosure tests on all three surfaces including the PDF,
which had none; a scan that every translation key the generators ask for
exists; and a browser test that recovers a bundle and reads the sealed
file, because asserting absence alone passes when the maker silently
drops the owner's writing.

Docs, both runbooks, the drill, the security review and ten screenshots
brought in line. The placement runbook's step 5 and the annual drill's
chain check were both backwards.
about.html told every reader "Every bundle includes contact details for
the other guardians, so they can coordinate without you", and listed the
contact list among a bundle's files. Both were live and both were the
opposite of what the tool does.

The maker's Recovery steps box said nothing about being sealed while the
People and places box beside it did, so a client read the contrast and
concluded their method was readable in a bundle. Both are sealed now,
and the chain warning stops implying the chain is the only exposure.

Found by reading the pages rather than grepping them. The grep sweep
that preceded this deploy missed all three, because none of them used
the phrases it searched for.
The runbook contradicted itself. Step 1 had the client drag files into
manifest/ and then said the maker asks for their words. Step 2 put the
descriptor on the chain "in Create Bundles, where you already are" and
had a transaction id pasted into it. Step 3 then sealed with
`inheritance init`. Those cannot both be true: the page makes the
bundles, and the id is entered in the page.

Ben's call: the browser makes the bundles, always. The command line
makes the same bundles and stays for whoever wants it, but a session
never mixes the two.

The transparency moment changes with it. METADATA.yaml is written only
by the command-line path, so step 3 now opens a bundle's README.txt and
reads it aloud. That does more work than the old ritual: it is the page
the guardian will actually read, so the client hears what that person
can and cannot see, and the Butler points at what is absent by name.

The CLI guide is deleted. Every one of its 282 commands named a binary
that does not exist, and its install section pointed at upstream's
releases and homebrew tap, so anyone following it installed a build with
none of this work in it. The web guide's own CLI section already told
the truth: built from source, no published binaries. That stays.

Also corrected: step 1 still said the method "travels to the chain and
into every bundle". Both halves are sealed now.
docker-compose.yml at the repo root pulled ghcr.io/eljojo/rememory:latest.
Anyone who ran `docker compose up` here got upstream's build, whose
bundles still name every guardian in every README with their contact
details. The README sent people to eljojo.github.io for Create Bundles
and Documentation, to upstream's homebrew tap and release binaries for
the CLI, and to upstream's demo bundles to practise a recovery.

There is now a Dockerfile that builds from source. It installs Go and
Node in the build stage, compiles the TypeScript and the maker's
WebAssembly, and ships the binary alone. Verified: the image builds, the
container serves, and the compose file builds rather than pulls.

The self-hosting guide, the README and the hide-quorum design doc name
the `inheritance` binary. The serve command's environment variables are
INHERITANCE_PORT, INHERITANCE_HOST, INHERITANCE_DATA and
INHERITANCE_MAX_MANIFEST_SIZE. Ports bind to 127.0.0.1 with a note to
put TLS in front.

The comparison table used to read "Only eljojo/rememory includes contact
details in each bundle". It now says this fork removed that, and why.

Left alone deliberately: the rememoryParseShare family are live
JavaScript globals the WASM module registers, not prose. Renaming them
touches the recovery path and is its own change. Also the REMEMORY_TEST_
variables, which gate tests and CI.

Also precise now: the runbook said mixing the page and the command line
"cannot carry the transaction id". The page's Save project.yml writes
name, threshold, language and guardians; seal reads recovery_steps,
chain_payload and chain_txid, which it never writes. Bundles sealed that
way carry no HOW-THE-WALLET-WORKS.txt and no CHAIN-COPY.txt at all.
Module path, command directory, seventeen JavaScript globals, the share
markers, the compact prefix, the test and self-host environment
variables, the release workflow's artefacts, the update nudge and the
about page.

Two formats are read by people and were renamed with care. A share now
reads BEGIN INHERITANCE SHARE and its compact form starts IH, and BOTH
parsers still accept the old spellings, in all three places they live.
A guardian's README is not reissued because a project was renamed.

Three copies of that format is what made this dangerous. The markers
exist in internal/core/share.go, in crypto/share.ts, and a third time as
a regex in app.ts. Renaming the first two left the browser unable to
read a pasted README, and nothing failed until a paste came back empty
in a browser test. TestEveryCopyOfTheShareFormatAgrees now pins all
three, and was verified to fail when one half of one regex drifts.

The release workflow's Homebrew step is gone: it published a formula to
eljojo/homebrew-rememory, a repository we do not own, so it could never
have worked here.

Two live bugs fell out. The update nudge told anyone running a
six-month-old saved copy to go and download upstream's release, and only
suppressed itself on upstream's host. And the about page read "Bitcoin
Inheritance is the te reo Maori word for a guardian", a rename artefact
where Kaitiaki had been substituted, live on the site.

Kept deliberately: NOTICE and LICENSE. Apache-2.0 section 4(c) requires
retaining attribution notices in derivative works and 4(d) requires
reproducing a NOTICE file's contents. Being free software is the reason
that attribution stays, not a reason to drop it. The changelog entries
below the fork also stay: they describe releases that genuinely were
ReMemory, and rewriting them would falsify the record.

Still there: eljojo/no-autopilot@v1, a third-party GitHub Action in the
PR review workflow. That is a vendor choice, not a leftover reference.
"Open source (Apache-2.0), built on Rememory" was the one string the
sweep missed, in both the markup and the translation file. Attribution
stays where the licence requires it, in NOTICE.
NOTICE was eighteen lines with a list of fork changes that had gone
stale months ago. Apache-2.0 section 4(b) wants modified files to carry
notice that they changed; the Git history is that notice, and says it
accurately. Eight lines now: our copyright, their copyright, the
derivation, the licence.

The README opened by saying 'Rememory does the heavy lifting; this fork
adds a small set of changes', which stopped being true, then carried a
second title and the upstream README wholesale. One <sub> line of
attribution now, pointing at NOTICE.

Removed rather than renamed: the 'Why I Built This' section. It is
Jose's story about a documentary and his cycling concussions, in the
first person. Renaming the product inside it would have put his
motivation in our mouth, which is worse than leaving it.
The markers and the compact prefix were typed out three times, in two
languages. Go now generates crypto/share-format.ts and both TypeScript
files import it, so there is nothing left to type and nothing to drift.

That retires the guardrail added a few commits ago. It read the
TypeScript looking for the right strings, which only shouts after
somebody has already typed the wrong thing, and it passed once while
half of one regex was broken. It is replaced by one question:
TestShareFormatTSIsCurrent regenerates and compares, and says how to fix
it. Verified by changing the Go constant and watching it fail.

The README is rewritten rather than patched. It opened by saying
upstream did the heavy lifting and this was a small set of changes,
carried a second title, a comparison of nine other Shamir tools, and a
personal story that was not ours. 328 lines to 193.

It now leads with the thing that makes this different: what a guardian
can and cannot see. Then the sealed files by name, the publish-before-
generate order for the chain copy, what a bundle holds, and what this
does NOT protect against, which nothing in the old one said.
Nothing in the guide had a figure of the chain step, so the order that
matters most there, publish BEFORE you generate, was words only. And
every recovery figure used a fixture with no owner texts, so no picture
ever showed the sealed files by name, which is the point of sealing
them. Both exist now, and the guide shows them where a reader asks.

Taking the first picture found a live layout bug. #chain-fields had no
CSS of its own, and `label { display: block }` only applies inside
.form-group and .words-group, so the descriptor label, its box and its
hint ran together as one line of prose on the shipped page. It has been
like that as long as the field has existed. Styled to match
.words-group, which is the same kind of form in the same card.

Every figure regenerated against this build, and a stray duplicate
</p> removed from the bundle-contents section.
The guardian PDF still carried the upstream palette: a dusty-blue band,
pale green panels and a lavender recovery-rule box. It is the Bitcoin
Butlers warm family now, deepened for ink, because the web gold #FBDC7B
is too light to read as a band on white paper.

It stays light rather than going to our dark mode. This document is made
to be printed: it carries a QR code to scan off paper and tells its
holder they can post it as a letter. A dark page prints as a black page.

Each guardian still gets their own colour, so a stack of printed pages
can be told apart.

Also removed: the paragraphs in the Dockerfile, the compose file, the
self-hosting guide and the README explaining why not to pull upstream's
image. Ours is the only instruction now. A reader does not need a second
rule about a thing we are not asking them to do.

Verified: the image still builds and the container still serves.
The docs told an owner to put the list in their will and never said why
that is safe. It is safe for the same reason it is not in the bundles:
on its own it is names and nothing else. To an heir it is the only route
to the bundles. Added to the guide, the README and the estate insert.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant