docs: decisions log, queue trim, HANDOFF rewrite — the map stops being a treadmill

TASKS.md 467 → 66 lines: one line per live item, detail stays in TASKS/
task-*.md. The old catalogue (63 rows of paragraph status prose plus the
historical narration) is preserved verbatim as TASKS/archive.md — a
2026-10-29 snapshot, not the live queue. The "out of product" list and every
retired decision moved to docs/ai/decisions.md with cause of death.

HANDOFF.md 440 → 40 lines, and it is now OVERWRITTEN, not appended: branch,
what's in flight, next action, live landmines, pending creator checks. History
belongs to git log; the previous handoff is reachable at ee3c592:HANDOFF.md.

docs/ai/decisions.md (new) collects what no longer governs: the paid tier and
its pricing, Polar, Store IAP, the Store route and why it died, the deleted
Velopack updater, signing economics, the retired monetization ideas, and the
permanent out-of-product list.

Facts that lived in the old ai.md "Current limitations" section were re-homed
rather than dropped with the router rewrite: OAuth/DPAPI/session behaviour →
docs/ai/youtube-api.md; paths, persistence and the Google project number →
docs/ai/environment.md.

verify.sh caps now cover all four always-read files (AGENTS 210, ai 100,
HANDOFF 40, TASKS 120).

Docs-only unit: scope-check run, no build.
This commit is contained in:
2026-10-29 11:10:51 -07:00
parent c9ac348976
commit 5a102a4a37
9 changed files with 723 additions and 981 deletions
+89
View File
@@ -0,0 +1,89 @@
# Decisions log — retired paths, kept so they are not re-litigated
One dated line per decision that **no longer governs**, plus the reason it died. Read only when
you are about to re-open one of them. Live rules live in the subsystem files; this is the
graveyard with cause of death.
## Monetization — all superseded by the free-forever ruling (2026-10-08, TASK 54)
- **2026-09-21** one-time perpetual licence `$29` founder → `$49` list — superseded 6 days later.
- **2026-09-27** `$10/mo` subscription or `$400` lifetime, **no annual**. Store revenue share was
verified at **15%** (ADA v8.10 §6(b)(i) → `$8.50` / `$340` net), including subscriptions.
- **No annual tier** because Microsoft does not pro-rate: *"monthly subscriptions and initial
(pre-renewal) purchases aren't eligible for a prorated refund"* — a first-year subscriber who
cancels loses the remaining months and the developer cannot refund it.
- **Clean numbers, no `.99`** (creator: *"Let's stop with the x.99 stuff"*). The convention exists
only to make a price look cheaper, which is incoherent for a brand sold on honesty.
- **Escalation model** (2026-09-01): the credit was deliberately obnoxious self-promo; the cadence
*is* the nagware — no modals, no feature locks, no stream-time cutoffs.
- **Pre-GA posture — preview-only flash** (reversed 2026-09-26): the credit used to render in the
preview and never on the broadcast, to keep test VODs clean. The creator ruled the free tier must
be honest advertising; it now composites into output and recordings when enabled.
- **Rejected**: always-on watermark (obscurable — replaced by the flash), hard stream-time cutoffs
(a stream dying mid-broadcast reads as broken), soft-limit nagging, freemium feature tiers,
donation-only, and any resolution/quality ceiling. Auto step-down (TASK 33) is a *protect the
stream* feature, never a monetization penalty.
- **Polar** — $50 already paid, 5% + $0.50 Starter, merchant of record. Dormant, not
deleted-in-principle: no product, org ID, key prefix or discount code exists
(`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/`LLAMA50`, the portal-validate endpoint and
the 14-day offline-grace machine are all gone). `Distribution.md` §4.2/§4.5 keeps the design.
- **Store IAP** was never implemented — `Windows.Services.Store` appears nowhere in the repo.
- ⚠️ **Two obligations any future subscription would create** (ADA §6(h)): fulfil the whole period
as marketed (breach → Microsoft may refund in full at its discretion), and **raising the price
disables auto-renew** — the launch price is effectively locked for the product's life.
## Distribution — Store route withdrawn 2026-10-22
- **2026-09-27 Store MSIX + Store IAP decided; 2026-10-22 withdrawn.** A brand name on the
Store's *publisher* line requires a **Company** account → a registered legal entity (LLC ~$85
New Mexico with a ~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an
Individual account to a Company account later, and an Individual account publishes under the
verified legal name. The creator will not fund an entity for a nameplate.
- **2026-10-22 Velopack deleted** — it downloaded and executed, which Store policy 10.2.2 forbids.
The Store was going to update the app, so that premise died with the route: **the app now has no
update path**, and `StorePackagingTests` currently asserts that no updater exists — rewrite that
guard with the chosen updater, never leave it to veto one.
- **The MSIX package still builds** (159 MB `artifacts/LlamaCasty.msix`, submitted nowhere). The
route contradiction between `ai.md`'s older text and the TASKS files is settled in one sweep at
the distribution revisit, not patched piecemeal.
- **Signing**: Microsoft removed the EV instant-bypass for SmartScreen in March 2024, so EV buys
what OV buys; the 2026-10-22 ruling is **ship unsigned** and accept the "unrecognized publisher"
warning until download reputation accrues. Deferred, not rejected.
- **Steam when $100 exists**: Steam Direct $100/product (recoupable after $1,000 AGR), categories
*Audio/Video Production* / *Player Tools* (OBS precedent), developer/publisher name self-chosen
(no entity needed), 5+ 1920×1080 screenshots + capsule art, a build Valve runs, a 2-week live
page, 30% cut.
## Product surface — retired
- **2026-09-24 StreamElements replaced by native alerts** (TASK 43). No third-party widget feed:
the chat poll *is* the event feed.
- **2026-09-01 the screens/layers settings audit** was retired into the gold pass (TASK 36 item 3)
rather than run as session debt.
- **2026-10-04 launch position**: restoring the saved position was rejected for a derived
top-left placement. The live rule is in `ui-surface.md`; the old clamp's tolerance range is why
it must not come back.
- **D3D11 swap compositor** — superseded by the software compositor (TASK 3 item 16).
- **Windows 8 / 32-bit support** — 64-bit Windows 10/11 only (creator ruling 2026-10-09).
- **"There is no v1.x"** (2026-09-01): everything queues to v1 or to *out of product,
permanently* — no feature parks "for a later version".
## Out of product — permanently
Do not build these; each was explicitly ruled out.
- D3D11 swap compositor (superseded by the software compositor).
- OBS-style source expansion — game capture, browser-source zoo, media playlist, VLC, colour-key,
MIDI. The minimal source set is deliberate; creators who need more have graduated to OBS.
- Multi-layout / profiles / presets — that is what OBS is for.
- Cross-process stream resume (impossible via the API; creator ruling 2026-09-01).
- Stream Deck / Loupedeck integration — the device sends keystrokes, and global hotkeys already
*are* the integration.
- Per-source audio sync offset — global offset shipped (TASK 22); per-source is the OBS mixer
rabbit hole and breaks the one-knob audio rule.
- Chroma key / NVIDIA-only background removal (the bundled ONNX path is the one shipped).
- Virtual camera output — a different product and a device-driver swamp; our output is one
YouTube stream.
- Replay buffer, stream clipping, multi-destination restreaming, an in-app bug reporter, and an
advanced broadcast-form tab (latency/DVR/embed/projection/CC/region are fixed at sane defaults;
every exposed field is a support ticket).
+16
View File
@@ -138,3 +138,19 @@ save/load round trips are exact: an element removed in the UI (`RemoveElement`
`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Persistence & external accounts (lookup)
- Layout: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, schema v8 — scenes, sources, assets by SHA-256
content hash, socials, settings; **full rewrite per save**).
- Auth session: `%APPDATA%\ytLlive\ytLlive.auth` (DPAPI, CurrentUser scope).
- Startup log: `%APPDATA%\ytLlive\startup.log`, capped at 5 MB with one rolled copy
(`Helpers/LogRotation.cs`, TASK 55 slice 1).
- **Google Cloud project `198374416003`** — the OAuth ClientId prefix is the project number. It is
owned by **`llamachile.shop@gmail.com`** ("gramps"), which is **NOT** the Google account a creator
signs into YouTube with; console access and channel identity are separate logins. Direct link:
`https://console.cloud.google.com/apis/credentials?project=198374416003` — if the picker shows no
match it defaults to the **"Recent"** tab, so switch to **"All"**.
- LAN credentials live in the gitignored `CREDENTIALS.md`. ⚠️ It says Home Assistant is
`192.168.50.144`; the real HA is `192.168.50.145` (`.144` is `gordito`). Fix that file when next
editing it.
+1
View File
@@ -16,6 +16,7 @@ Read the file whose row matches the task. There is no required reading order.
| [`product.md`](product.md) | Brand/naming split, AI transparency, the GPL licensing prohibitions, the free-forever monetization posture, auth/account assumptions, and packaging/distribution | Branding, marketing, pricing, licensing, or packaging decisions |
| [`youtube-api.md`](youtube-api.md) | Go-live ordering + the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service |
| [`overlays.md`](overlays.md) | The social bar, the native alert box (six animations, alert clip, ticker) and the chat box conventions — everything composited over the frame | Adding or changing anything drawn on top of the output |
| [`decisions.md`](decisions.md) | Retired paths with cause of death: the paid tier and pricing, Polar, Store IAP, the Store route, the deleted updater, the rejected monetization ideas, and the permanent out-of-product list | You are about to re-open a decision that was already settled |
| [`ui-surface.md`](ui-surface.md) | The control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, and the theme/properties-panel conventions | Changing anything the creator sees: layout, controls, theming, properties panel |
## Split-out yet to come
+32 -95
View File
@@ -123,90 +123,38 @@ Apply this to every UI decision:
stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on
a high-end chassis, it doesn't ship — the OBS escape hatch is open by design.
## Monetization (design decision — the branding flash is the sword)
## Monetization — free forever, no watermark
> **⛔ Superseded 2026-10-08/09 — free forever, no branding on the stream.** The credit is now
> **OFF by default**: `MainViewModel.ShowBrandFlash` (internal static, default `false`) is the
> master switch, and `BrandFlashEnabled => ShowBrandFlash && !IsPremium` feeds the presenter at
> construction and on every licence change. The code (`BrandFlashPresenter`, the FramePump
> `brandFlash:` seam, the preview layer) is kept, not deleted — creator ruling: "don't delete it,
> just make it conditionally displayed". `BrandFlashOutputTests` opt in by setting the switch
> before constructing a `MainWindow`; `Credit_IsOffByDefault_EvenOnTheFreeTier` guards the default.
> Everything below is the historical paid-tier design.
**All features are free and ungated** (creator ruling 2026-10-08, TASK 54): no feature lock, no paid
tier, no watermark. Money comes from gratuity and merch, not gating. **No licence chatter reaches a
user** — no keys, no expiry, no licence UI. Support is email + GitHub issues; an in-app bug reporter
is out of product. `IsPremium` gates nothing in v1.
Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar
product, discount code, or license key exists any more** — nothing bills today; Polar is dormant.
Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model.
**The branding credit is OFF by default.** `MainViewModel.ShowBrandFlash` (internal static, default
`false`) is the master switch; `BrandFlashEnabled => ShowBrandFlash && !IsPremium` feeds the
presenter. The code is kept, not deleted — creator ruling: *"don't delete it, just make it
conditionally displayed."* `BrandFlashOutputTests` opts in by setting the switch before constructing
a `MainWindow`; `Credit_IsOffByDefault_EvenOnTheFreeTier` guards the default.
Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since
2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No
creator will tolerate a watermark, and as a good-guy developer we give everyone complete access
so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54).
If it is ever enabled, the mechanics that matter:
- Full-frame neon "made with LlamaCasty!" at a **random position per presentation**, held **2s**
(500ms in / 1000ms full / 500ms out), first ~5s after start then every `rand(30s)+30s`.
- Published to the frame pump and the preview from the **same** `VideoFrame`, so preview and
broadcast cannot drift. Deliberately **not** folded into `SceneGraph.GetBakedBase` — the bake
cache compares element ids and would freeze the credit in place.
- **App-lifetime cadence:** the presenter is `Start()`ed once in the `MainViewModel` ctor, not per
go-live, because go-live and local recording share one `FramePump`. `IsPremium`'s setter therefore
restarts the cadence timer when re-enabling (`IsCadenceTimerEnabled` is the test seam).
- **It composites into the recording and the stream** (reversed 2026-09-26 — the free tier must be
honest advertising). Test arithmetic trap: `Advance(5.1)` in one call blows through the 2s window
(it both opens and ages the presentation) — step in 1/30s increments like `BrandFlashOutputTests`.
- **A mid-credit downgrade** cuts the advertisement on the next tick: the presenter re-checks its own
`Enabled` gate every frame rather than letting the credit finish.
Pricing, billing, the Store IAP plan and the rejected alternatives are history →
[`decisions.md`](decisions.md).
- **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core,
feathered red/blue halo with the channels split in opposite directions), at a **random position
inside the frame on every presentation** (single pre-measured line, so it never wraps; travel range
is `0 … 1920−textWidth`), held **2s** (500ms fade in, 1000ms full, 500ms fade out), first credit
~5s after go-live then **every `rand(30s)+30s`** (30–60s), on the live output **and** local
recordings. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't
be cropped and is impractical to edit around on a live feed.
**The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own
viewers; the free tier is distribution, not compromise.
Implemented as `Services/Compositor/BrandFlashPresenter.cs` (raster + envelope + placement + licence
gate), published to **both** the frame pump (`FramePump(brandFlash:)` → the compositor's per-frame
`flashFrame` slot) and the preview (`BrandFlashElement`, bound to `BrandFlashImageSource`) from the
**same** `VideoFrame`, so the preview cannot drift from the broadcast. Deliberately NOT the
`flashFrame` parameter of `SceneGraph.GetBakedBase` — `SceneRegion.Matches` compares element ids
only, so a credit folded into the cached bake would freeze there and never expire.
**Pre-GA posture — REVERSED 2026-09-26 (TASK 36 shipped).** This used to render *in the preview
only*, never on the broadcast, so test VODs stayed clean. The creator ruled that the free tier must
be honest advertising: it now composites into the recording and the stream. Test VODs from an
unlicensed instance carry the credit — use a license key or the paid build for clean captures.
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting.
License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes
between free and paid, ever.
**Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is
`Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be
gated on `IsLive`, which meant a recording made without ever going live carried no credit — the
creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"*
and *"should also appear in recordings"*. One start now covers every scene, the preview, the
stream and the recording, because go-live and local recording are the **same `FramePump`**
(`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:`
delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The
licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere.
**Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops
the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer
called per go-live, nothing would ever restart it, so a key entered mid-session would leave the
credit dead until the process restarted. The setter therefore restarts it when re-enabling while
`_running` (`IsCadenceTimerEnabled` is the test seam).
**Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a
credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single
5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer
(the `Step` helpers in `BrandFlashOutputTests`).
- **Paid:** branding flash removed. `BrandFlashEnabled` is now a **derived, non-assignable**
`!IsPremium` and the presenter's own `Enabled` gate is re-checked on every frame, so the
entitlement changing mid-credit cuts the advertisement on the next tick rather than letting it
finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording —
everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice;
what it gates has not.**
- **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as
reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and
overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from.
**How `IsPremium` would be obtained is a billing concern; what it gates is not.**
- **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The
Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere
in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever
returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter,
**$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org
ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/
`LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all
gone).
- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the
once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are
how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with
emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app
is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is
struck.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
@@ -252,12 +200,6 @@ removal above). It has three layers, all free — this is the product's differen
via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate +
snapshot history were baked into slice 1.
**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on
the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer
"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the
stream* feature, never a monetization penalty.
## Auth gates Go Live, but not exploration
@@ -277,15 +219,10 @@ YouTube channel, they go to YouTube first. This assumption is explicit and must
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
YouTube" button; going live is unreachable until an account is connected.
## Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped)
## Windows packaging & distribution (direct + Steam; Store dropped 2026-10-22)
**The Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a
**Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a
~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to
a Company account later, and an Individual account publishes under the verified legal name. The
creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on
2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in
the repo.
**The Microsoft Store route is dead** — a brand on the publisher line needs a Company account and
a legal entity. Cause of death and the abandoned routes: [`decisions.md`](decisions.md).
**The route now:**
@@ -317,7 +254,7 @@ the repo.
`runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in
`MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`.
- **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct
download, and it fixes the 2026-09-01 404). See [`docs/ai/output.md`](docs/ai/output.md) → "FFmpeg locator".
download, and it fixes the 2026-09-01 404). See [`output.md`](output.md) → "FFmpeg locator".
- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set
`<PlatformTarget>x64</PlatformTarget>` (the test project too — an AnyCPU test project
referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg
+22
View File
@@ -253,3 +253,25 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped
2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16)
and the RewardEvent persistence (item 20) remain open.
## Auth & session (YouTube OAuth)
- `Helpers/OAuthCredentials.cs` holds the real ClientId/ClientSecret. Auth is complete and the
session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`,
CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry
access token.
- Sign-in / Change Account lives **inside the Start Stream dialog** — a two-state flow, no separate
Connect button. Sign-out is **explicit only**: `StopStream()` does NOT clear the session
("stopping a recording leaves the creator signed in", 2026-08-29).
- `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback
(test seams + save hook; services are constructed in `MainViewModel`).
- **Change Account must never sign out first** (fixed 2026-09-27): `SignOutYouTubeAsync` →
`TokenStore.Clear()` **deletes** `ytLlive.auth`, so cancelling in the Google chooser left the
creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites
the token through the `sessionChanged` hook; cancel/failure leaves the session untouched.
- **The OAuth flow releases its loopback port on retry:** an abandoned browser attempt holds
`localhost:8765` for its 5-minute timeout, so `AuthenticateAsync` cancels and awaits the abandoned
attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without the gate, two
overlapping calls both see a free port and race to bind it.
- Test seam: `YouTubeAuthService.SetSession()` is public — inject a channel with a future
`TokenExpiry` so `EnsureToken()` passes offline, paired with `HttpClient(new StubHandler())`.