## 2026-10-30 — C# record struct: `new T()` ignores the parameter defaults **Symptom:** two property controls "had no code behind them" — the Background checkbox and the opacity slider changed nothing visible. **Cause:** `public readonly record struct ScoreboardStyle(string TextColor = "#FFFFFF", bool BackgroundEnabled = true, …)`. `new ScoreboardStyle()` binds to the implicit **parameterless constructor**, which zero-initialises — the defaults in the parameter list are only used when the constructor is called with those arguments omitted *at a call site that supplies some of them*. Result: null colour, `BackgroundEnabled = false`, `FontOpacity = 0`, `FontSize = 0`. The renderer drew the zeros faithfully, so the UI looked dead while every unit test of the renderer passed (they constructed the style explicitly). **Rule:** a record struct with meaningful defaults needs an explicit parameterless constructor (`public T() : this() { }`). And a "defaults" test must construct the type the same way production does — with `new()` — never with explicit arguments, or it proves nothing. **Guard:** `ScoreboardRendererTests` asserts `new ScoreboardStyle()` yields white / on / black / 0.20 / max 200. # MyMistakes.md > **Two jobs**, distinguished by heading: > > 1. **Per-task failure log** — updated before every commit touching that task: > current iteration + why the last one failed. On task complete, committed AND > pushed → truncate to this stub. A new task does NOT seed this file until its > first failure. > 2. **Recipes registry** (DERIVED-SOLUTION RULE, see `AGENTS.md` 🔬) — the durable > home for one-off derived solutions, recipes, and how-tos. The moment you work > out a reusable solution, write it here **in the same session**. GREP THIS FILE > FIRST when you hit a "I've done this before but have to figure it out again" > wall. Recipe entries stay permanently (they are NOT truncated on task > completion) — only the failure log truncates. ## 🔬 Recipes registry ### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30) **Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a prompt, then answered `Permission denied (publickey)` everywhere, and the two md files that document the deploy only said *"copy this working tree into `/var/www/linkstack/`"* — a destination, not a transport. **What it actually is:** the static site lives in **`/var/www/linkstack/`** on the YunoHost droplet (`llamachile.tube` = `llamacasty.com` = `143.244.176.131`), owned `linkstack:www-data`. The host sshd is **port 2214** with **key-only auth** (`PasswordAuthentication no`; publickey is the only method offered for every user). The `CREDENTIALS.md` password is the **sudo** password — it is not an SSH auth method and never was. **The hang was the second factor:** sshd had `AuthenticationMethods publickey,keyboard-interactive` (PAM `pam_google_authenticator`), so a key got through and then the server asked for a verification code that a non-interactive tool call cannot answer. Disabled permanently 2026-10-30 by commenting that line and the PAM module in `/etc/pam.d/sshd`, then `sshd -t && systemctl restart ssh`. **The recipe:** `llamacasty.web/deploy.sh` — tars the **public** files only (`index.html`, `privacy.html`, `css/`, `js/`, `assets/`), `scp`s the tarball to `/tmp`, then one `sudo -S` shell that backs up the live root to `/var/backups/linkstack-.tgz`, extracts over it, restores `linkstack:www-data` + `755/644`, deletes `build-this.md` from the root, and curls the site. Password comes in on **stdin** (`SITE_SUDO_PW=... ./deploy.sh`), never on a command line. - ⚠️ **Never publish the md files.** `build-this.md` shipped in the web root until 2026-10-30 and now carries **INTERNAL** pricing notes. The deploy script's file list is explicit for that reason. - ⚠️ **drvfs sets 777 on copied files** — the chmod pass in the script is not cosmetic; a world- writable web root is a defacement waiting to happen. - ⚠️ **`/var/www/linkstack__2` is a different app's directory** (the old LinkStack PHP install). The static site is `linkstack`, without the suffix. - ⚠️ Ports **22 and 1319 are closed**; 2214 is the only SSH door, and it is the *host*, not Gitea (Gitea pushes go over **HTTPS** with the credential helper — see `docs/ai/environment.md`). ### POINTING www AT THE APEX ON THE YUNOHOST DROPLET (RECIPE, 2026-10-31) **Problem:** `www.llamacasty.com` did not resolve at all, and once a record existed the server half was four separate traps: YunoHost added the vhost with a **self-signed** cert, refused to issue Let's Encrypt without a diagnosis, and served the **SSO login** because the domain has no app. **1. DNS** — Namecheap BasicDNS, zone `llamacasty.com`. `www CNAME llamacasty.com.` (YunoHost's own subdomain example uses a CNAME per subdomain, and a CNAME follows the apex if the droplet IP moves; an `A www` record would need a second edit for every rebuild). The apex `A` is the only IP holder. **2. Domain + cert on the droplet:** ```bash yunohost domain add www.llamacasty.com --install-letsencrypt-cert # vhost + SELF-SIGNED cert yunohost domain cert install www.llamacasty.com --no-checks # real LE cert ``` - ⚠️ The `--install-letsencrypt-cert` flag **does nothing here**: *"Skipping Let's Encrypt certificate attempt because there's no wildcard configured on the parent domain's DNS records."* It exits SUCCESS with a self-signed cert, so check `openssl s_client -servername ` afterwards. - ⚠️ Plain `yunohost domain cert install` refuses with *"no diagnosis result for domain … use `--no-checks`"*. `--no-checks` is safe when the name resolves publicly — LE's HTTP-01 validation is itself the DNS proof (it failed over to a real cert on the first attempt). **3. The redirect** — with no app installed the vhost 302s to `/yunohost/sso?r=…`, so a `301` is needed. YunoHost includes `/etc/nginx/conf.d/.d/*.conf` **inside the server{} block**; `location / { return 301 https://llamacasty.com$request_uri; }` there wins over SSO because `return` runs in the rewrite phase, before `access_by_lua_file` (the global SSO guard). The `.d` include survives YunoHost regenerating the `.conf`; never edit the `.conf` itself. - ⚠️ **The heredoc ate `$request_uri`.** Writing that file through `ssh "sudo bash -c 'cat > … <`. **Verify:** `curl --resolve www.llamacasty.com:443:143.244.176.131 -o /dev/null -w '%{http_code} %{redirect_url}' https://www.llamacasty.com/privacy.html` → `301 https://llamacasty.com/privacy.html`; the cert must read `CN=www.llamacasty.com`, issuer Let's Encrypt. ### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51) **Problem:** MediaPipe segmentation models end in the custom op `Convolution2DTransposeBias`; tf2onnx and tflite2onnx refuse it. **Recipe:** `tools/selfie-seg/convert_tflite_to_onnx.py` — parse the flatbuffer with the `tflite` pip package, emit ONNX with `onnx.helper`, transpose NHWC→NCHW once at input/output, fold every `DEQUANTIZE` (fp16 weights) into fp32 initializers. Weight layouts: CONV OHWI→OIHW; DEPTHWISE [1,H,W,C]→[C,1,H,W] with `group=C`; transpose-conv [O,H,W,I]→[I,O,H,W]. TF `SAME` padding = ONNX `auto_pad=SAME_UPPER`. - ⚠️ **Enum trap:** the custom op's options are the C-API `TfLiteTransposeConvParams`, whose `padding` uses `TfLitePadding` (Unknown=0, **SAME=1**, VALID=2) — NOT the flatbuffer `Padding` enum (SAME=0). Comparing against `Padding.SAME` rejects the real model. - **Proof, not hope:** `pip install mediapipe` ships the TFLite runtime WITH the custom op. Feed an image already at the model's exact input size (no resampling on either side) to MediaPipe's `ImageSegmenter` and to onnxruntime; compare masks (`verify_against_mediapipe.py`, exit 0 at max |diff| < 1e-3 — we got 5e-5). Work in a throwaway venv under `/tmp/opencode/`. ### ⛔ NEVER PRINT CREDENTIALS.md — NOT EVEN "FILTERED" (2026-10-09 incident) **What happened:** to look for an API key, a `sed -n '1,40p' CREDENTIALS.md` piped through a redaction regex was run. The regex only matched `password: \`…\``; the **"Sudo pattern" line stores the password inside `echo '…'`**, slipped past the filter, and the full sudo password was printed into the session output (and so into local session logs and the model provider's transcript). Owner had to rotate it. **Rule:** never `cat`/`sed`/`grep`/`read` CREDENTIALS.md (or any secrets file) in a way that can print values. Redaction regexes are not a safety net — secrets hide in shapes you didn't anticipate. - **Need a secret for a command?** Load it into a variable, never echo it: `PW=$(sed -n 's/.*password:.*\`\(.*\)\`.*/\1/p' CREDENTIALS.md | head -1)` then use `"$PW"`. - **Need to know what's in the file?** List **keys only**, never values: `grep -oE '^\s*- \*\*[^*]+\*\*' CREDENTIALS.md` (bold labels) — or ask the owner. - **Looking for a specific secret?** Test for presence without printing: `grep -qi 'gitea' CREDENTIALS.md && echo present || echo absent`. - Secrets in source (e.g. the Google client secret) live in gitignored `Helpers/OAuthCredentials.cs` — check with `git check-ignore -v`, don't open it. **Rotation (YunoHost user `gramps`):** YunoHost owns the password (LDAP) — plain `passwd` may not apply. Owner rotates via web admin → Users → gramps, or `sudo yunohost user update gramps -p` (prompts). Never route the new password through the AI. ### YOUTUBE STATISTICS ARE JSON STRINGS; TryGetInt64 THROWS ON STRINGS (RECIPE) YouTube Data API v3 returns `statistics.subscriberCount / viewCount / videoCount` as JSON **strings** (`"350"`), not numbers. (2026-09-23: the YPP parse bug surfaced the moment the auditDetails 403 stopped masking it.) Fix = a tolerant read that checks `ValueKind` FIRST, then `TryGetInt64` for `Number`, then `long.TryParse(GetString())` for `String`. Trap within the fix: **`JsonElement.TryGetInt64` THROWS `InvalidOperationException` on any non-Number token** ("requires an element of type 'Number'") — it is try-type-in, not try-catch. Guard on `value.ValueKind` before calling it; a leaked 403→parse chain means your "whole request failed" symptom can paper over a second crash that only appears once the 403 is fixed (test the full happy path, not just the error path). ### liveChatId LIVES IN SNIPPET AND ONLY EXISTS ONCE THE BROADCAST IS LIVE (RECIPE) YouTube Data API v3 `liveBroadcasts`. `liveChatId` is in **`snippet.liveChatId`** — `contentDetails` has NO such property. AND it is only populated once the broadcast is **live** (the official `GetLiveChatId.java` sample lists `broadcastStatus=active`); a fetch right after insert (lifecycleStatus `ready`) returns nothing. (2026-09-25: this cost a whole investigation — the TEST-tab "Chat polling couldn't start — Mock Chat Input is disabled" report — because the code read `part=contentDetails` + ran before the frame pump pushed RTMP.) Fix = read part=snippet + bounded retry AFTER the encoder starts (`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)`). Debug lens: missing liveChatId ≈ "broadcast not live yet", NOT an auth failure. ### liveChat/MESSAGES.INSERT BODY REQUIRES snippet.type (RECIPE) YouTube Data API v3 `liveChat/messages.insert` rejects the body with `400 MISSING_REQUIRED_FIELD` (`domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`) unless the snippet declares `snippet.type` = `textMessageEvent` (or `pollEvent`) alongside `liveChatId` and `textMessageDetails.messageText` — the official insert reference lists `type` as a required property. (2026-09-25: the TEST-tab "YouTube rejected the message (error 400)" report — the liveChatId fix in TASK 44 had worked and the drawer's Mock Chat Input was issuing a real insert, but the body omitted `type`, and the TASK 41 test only asserted `liveChatId` + `messageText` were present so it stayed green while real YouTube rejected every send. Fix = add `type = "textMessageEvent"` to the body + assert it in the Good Dog test.) Debug lens: MISSING_REQUIRED_FIELD ≠ auth/scope — it means the request body shape is wrong, and the false-green test is the classic trap: assertion on the body was about WHAT WE SEND, so YouTube's required fields must be mirrored in the test. ### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE) YouTube Data API v3 `channels.list` rejects the ENTIRE request with `403 insufficientPermissions` if your `part=` list includes `auditDetails` but the token lacks `https://www.googleapis.com/auth/youtubepartner-channel-audit` — the docs' exact words: "A request that retrieves the auditDetails part for a channel resource must provide an authorization token that contains the youtubepartner-channel-audit scope". That scope is MCN content-partner tooling (with a two-week token-revocation rule); normal-creator apps should NEVER ask for it. 2026-09-23 real-log evidence: YPP refresh 403'd three times in a row while the mock-fake tests stayed green ("current scopes suffice, no re-consent" was wrong). Fixes: (1) drop `auditDetails` from `part=`; (2) log the response BODY — the bare status code could not name `insufficientPermissions`, which is what made this failure undiagnosable for days; (3) when a feature needs data no ordinary scope grants, deep-link to the site instead of requesting the partner privilege. ### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE) A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403 `invalidTransition` even though the stream just ended normally — 2026-09-22 logged it on EVERY session end. Cause: the broadcast's own state moves toward complete via `enableAutoStop`/YouTube auto-complete; the transition method's errors doc (https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition) shows `invalidTransition` = "can't transition from its current status". A blind POST just races — read the broadcast's `lifeCycleStatus` first (`liveBroadcasts.list?part=status`) and only POST complete from `live`/`testing`; skip silently otherwise and let `enableAutoStop` finish it. Never throw on the close-out either way. ### YOUTUBE liveStreams LIST: healthStatus IS AN OBJECT, NOT A STRING (RECIPE) YouTube Data API v3 `liveStreams.list` nests health under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the `healthStatus` element is an **OBJECT**, and `configurationIssues[]` lives INSIDE it, not directly under `status`. Reading `healthStatus.GetString()` throws System.Text.Json's `The requested operation requires an element of type 'String', but the target element has type 'Object'` — the exact log line seen 2026-09-22 on every live health poll (two test sessions). The parse must read `healthStatus["status"]` (and nested `healthStatus["configurationIssues"]`). Something like REST-shape drift is a good reason to grep the API reference (https://developers.google.com/youtube/v3/live/docs/liveStreams) before writing parsers against a "remembered" shape — our own flat-string fixture was the wrong assumption the whole time. ### WINRT RESOURCE-ALLOCATION CALLS: WRAP PER-CALL, DEGRADE TO NEXT OPTION (RECIPE) Creator callout (2026-09-15): WinRT/COM calls that allocate or start a resource — `InitializeAsync`, `CreateFrameReaderAsync`, `StartAsync`, `CreateReaderAsync` & friends — are THROW-HEAVY. An unsupported subtype/format, a device that just vanished, an access mode rejected mid-flight: these surface as `ArgumentException`/`E_INVALIDARG` ("value does not fall within the expected range") or HRESULTs, NOT as a returned status you can switch on. Relying on ONE outer catch to "handle failures" is not handling — one rejected call inside a fallback ladder aborts the whole ladder and every untried option. Rule: - Each allocation/start call inside a try/catch of its OWN, so a throw on candidate N falls through to candidate N+1 (log each rejection with its message/status; collect them for the final error string). - `return`/`break` on success must be reached WITHOUT passing through a `finally` that disposes the resource you just committed (classic reader/capture dispose-after-commit bug). - Unsubscribe + dispose the partial resource in the catch block when the subscription happened before the throwing call. - The outer catch stays as the LAST-RESORT net for device-level errors, not the primary one. - Same discipline applies to the frame-consumption side: teardown races reader threads (see HANDOFF crash follow-up) — a frame callback can't assume the pipeline is alive. Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix). ### "SAVE-RECORDING DIALOG IS CLIPPING CONTENT" — WHERE + HOW (RECIPE) **WHERE:** the end-of-recording modal (creator: "when I end a recording the app throws up a save-recording dialog with a default filename") is **`RenameRecordingDialog.xaml` at the repo ROOT** (title "Rename Recording", `x:Class="ytLive.RenameRecordingDialog"`). It is NOT one of the `*Dialog.xaml` files under `Controls/` — grep for the window title, not the filename, when the user names a dialog by what it does. Only fix dialogs the user actually named; do not "while I'm here" bump sibling dialogs. **HOW:** the dialog is `ResizeMode="NoResize"` with a fixed `Height` attribute; content client area ≈ `Height − ~37px` of window chrome. Sizing/margin changes pushed content down in Z-order until it clipped — first the file-name textbox (230 too short), and after the +20% bump (→276) the creator found the Cancel/Save button row had ALSO been obscured. Fixed `Height` to 304 (+10% more), `x:Name` the last interactive control (`SaveRecordingButton`) so a test can measure it. **Verify (never eyeball-predict):** `RenameRecordingDialogSizingTests` (RealApp host) instantiates the real dialog, `Show()` + `UpdateLayout()`, then asserts the BOTTOM EDGE of each named control (`TransformToAncestor` against the content root → `TransformBounds(RenderSize).Bottom`) is ≤ the client `ActualHeight`. Every such dialog fix ships with that assertion for every control that was reported obscured. (Client-area math means the button row needs MORE slack than the textbox: its bottom sits deeper because of the `Margin="0,20,0,0"` above it.) ### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE) **THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync` produces an **OPAQUE** PNG. From the WebView2 spec (sender: MicrosoftEdge/WebView2Feedback `specs/BackgroundColor.md`): "WebView will always honor a webpage's background content." `DefaultBackgroundColor = Transparent` only shows through pages with NO background style — the widget's own CSS paints html/body opaque. Every take below built on the false premise "the capture has transparent margins, alpha=0"; it never did. `FindContentBounds` then had no alpha-0 margins to find → wrong crop → black bounding box. The compositor blend saw alpha=255 → black over webcam = "transparency broken". Same bug, three symptoms. **THE FIX (the OBS way, applied 2026-09-08):** inject the transparency BEFORE the page parses using `CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync` — documented to run "before the HTML document has been parsed and before any other script included by the HTML document is run" (learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.addscripttoexecuteondocumentcreatedasync). The old `ExecuteScriptAsync` on NavigationStarting/NavigationCompleted ran AFTER page scripts/CSS → widget page repainted background opaque → lost the fight. OBS browser sources do the same via a pre-parse user.css. Kept the nav handlers as a post-load re-assertion. **Take timeline (the honest record):** - `bccdb48` (Aug 28): added FindContentBounds crop — correct idea (tight bbox, no dead space), but the capture was OPAQUE so the bbox math was built on nothing. - `5348b5c` (Aug 28, 2 min later): reverted to full-frame no-crop — looked "good" for a full-bleed widget, but floating widgets regained dead space ("ghost boundary"). - take-21 (`e002847`) full canvas → shrunken/offset widget (UnifomToFill of whole canvas into a small element = lost resizing). - take-22 (`ed9d7c1`) crop width used as canvas stride for buffer indexing → garbage. - take-23 (`f6802c7`) stride fixed with `src.Width`; PasteKey lacked CropBounds → stale raster cache → stale crop. - take-24 (`081e4c1`) CropBounds in PasteKey — STILL BROKEN because the SOURCE ALPHA WAS NEVER REAL. - take-25 (**ME, this session — the user's "RE-INTRODUCING THE BOUNDING-BOX PROBLEM"**): I removed FindContentBounds + the crop path entirely, betting full-canvas UniformToFill was the answer. It WASN'T — the source is opaque-black, so the element rendered as a SOLID BLACK BOX (the user's screenshot: "a black box in the lower right corner"). Killed the crop → dead space returned AND black box. The compositor math was ALREADY correct; gutting it was vandalism in response to a source-level bug. **Rules, self-inflicted:** 1. Instrument FIRST. This session added: first-capture PNG dump of the raw WebView2 PNG (to `%TEMP%\ytLive-web-.png`) + alpha min/max/mean/%zero + FindContentBounds result logged to startup.log once per session. That's the diff between a five-take loop and a five-minute diagnosis. 2. When the same symptom loops across takes, the PREMISE is wrong, not the code — the capture being transparent was the load-bearing premise and it was never verified. 3. Do not delete code paths that fix one axis (crop=bbox) while debugging another (source alpha). Revert scope creep; keep layer contributions separable. **2026-09-10 follow-up → NOW VERIFIED and hardened.** The 12:54 and 13:50 sessions showed the REAL widget document capturing TRANSPARENT (widget dumps `w1..5` for `8d7234ec`: alpha 100% zero, content 12×3; whole-file decodes of `%TEMP%\ytLive-web-*.png` alpha max = 0) while the RECORDING kept showing a black, opaque box over the element rect (crisp edges at the exact rect 1231,679 705×396 — NOT the desktop showing through). Concluson: branch (a) — the OLD inline `element.style.background='transparent'` injection only wins while the page has nothing to paint; once the widget connects and paints its own container background-COLOR, the capture goes opaque again → black box. The OBS-validated answer (valid for arbitrary pages for a decade) is an injected pre-parse `