diff --git a/Distribution.md b/Distribution.md
index 457d9f3..7bc0c22 100644
--- a/Distribution.md
+++ b/Distribution.md
@@ -97,9 +97,13 @@ version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring
-- `IEntitlement` has two implementations: **Steam DLC** (lifetime, local) and **Polar** (monthly,
- network + 7–14 day lease). Resolution is `Pro = steamDlc || polarValid`; anything unresolved is
- the free profile.
+- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
+ (`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
+ beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
+ the Debug-only `DeveloperMode`; anything unresolved is the free profile.
+- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
+ answers false without touching the native library. Set the id when Steamworks creates the DLC.
+- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
diff --git a/HANDOFF.md b/HANDOFF.md
index 84c306c..708e822 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -17,11 +17,11 @@
## Next action
-**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Undo/redo, the rail gear and the
-EDIT alignment landed early on 11-01, so day 6 has room. Day 1 unchanged: licensing —
-`IEntitlement` + Steam DLC + fake, `HasProOutput` on the seam, greyed tiers, licence UI's four
-states, system messages, licence ID in About. Days 2–6 in `TASKS.md` → "The surge".
-No scope beyond that list. No polish before day 6.
+**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
+`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
+with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
+early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
+About. Days 2–6 in `TASKS.md` → "The surge".
## Landmines
diff --git a/Services/Entitlement/FakeEntitlement.cs b/Services/Entitlement/FakeEntitlement.cs
new file mode 100644
index 0000000..a090edd
--- /dev/null
+++ b/Services/Entitlement/FakeEntitlement.cs
@@ -0,0 +1,13 @@
+namespace ytLive.Services.Entitlement;
+
+///
+/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
+/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
+/// answers without a Steam client or a network.
+///
+public sealed class FakeEntitlement : IEntitlement
+{
+ public FakeEntitlement(bool isPro = false) => IsPro = isPro;
+
+ public bool IsPro { get; set; }
+}
diff --git a/Services/Entitlement/IEntitlement.cs b/Services/Entitlement/IEntitlement.cs
new file mode 100644
index 0000000..9c7065c
--- /dev/null
+++ b/Services/Entitlement/IEntitlement.cs
@@ -0,0 +1,19 @@
+namespace ytLive.Services.Entitlement;
+
+///
+/// Where "is this copy licensed for Pro output?" comes from.
+///
+/// One question, two sources. The lifetime SKU is a Steam DLC; the monthly is a Polar
+/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
+/// tier list, the notices — reads only this. Nothing outside Services/Entitlement may know
+/// which rail proved it.
+///
+/// Fail closed. An implementation that cannot reach its source, cannot load its
+/// native library, or has no product id configured returns false. Pro is never a default,
+/// never a cached "probably", and never the result of an exception being swallowed as true.
+///
+public interface IEntitlement
+{
+ /// True when this session may emit above the free output budget.
+ bool IsPro { get; }
+}
diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs
new file mode 100644
index 0000000..45dfc58
--- /dev/null
+++ b/Services/Entitlement/SteamEntitlement.cs
@@ -0,0 +1,82 @@
+using Steamworks;
+
+namespace ytLive.Services.Entitlement;
+
+///
+/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
+///
+/// This is the whole purchase story. There is no key, no server and no lease — the
+/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
+/// the answer; a refunded DLC stops reporting ownership the next time the app starts.
+///
+/// ⛔ Never sell the monthly plan through this. Steamworks requires in-app purchases
+/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
+/// monthly lives on the website and is billed by Polar ( when it
+/// lands); this class only ever reads what Valve already sold.
+///
+/// Steam absent is normal, not exceptional. A copied folder, a shortcut run without
+/// the client, or a CI machine has no Steam — returns false and the
+/// app runs as the free tier. It must never refuse to start over an entitlement lookup.
+///
+public sealed class SteamEntitlement : IEntitlement
+{
+ /// The DLC's own app id, not the base app's. Zero = not configured yet, which reads
+ /// as "no entitlement" rather than "try anyway".
+ private readonly uint _dlcAppId;
+
+ private bool _initAttempted;
+ private bool _available;
+ private bool? _cached;
+
+ public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
+
+ /// Configured with no product id — the state before the Steamworks app exists.
+ public static SteamEntitlement NotConfigured() => new(0);
+
+ public bool IsPro
+ {
+ get
+ {
+ if (_dlcAppId == 0) return false;
+ if (_cached is { } known) return known;
+
+ if (!_initAttempted)
+ {
+ _initAttempted = true;
+ try
+ {
+ // No steam_appid.txt and no client → false. That is the documented free path.
+ _available = SteamAPI.Init();
+ }
+ catch (Exception)
+ {
+ // A missing steam_api64.dll throws on first touch. Free tier, never a crash.
+ _available = false;
+ }
+ }
+
+ if (!_available) { _cached = false; return false; }
+
+ try
+ {
+ var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
+ _cached = owned;
+ return owned;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+ }
+
+ /// Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
+ /// would be wrong, so nothing here schedules it automatically.
+ public void Shutdown()
+ {
+ if (_available) SteamAPI.Shutdown();
+ _available = false;
+ _cached = null;
+ _initAttempted = false;
+ }
+}
diff --git a/TASKS.md b/TASKS.md
index 17e283c..914b120 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -58,7 +58,7 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
| 53 | ☐ | Output-format contract: pin every tier with a geometry test, prove 9:16 end-to-end, recorded file matches output | [`task-53`](TASKS/task-53-output-formats.md) |
| 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
-| — | ☐ | **Steamworks entitlement** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) |
+| — | 🔶 | **Steamworks entitlement** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks, then the ownership path is live | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.md) |
| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Licence UI** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) |
diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs
index b86e031..cf08765 100644
--- a/ViewModels/MainViewModel.OutputProfile.cs
+++ b/ViewModels/MainViewModel.OutputProfile.cs
@@ -1,4 +1,5 @@
using ytLive.Helpers;
+using ytLive.Services.Entitlement;
namespace ytLive.ViewModels;
@@ -23,7 +24,26 @@ public partial class MainViewModel
/// settable inside a development build (see ), so this expression
/// reduces to IsPremium in a Release binary.
///
- public bool HasProOutput => IsPremium || DeveloperMode;
+ public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro;
+
+ ///
+ /// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it
+ /// is , which reads false (fail closed). Tests
+ /// inject a fake through .
+ ///
+ private IEntitlement _entitlement = SteamEntitlement.NotConfigured();
+
+ /// Test seam: swap the rail without a Steam client. Internal — the test assembly is
+ /// the only caller, and a public setter here would be a licence bypass surface.
+ internal IEntitlement EntitlementOverride
+ {
+ get => _entitlement;
+ set
+ {
+ _entitlement = value;
+ OnPropertyChanged(nameof(HasProOutput));
+ }
+ }
private bool _developerMode;
diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md
index 9da098c..f2bd443 100644
--- a/docs/ai/commercial.md
+++ b/docs/ai/commercial.md
@@ -56,11 +56,16 @@ touches a feature. Fail closed: anything unresolved is the free tier.
### Steam (lifetime)
-- **Steam owns the lifetime SKU.** `ISteamApps::BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`.
- It is a local check against Steam's own state — no server, no key, no lease.
+- **Steam owns the lifetime SKU.** `BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`, behind
+ `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no
+ server, no key, no lease.
+- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client),
+ a thrown native load (missing `steam_api64.dll`), and "not owned" all resolve to the free tier.
+ None of them may crash the app or grant Pro.
- **Steam down or not running** → free profile, with a line explaining why, never a refusal to
start. A Steam build launched outside Steam is the common case for people who pin shortcuts.
-- **No licence key exists for the Steam SKU**, so nothing to paste, email or lose.
+- **No licence key exists for the Steam SKU**, so nothing to paste, email or lose. A refunded DLC
+ simply stops reporting ownership on the next start.
### Polar (monthly, sold off-Steam)
diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs
new file mode 100644
index 0000000..753b273
--- /dev/null
+++ b/ytLive.Tests/EntitlementTests.cs
@@ -0,0 +1,67 @@
+using System;
+using System.IO;
+using Xunit;
+using ytLive.Services.Entitlement;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a
+/// Steam DLC rail, a Polar rail to come, and a fail-closed default.
+///
+/// The rule that must never break: **no configured rail means the free profile**. A missing
+/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
+/// crash the app or quietly grant Pro.
+///
+public class EntitlementTests
+{
+ [Fact]
+ public void No_configured_dlc_id_reads_as_no_entitlement()
+ {
+ // The state before the Steamworks app exists: pure false, no native call attempted.
+ Assert.False(SteamEntitlement.NotConfigured().IsPro);
+ }
+
+ [Fact]
+ public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash()
+ {
+ // No steam_appid.txt in the test host and no client running: Init fails, we report false.
+ var entitlement = new SteamEntitlement(1234567u);
+ Assert.False(entitlement.IsPro);
+ }
+
+ [Fact]
+ public void The_fake_reports_what_a_test_sets()
+ {
+ var fake = new FakeEntitlement(true);
+ Assert.True(fake.IsPro);
+ fake.IsPro = false;
+ Assert.False(fake.IsPro);
+ }
+
+ [Fact]
+ public void The_view_model_takes_its_answer_from_the_rail()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-entitlement-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ var vm = new MainViewModel();
+
+ // Default rail is NotConfigured → free output.
+ Assert.False(vm.HasProOutput);
+
+ vm.EntitlementOverride = new FakeEntitlement(true);
+ Assert.True(vm.HasProOutput);
+
+ vm.EntitlementOverride = new FakeEntitlement(false);
+ Assert.False(vm.HasProOutput);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(tempDb); } catch (IOException) { }
+ }
+ }
+}
diff --git a/ytLive.csproj b/ytLive.csproj
index 266426b..4846332 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -175,6 +175,9 @@
+
+