- A working code path can still be the wrong UI, and no unit test could have said so. Every dig-app
confirm window was drawn
MB_OKCANCEL | MB_ICONWARNINGon Windows, so all eleven informational tray messages arrived as a warning triangle with a Cancel that no caller reads. Every path involved was correct; the defect was the presentation, and it was found by looking at a screenshot. The lesson is where the classification lives:Presentationis now part ofConfirmContent, decided once where the content is composed and unit-tested, so a per-OS backend cannot style a notice as an alarm. - Classify per call site — a blanket conversion destroys a real decision. The two enrolment retention
screens look like notices and are not: refusing either abandons setup. They needed a THIRD seam
(
confirm_claim: two choices, no biometric, fail-closed by default) rather than being swept into either existing one. A mutation that flattened every window to one button was caught by three tests; a mutation that routed the retention screens back throughshow_noticewas caught by exactly ONE, because the returnedRetentionDecisionis identical either way — only the SEAM changes. Assert the seam. - Probing the live Win32 dialog is cheap, scriptable, and better evidence than a screenshot.
EnumWindowsfor class#32770owned by the process, thenEnumChildWindowsforButtonchildren: their COUNT andGetDlgCtrlIDanswer "one button or two" exactly, and theStaticchild's text is the full body.PrintWindowrenders the window to a bitmap even when the desktop is LOCKED, whereCopyFromScreencaptures only wallpaper — which is what a headless-ish agent session usually has. Note$pidis read-only in PowerShell, so the out-param needs another name. - A dismissed
MB_OKnotice returnsIDOK, soshow_noticestill reportsApproveand callers keep distinguishing "the user saw it" from "no window could be drawn". Verified byBM_CLICKon the live button and reading the process's stdout, not from the docs. - A carefully-written message that cannot print is worse than none. The DID explainer became unreachable the moment its row was correctly disabled. The fix was not to re-enable a control that cannot act but to change what the row PROMISES — an explanation, which it can always deliver — and to delete the minting action outright so its absence is structural.
dig_session::SEED_LENis 32, and a 24-word BIP-39 phrase carries exactly 32 bytes of entropy — so the entropy IS the master seed. That identity is what makes the phrase to seed mapping a lossless bijection and lets a restore reach the same identity with zero stored state. But it is NOT the Chia derivation: Chia wallets go phrase → 64-byte PBKDF2 seed →SecretKey::from_seed, so the SAME phrase gives a DIFFERENT address in Sage than in DIG. Making them agree means wideningSEED_LENto 64 indig-session(a10-primitivescrate), cascading throughdig-accountanddig-wallet-backend, and breaking at-rest compatibility for every enrolled account. Do not "fix" this casually.dig-accountdeliberately never returns the master seed.UnlockedAccounthands out capability handles only, andAccountStore's blob-key prefix ("account.") is a private const. So dig-app cannot re-derive an enrolled account's words, and re-implementing the prefix app-side would be a copied canonical value waiting to drift. The app therefore keeps its own copy of the phrase, sealed under the ROOT profile's DEK, which adds no new exposure class (the phrase and the seed are one secret in two encodings, under one unlock). The cleaner answer — arecovery_seed()accessor onUnlockedAccount— is a release-firstdig-accountchange, and would additionally let a LEGACY (phrase-less) account be rendered as words without destroying it.- The unlock password is machine-generated and lives in the OS credential store. So the sealed blob is openable on exactly one machine, and the phrase is not a convenience — it is the ONLY thing that survives losing the machine. This is why a phrase-less account is genuinely unrecoverable and must be labelled that way rather than treated as merely "older".
- A tray menu has no text field. The OS gives a tray only menu items and message boxes, so 24 words of
typed input cannot be taken there. Restore therefore lives in
dign account restore(echo suppressed) and the tray hands over the exact command instead of offering a dead end. - A unit test on the CONTENT passed while the RENDERER was untouched. The notice window's two-button
sentence was fixed in
ConfirmContent, its test went green — and the live Windows window still said "Choose OK to I have written these down, or Cancel to reject.", because thewindows.rspatch had silently failed to apply and nothing tested the composition. Reading the real window out of the running process is what caught it:Get-Process <name> | Select MainWindowTitlegives the title, andSystem.Windows.Automation(UIAutomationClient) walks the descendants and dumps the full body text of a live modal. That is a genuine machine check for a native window, and on a session with no visible desktop it works where a screen capture returns black. - A
MessageBoxWcannot relabel its buttons, so it must spell the choice out in the body — and one template cannot serve both an authorization ("Choose OK to Sign") and an acknowledgement, whose button label is a first-person claim. macOS/Linux put the label on the button and need no such sentence.
- authorize()==Ok is necessary but NOT sufficient — the confirm ceremony is a SECOND, independent
gate. The money path (
account::money::MoneyPath) runs summarize →SpendAuthorizer::authorize→ (for every tier aboveAutoSend)AuthProvider::confirm_spend→ sign, in that fixed order. ARequireAuth-class spend (Vault, or over-allowance Confirm) that the authorizer ALLOWS but the confirm ceremony DECLINES/skips is refused, and the money signer is never even built (the #1522 gate note). Only a within-allowanceAutoSendmay skip the ceremony. - Re-read the residency at SIGN time, not once up front. The signer is drawn from the shared
AccountResidencyAFTER the (async) confirm ceremony returns, so a lock landing DURING the confirm dialog fails the sign closed — no snapshot escape. The residency is the SAME lockable seed home the identity signer reads, so onelock_all()relocks BOTH money and identity signing. - The #908 on-wire test is only possible because the money key derivation is byte-contracted.
dig-app's
wallet::signing::WalletKey::from_seed(seed)reproduces dig-account's canonical money key atProfileIx::ROOT(master_to_wallet_unhardened(seed,0).derive_synthetic()), proven inwallet_key_byte_contract.rs. That lets the test derive the money secret + DEK INDEPENDENTLY from a known seed and assert they never appear (raw or hex) in the serializedcontrol.wallet.*wire bytes, while the signed bundle does. Model-A's whole point: only signed bytes cross the dig-app→dig-node IPC. - The money signer refuses an unhinted non-change output (exfiltration guard). A real test send
must HINT the recipient coin (
ctx.hint) and return change to the wallet's own puzzle hash; a barecreate_coin(recipient, amount, Memos::None)reads as a possible drain and dig-account fails it closed withSpendValidationFailed: a non-recipient output does not return to this wallet.
- The two custody roots are not reconcilable, so migration is a clean cutover. The retired model
sealed each profile under a DEK derived from an INDEPENDENTLY-RANDOM per-profile BLS identity scalar
(
keystore/secrets.rs:version(0x02) || scalar, HKDF-SHA256(DEK_SALT, IDENTITY_IKM_VERSION||scalar, PROFILE_DEK_LABEL)). The master-HD model (dig-account) derives every profile's scalar FROM one account master seed at a profile index. A byte-identical DEK across the two is therefore cryptographically impossible — no master seed can be found that derives a pre-existing random scalar — so a "re-enrol the scalar onto a seed index" migration cannot preserve the at-rest DEK; it would require reading old data with the old scalar and RE-SEALING under the new seed-derived DEK (a data migration, not byte-identical). dig-app is PRE-RELEASE (NullConnector stub engine, U-milestone WIP, §3.7 no production users), so the decision is a clean cutover: old per-profile-scalar identities are abandoned, a fresh master-seed account is enrolled on first boot. The sealing CONTAINER (DIGOP1) and the DEK derivation CONTRACT (HKDF + thedig-constantssalt/version/label/len) are PRESERVED — only the seed SOURCE changed — so this is a root swap, not a format break. - Live-view capabilities beat snapshot capabilities for lockability.
dig_account::UnlockedAccount::signerreturns aProfileSignerthat captures its OWNArc<seed>; injecting that snapshot would make a tray lock cosmetic (the running signer keeps its seed). dig-account itself DEFERS wiring idle-relock onto the capability lifecycle (itsunlockeddocs / SPEC §4.1). The harness closes the gap withAccountResidency: it owns the soleUnlockedAccountbehind a shared lock and hands out live-view signer + sealer that re-read the account per operation and fail closed once locked — solock_all()relocks the running paths without depending on the deferred crate feature. - Zero-prompt unlock = OS-credential-store password + file-backed sealed seed. The
CredentialCeremonygenerates + persists a 256-bit account password in the OS credential store on first run and fetches it thereafter, so dig-account unlocks the master seed with no prompt on Windows/macOS. This SPLITS the password (credential store) from the ciphertext (file backend) — a strict improvement over the retired vault that co-located both in one entry.
- Thin async transport over a sync security core. The loopback server (
loopback/mod.rs, tokio-tungstenite) only moves bytes + applies the WS-upgrade guard; all security logic — the auth gate, the pairing handshake, the error taxonomy — lives in the synchronousloopback/dispatch.rsFrameRouter+pairing.rs. That split is why the crypto-critical paths are exhaustively unit-tested without binding a socket, and the one async round-trip test drivesserve_connectionover an in-memorytokio::io::duplex(no real port) with a craftedHost/Originhandshake — non-flaky. - Verify MAC before nonce, and never advance the ledger on failure.
PairingStore::verify_framechecks the HMAC (constant-timeMac::verify_slice) first, then the monotonic nonce, and mutateslast_nonceONLY on full success. A forged/replayed frame therefore can neither pass nor perturb the counter — a bad-MAC frame at a huge nonce can't poison the ledger and lock out honest frames. canonical_jsonis a cross-repo byte contract. The per-frame MAC bindscanonical_json(params), so dig-app and the extension (SIGN-4) MUST derive identical bytes. Pinned in SPEC §5.6.3: keys sorted at every level, no whitespace, escaped scalars (so a raw0x00never collides with the0x00field separators incanonical_frame_bytes). serde_json's defaultMapis aBTreeMap(already sorted), but the canonicalizer rebuilds explicitly so it never depends on apreserve_orderfeature flag.- The channel token is not sign authority. The 32-byte CSPRNG token only gates channel access; the
terminal
NativeConfirmer(SIGN-3) binds every sign. SIGN-1 ships only the fail-closedHeadlessConfirmer(returnsUnavailable→SIGN_NO_CONFIRMER), so a headless build never acts.
Verifying U3 tray/headless shell + adding the residual per-user autostart artifacts (macOS LaunchAgent, Linux systemd user unit) called out in SPEC §4. Tracked under epic #908.
The three OS confirmers reduce to one shared, unit-tested policy (confirm::gated_consent): a
ForegroundWindow shows the decoded tx, then a BiometricVerifier re-authenticates; approve iff
both succeed, everything else fails closed. Each backend only implements those two thin adapters —
so the security logic lives in ONE place and can't drift per platform.
Sharp edges that cost time here:
- Cross-platform dead-code:
WindowIntent::{Timeout,Unavailable}are only CONSTRUCTED by the Linux backend (dialog--timeout, missing helper); the modal Windows/macOS dialogs never produce them, so clippy-D warningsflags them dead on those hosts. Fix =#[allow(dead_code)]on those variants (a legitimately cross-platform enum), not restructuring. - Can't cross-
cargo checkthe non-host backends from a dev box: chia's C deps (blst, blake2b-rs) need a target C toolchain (x86_64-linux-gnu-gcc, apple clang) that a plainrustup target adddoesn't provide. The reliable gate is NATIVE CI runners — thenative-backendsjob onwindows-latest+macos-latestcompiles/lints/tests the per-OS files (ubuntu CI only ever builds the Linux#[cfg]). Verify objc2/windows API shapes against the crate source in the cargo registry cache before pushing a backend you can't compile locally. - Biometric ≠ vault passphrase. The confirm biometric is OS user re-auth (Windows Hello / Touch ID / polkit, each with its own password fallback) — user presence + device-owner identity. The DIG key unlock is separate (keystore/dispatch). Keeping the confirmer free of key material keeps its boundary clean.
Turning the SIGN-1/2/3 seams into a running server is sign_service::build_router + serve_blocking
(assemble over the active profile → restore sealed state → serve), called from the tray shell's
start_sign_service. The shell gate is fail-closed: it starts ONLY on a desktop session (Tray form
factor) with an unlocked active profile. Zero-prompt unlock is Windows/macOS-only (OS credential
store RootUnlock::OsKeychain); Linux needs a passphrase UX not yet wired, so the channel defers
there rather than start keyless.
Sharp edges that cost time here:
- The router used to DISCARD
sealed_record.PairingStore::pair/WhitelistStore::grantcompute the sealed bytes, butFrameRouterdropped them — nothing survived a restart. The fix is aSealedRecordStoreseam (defaultNullSealedStore, productionFileSealedStore) the router persists through on grant/revoke, plusFrameRouter::restore()on boot. Added via awith_persistence(...)builder so the SIGN-1/2 unit tests (which construct the router without persistence) keep compiling. - Cross-restart replay (#956) — PARTIAL: normal-restart replay is closed; the rollback/swap variants
are not.
restore_sealedre-inserts a pairing withlast_nonce: None, so a captured pre-restart frame would replay. The nonce is a monotonic COUNTER (not key material), so it persists in a small PLAINTEXTnonces.jsonwritten on each accepted frame — cheap, unlike a per-frame Argon2 re-seal — and the router re-seeds it on boot viaPairingStore::seed_last_nonce(only ever RAISES the mark). Two load-bearing subtleties: (a) fail-closed — a restored pairing with NO persisted mark is DROPPED (require re-pair), never restored with an empty ledger that accepts any nonce (that empty-ledger case fully reopens the window). (b)nonces.jsonis UNauthenticated — nothing MACs or seals it (do NOT claim MAC coverage). A same-user attacker with AppData write can reset/roll-back/swap it, reopening a channel-layer replay window; that residual is backstopped ONLY by the native-confirm re-gate (every replayed sign still needs a fresh biometric). Folding the mark INTO the sealed, MAC'd pairing record is the robust closure and stays open as #956's remaining work. - Locked-profile sign must be a
LOCKEDerror, not an ok zero-sig.ProfileSessionSigner::signis infallible (returns an all-zero non-verifying signature when locked), sohandle_signmust NOT frame its output blindly — it uses the fallibleSessionSigner::try_signand emitsSignErrorCode::LockedonNone, never a success envelope carrying zeros. - Custody-preserving signer. The loopback signs with the active profile's
0x0010key viaProfileSessionSigner, which delegates toUnlockedIdentities::sign(did, msg)— the key never leaves the session. A locked profile yields an all-zero (non-verifying) signature, never a forgery. - Windows MAX_PATH: building under the deep agent-worktree path trips libz-sys's CMake (260-char
limit). Build with a short
CARGO_TARGET_DIR(e.g.C:\dt).
- The live custody root is
account::residency::AccountResidency(master-HD), full stop. After the #1530 switchover the retired per-profile-identity path is GONE:profiles/(UnlockedIdentities, ProfileSessionSigner, KeystoreSealer, ProfileManager, IdentityStore, DidMinter, …),keystore/{secrets, vault}(IdentitySecrets, ProfileVault),wallet/{signing,spend}(WalletKey + local spend builders), andonboardingwere deleted. What survived was RELOCATED, not kept in place: the sealing seamProfileSealer/SealError-> top-levelcrate::sealer;did_hash->crate::storage;ProfileRef->crate::agent.keystorewas trimmed to ONLY the OS credential-store seam (CredentialStore/OsCredentialStore/KeystoreError) — the zero-prompt password source the account boot reads; everything crypto now lives indig-account. - Isolation moved from the DID string to the DEK (per ProfileIx). The old
KeystoreSealerderived a DEK per-DID from an in-memory identity; the newAccountSealeris bound to ONE profile DEK (profile_dek(seed, ix)) at construction — theprofile_didargument is advisory. So cross-profile isolation tests now use DISTINCT DEKs (distinct labels / profile indices), NOT distinct DID strings. A shared#[cfg(test)] test_supportmodule gives every test one way to build the two seams (test_sealer(label)= a per-label-DEK AccountSealer;test_residency()= an enrolled unlocked residency whose.signer(ROOT)is the live-view SessionSigner). Same label -> same DEK (a "restart" re-opens a sealed blob); different label -> AEAD-rejected (isolation). dig_account::WalletKeyis the public test builder for spends. The deleted dig-appWalletKeywas byte-identical todig_account::WalletKey(from_seed/public_key/puzzle_hash/address); tests that build a real coin spend for the residency's money signer now usedig_account::WalletKeydirectly (takes&[u8], not[u8;32]). The obsolete dig-app-WalletKey byte-contract integration test was dropped — the golden now lives inside dig-account.- Money-send has NO live surface yet. MoneyPath is fully constructed over AccountResidency at library
level, but nothing invokes
authorize_and_signon the live path: the tray menu is lock/quit only, and the loopback APP-SIGNpayload_type="spend"returns an identity ATTESTATION signature over the DIGNET-SIGN-v1 callback message (NOT asign_coin_spendsaggregate SpendBundle). Routing loopback-spend through MoneyPath would change that wire semantics — a cross-repo (ext/dig-node) contract change — so it is a deliberate SHAPE decision left for a decider, not built speculatively (§1.10).
- dig-node has no named pipe and no Unix domain socket.
SPEC.md§5.1,crate::ipc, and ticket #949 all described the app↔engine channel as a per-user OS pipe (\.\pipe\dignetwork-<USER>) / UDS (<RUNTIME_DIR>/dignetwork.sock), with the engine half deferred to "U6". Verified against dig-node v0.64.0origin/main:git grep -iE 'NamedPipe|UnixListener|dignetwork\.sock'across the whole repo matches only unrelated service-name strings. That listener was never built, soNullConnectorwas not a temporary stub over an almost-ready seam — it stood in front of a transport with no other end. - What dig-node actually answers (
dig-node-service/src/server.rs::serve_with_shutdown): JSON-RPC 2.0 over loopback HTTP,POST /, gated by anX-Dig-Control-Tokenheader. Up to three listeners for the ONE surface:127.0.0.1:9778(always on, fatal if it cannot bind),[::1]:9778(best-effort IPv6 twin), and127.0.0.2:80for barehttp://dig.local(best-effort). dig.localis on port 80, NOTDIG_NODE_PORT.dig_constants::DIG_LOCAL_HOST's own doc says a client "triesdig.local(onDIG_NODE_PORT)", but the node binds127.0.0.2:80for that name and nothing on127.0.0.2:9778. A client that appends the high port todig.localsilently loses the whole first §5.3 tier. Default the port from the URL SCHEME, never from the node's high port.rpc.dig.netis not a control tier. §5.3's fourth tier is a content-read fallback: the public gateway dispatches nocontrol.*and could not hold this machine's local control token, so probing it for node status can only mislead. The control ladder ends atlocalhost.- A node that REFUSES is a different fault from no node. The remedies diverge completely (a token/permission fault on a running node vs nothing installed or running), so the disconnected reason distinguishes them and names the control token when that is the cause. Collapsing both into "not connected" sends a person hunting the wrong thing.
dig-node-control-interfacemirrors the node faithfully but nothing enforces it. Itsresults::StatusResultis field-for-field whatcontrol.rs::statusemits (checked by hand). Yet dig-node does NOT depend on the crate —git grep dig-node-control-interfacein dig-node returns nothing — so the "cannot drift" property the crate claims is currently one-sided: only clients read it. It also does not carry theX-Dig-Control-Tokenheader name (it is transport-agnostic), so each client re-declares that constant.- A test double parked in a blocking
acceptHANGS the suite when nobody dials it. Found while proving a ladder test load-bearing: reverting the fall-through made the test hang, not fail — which reports nothing and would stall CI. A one-shot fake server MUST unblock its ownaccepton drop. - A machine with a real dig-node installed breaks env-based fixtures. Tests that pointed
DIG_NODE_STATE_DIRat a temp dir still found the REALC:\ProgramData\DigNode\control-tokenthrough the later default candidates, so "no token here" assertions passed a real token. Make the lookup take an explicit candidate list and assert on that, rather than mutating global env and hoping the machine is clean.
Measured against pristine ubuntu:24.04 in a container, with a release dig-app built inside it.
- The dlopened indicator library panics; it does not return an error. With GTK installed and
libayatana-appindicator3-1absent,libappindicator-sys-0.9.0/src/lib.rs:41panics ("Failed to load ayatana-appindicator3 or appindicator3 dynamic library") and the process DIES. The long-standing assumption — recorded inSPEC.mdand in the shell's own comments — was that a missing indicator yields a running process with no icon. It does not. A panic unwinds past theResultthe whole degrade path is written around, sotray_unavailable_adviceand its two unit tests were dead code on the one platform they were written for: green tests, perfect message, unreachable. Mounting is now wrapped incatch_unwind(dig_app::tray_render::mount_or_degrade). lddnames ALL missing link-time libraries; the loader names only the FIRST. Thetraybuild needs seven:libgtk-3.so.0,libgdk-3.so.0,libgdk_pixbuf-2.0.so.0,libcairo.so.2,libglib-2.0.so.0,libgobject-2.0.so.0,libgio-2.0.so.0. Running the binary reports onlylibgdk-3.so.0, so fixing them one at a time looks like a fresh regression on every attempt — the same trap the earlier libxdo hunt fell into. Four packages cover all seven:libgtk-3-0t64,libgdk-pixbuf-2.0-0,libcairo2,libglib2.0-0t64(verified: installing them leaveslddclean). Note the Ubuntu 24.04t64suffixes —libgtk-3-0andlibglib2.0-0are the wrong names there.- A dlopened dependency is invisible to
ldd. The indicator library never appears inlddoutput orDT_NEEDED, so a dependency audit based onlddalone will always miss it. It has to be listed by hand, alongside the runtime executables the tray shells out to (xclipfor the clipboard,xdg-openfor the log folder). - Reproducing this needs a DISPLAY, not just a container. Without one,
FormFactor::detectreturnsHeadlessand the tray is never attempted, so the bug hides.Xvfb :99+DISPLAY=:99is what makes it appear.
Alt+Spaceis not free on Windows — it opens the focused window's system menu (Move / Size / Minimize / Close), and has since Windows 3.x. A successfulRegisterHotKey(NULL, id, MOD_ALT, VK_SPACE)takes precedence and suppresses that menu globally, for every application, for the app's whole lifetime. Claiming it is a real trade, taken with precedent (PowerToys Run ships the same default) and disclosed inStatus— not a free win.MOD_NOREPEATis not optional. Without it, holding the chord down deliversWM_HOTKEYat the keyboard auto-repeat rate, which opens one bar per tick.WM_HOTKEYfromRegisterHotKey(NULL, …)is a THREAD message with no window, posted to the queue of the thread that registered it. It cannot reach a window procedure, so a shortcut cannot be handled by adding a case to an existingwnd_proc, and depending on tao to surface it would be depending on tao forwarding a message it has no reason to. A dedicated thread with its ownGetMessageWloop is the shape. Windows releases a thread's hotkeys when the thread ends, so process exit frees the chord.WS_CAPTIONisWS_BORDER | WS_DLGFRAME, sostyle & WS_CAPTION != 0does NOT mean "has a caption". A frameless window that deliberately keepsWS_BORDERfor its edge trips that test. The assertion has to be against the FULL mask. This cost a red test on the first run and would otherwise have been a screenshot bug.- A locked desktop makes
SendInputsucceed and deliver nothing. The call returns the full event count — it queued them — but a locked session's input goes to the Winlogon desktop, so a global-hotkey test synthesizing its own keystroke times out looking exactly like a broken shortcut. Rule out the lock before believing that failure.
- dig-app has NO chain source for a balance today, and the reason is one level down.
dig-node-control-interface0.2.0's method catalog carries nocontrol.wallet.*entry, and theWalletEngineseam has no production implementation — so a node can be running, reachable and healthy and still be unable to answer "what do I hold?". That is why the wallet window distinguishes no node from a node that does not serve wallet reads: the two look identical from the app and call for completely different things from the user. Adding a balance read means adding the method to the node's published contract first (release-first), not reaching for a chain from the app. - An address that is merely
xch1-shaped proves nothing. Every wrong derivation — the wrong profile index, a dropped.derive_synthetic(), a seed one bit out — produces a perfectly well-formed address that receives nothing recoverable. The check that has teeth is a SECOND derivation, in-test, fromchia-bls+chia-puzzle-types+ a locally-written bech32m encoder, plus a frozen literal. The encoder is worth writing by hand: it makes the bech32m half independent of thechia-wallet-sdkAddresstype that produced the value under test. - Bech32 vs bech32m is a checksum constant, and both encode the same payload. The two differ only in
the final XOR (
1vs0x2bc830a3), so a wrong-variant address has an identical body and six different trailing characters — invisible by eye. When reproducing an address vector out of tree, check the constant before concluding the derivation drifted. - A
PrintWindowcapture of a Win32 dialog can silently drop later-drawn text. Photographing the wallet window withPrintWindow(hwnd, dc, PW_RENDERFULLCONTENT)showed the first two paragraphs and an empty slab where the rest belonged — indistinguishable from the body-clipping bug #49 fixed. A DPI-awareCopyFromScreenover the same window rect showed the whole thing. Make the capturing process DPI-aware (SetProcessDpiAwarenessContext(-4)) or its screen coordinates are scaled and it photographs the wrong rectangle.
Two distinct silent-text failures, both invisible to contains(...) assertions and both found only in a
photograph. They are worth writing down together because they look identical on screen — a sentence that
is wrong in a way no test noticed.
1. A STATIC wraps at SPACES, and clips what it cannot wrap. Give it a 130-character otpauth://
URI and there is no wrap opportunity, so it draws one line and silently discards the rest — no error, no
ellipsis, no return value. This is the same family as the recovery-phrase window that showed ten of
twenty-four words (#49), and it is the reason provisioning_uri was DELETED rather than drawn in #1840.
The height estimate cannot save it: the estimate divides a character count by a line width and correctly
concludes the text needs three lines; the control simply refuses to use them.
The fix is break_long_runs in confirm::windows_input — insert breaks inside any whitespace-free run
longer than a line, leaving prose alone. The sharp edge is that fixing it is only HALF a fix: measuring
the broken text while drawing the raw text reproduces the clip exactly, and no assertion about heights
can see the difference. So the Layout now carries the body's TEXT as well as its height, and the
drawing code takes both from there — the pair is unrepresentable apart, which is the same reasoning that
already put the control positions and the total height on one struct.
2. A string literal can acquire a run of spaces mid-sentence. Rust's \-at-end-of-line continuation
strips the following indentation, so a hand-written literal is fine — but a literal written by ANY tool
that treats the backslash as its own line continuation (a Python heredoc doing string surgery on a source
file, for one) keeps the indentation, and ships "Or add it by hand — choose to add…". Every
substring assertion still matches. This has now happened three times in dig-app-core; the destroy
window's copy uses concat! for exactly this reason.
no_screens_copy_carries_a_hole_mid_sentence is the cheap guard: walk everything a journey showed, skip
the deliberately column-aligned code blocks (identified by carrying no lower-case letters), and refuse
any prose line containing three consecutive spaces. Worse than prose is a URI: the same mangling turned
a demo otpauth:// string into one with spaces in it, which still encoded into a square that LOOKS
exactly like a QR code and that no authenticator can parse.
How a QR is actually verified. Not by looking at it. Render the window, screenshot the REAL pixels (the capturing process must be per-monitor DPI-aware, or Windows virtualises 3840x2400 down to 1536x960 and the modules blur into each other — you then measure your screenshotter, not your window), and decode with an implementation that is not the encoder. Degrading the capture first — perspective warp, downscale, Gaussian blur, lossy JPEG — approximates the camera path, which is the one that matters: a QR that decodes from a pristine bitmap and not from a photograph is the exact failure worth catching.