diff --git a/code/CLI_CONTRACT.md b/code/CLI_CONTRACT.md
index 86076cd..7df571b 100644
--- a/code/CLI_CONTRACT.md
+++ b/code/CLI_CONTRACT.md
@@ -106,11 +106,25 @@ The text-only template format (`--template`) is documented separately in
## Debenu license key
Rendering (not argument validation) requires a Debenu Quick PDF Library 10.13 license key at
-runtime, read from the `DEBENU_LICENSE_KEY` environment variable. Without it, every render fails
-at the save step with exit `1` — confirmed directly against the vendor DLL: page creation, font
-embedding, and text drawing all succeed, but `SaveToFile`/`SaveToString` both return error code
-999 regardless of content. A working key for local development is kept at the project root in
-`key.txt` (untracked — never commit it or hardcode it into source).
+runtime. Without it, every render fails at the save step with exit `1` — confirmed directly
+against the vendor DLL: page creation, font embedding, and text drawing all succeed, but
+`SaveToFile`/`SaveToString` both return error code 999 regardless of content.
+
+The key is resolved in this order (`DebenuLicenseKeyResolver.cs`):
+
+1. The `DEBENU_LICENSE_KEY` environment variable, if set to a non-blank value.
+2. Otherwise, a `key.txt` file, walked up from the CLI executable's own directory (its own
+ directory first, then each parent, up to 10 levels) — so a `key.txt` dropped next to
+ `EnvelopeRenderer.Cli.exe` is picked up automatically, and a `key.txt` at the project root
+ still works for local `dotnet run` development without needing to set anything.
+
+This matters for the desktop app in particular: `EnvelopeRenderer.Desktop` launches
+`EnvelopeRenderer.Cli.exe` as a child process, which by default only inherits environment
+variables that were already present in whatever process launched the desktop app itself (a
+double-clicked `.exe` or Start Menu shortcut typically has none). Requiring an operator to set an
+environment variable before every launch is not viable, so the key.txt fallback is the intended
+path for that workflow — never commit a real key.txt or hardcode a key into source; it's
+untracked (`.gitignore`) for exactly this reason.
## UNC paths
diff --git a/code/README.md b/code/README.md
index 47f32c4..c1e0ca5 100644
--- a/code/README.md
+++ b/code/README.md
@@ -40,6 +40,12 @@ executable has been moved or is deployed separately, point the desktop app at it
`ENVELOPERENDERER_CLI_PATH` environment variable (see
[`Launch/CliExecutablePathResolver.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs)).
+Rendering needs a Debenu license key (see "Debenu license key" in `CLI_CONTRACT.md`) — since the
+desktop app's child CLI process only inherits whatever environment variables were already present
+when the desktop app itself was launched (typically none, for a double-clicked `.exe`), the
+simplest way to supply it here is to drop a `key.txt` file next to `EnvelopeRenderer.Cli.exe` in
+the build output folder; the CLI finds it automatically without any environment variable setup.
+
The render runs in a hidden child process — the desktop app now reads its `PROGRESS`/`ERROR:`
stdout/stderr streams itself (Sprint 1, Batch 5: "Show render progress and completion summary")
rather than leaving a separate visible console window, per `CLI_CONTRACT.md`. While a render is
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKey.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKey.cs
index a9d113a..61f9d99 100644
--- a/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKey.cs
+++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKey.cs
@@ -2,30 +2,12 @@ namespace EnvelopeRenderer.Cli.Tests;
///
/// Resolves a local Debenu Quick PDF Library license key for integration tests that need to
-/// verify real save output — never committed, never hardcoded. Checked in order: the
-/// DEBENU_LICENSE_KEY environment variable, then an untracked key.txt found by walking up from
-/// the test output directory (the project root, where the developer's local copy lives).
+/// verify real save output — never committed, never hardcoded. Delegates to the same
+/// the shipped CLI uses, so tests and
+/// production share one resolution rule (env var, then a key.txt walked up from the executable's
+/// own directory).
///
public static class DebenuLicenseKey
{
- public static string? Resolve()
- {
- var fromEnv = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY");
- if (!string.IsNullOrWhiteSpace(fromEnv))
- {
- return fromEnv;
- }
-
- var directory = new DirectoryInfo(AppContext.BaseDirectory);
- for (var i = 0; i < 10 && directory is not null; i++, directory = directory.Parent)
- {
- var candidate = Path.Combine(directory.FullName, "key.txt");
- if (File.Exists(candidate))
- {
- return File.ReadAllText(candidate).Trim();
- }
- }
-
- return null;
- }
+ public static string? Resolve() => EnvelopeRenderer.Cli.DebenuLicenseKeyResolver.Resolve(AppContext.BaseDirectory);
}
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKeyResolverTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKeyResolverTests.cs
new file mode 100644
index 0000000..61eacef
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuLicenseKeyResolverTests.cs
@@ -0,0 +1,73 @@
+namespace EnvelopeRenderer.Cli.Tests;
+
+///
+/// Mutates the process-wide DEBENU_LICENSE_KEY environment variable, so these tests always
+/// save/restore it around a narrow act phase and never run assertions while it differs from the
+/// ambient value other tests (e.g. the real-DLL integration test) expect.
+///
+public class DebenuLicenseKeyResolverTests : IDisposable
+{
+ private readonly string? _originalEnvValue = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY");
+ private readonly string _tempRoot = Directory.CreateTempSubdirectory("EnvelopeRendererLicenseKeyTests-").FullName;
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", _originalEnvValue);
+ Directory.Delete(_tempRoot, recursive: true);
+ }
+
+ [Fact]
+ public void Resolve_PrefersEnvironmentVariableOverKeyFile()
+ {
+ File.WriteAllText(Path.Combine(_tempRoot, "key.txt"), "from-file");
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", "from-env");
+
+ var result = DebenuLicenseKeyResolver.Resolve(_tempRoot);
+
+ Assert.Equal("from-env", result);
+ }
+
+ [Fact]
+ public void Resolve_FallsBackToKeyFileInStartDirectory_WhenEnvVarUnset()
+ {
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", null);
+ File.WriteAllText(Path.Combine(_tempRoot, "key.txt"), " from-file-with-whitespace \r\n");
+
+ var result = DebenuLicenseKeyResolver.Resolve(_tempRoot);
+
+ Assert.Equal("from-file-with-whitespace", result);
+ }
+
+ [Fact]
+ public void Resolve_WalksUpToAnAncestorDirectory_WhenEnvVarUnset()
+ {
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", null);
+ var nested = Directory.CreateDirectory(Path.Combine(_tempRoot, "bin", "Debug", "net10.0")).FullName;
+ File.WriteAllText(Path.Combine(_tempRoot, "key.txt"), "from-ancestor");
+
+ var result = DebenuLicenseKeyResolver.Resolve(nested);
+
+ Assert.Equal("from-ancestor", result);
+ }
+
+ [Fact]
+ public void Resolve_ReturnsNull_WhenNeitherEnvVarNorKeyFileExist()
+ {
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", null);
+
+ var result = DebenuLicenseKeyResolver.Resolve(_tempRoot);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Resolve_TreatsABlankKeyFileAsAbsent()
+ {
+ Environment.SetEnvironmentVariable("DEBENU_LICENSE_KEY", null);
+ File.WriteAllText(Path.Combine(_tempRoot, "key.txt"), " \r\n ");
+
+ var result = DebenuLicenseKeyResolver.Resolve(_tempRoot);
+
+ Assert.Null(result);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs b/code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs
new file mode 100644
index 0000000..10be9b5
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs
@@ -0,0 +1,38 @@
+namespace EnvelopeRenderer.Cli;
+
+///
+/// Resolves the Debenu Quick PDF Library license key: the DEBENU_LICENSE_KEY environment
+/// variable first, then an untracked key.txt found by walking up from a starting directory
+/// (the CLI's own executable directory in production, so a key.txt dropped next to
+/// EnvelopeRenderer.Cli.exe — or in any ancestor directory, such as the repo root during
+/// local development — is picked up automatically without requiring an operator to configure an
+/// environment variable). Never committed, never hardcoded — see "Debenu license key" in
+/// CLI_CONTRACT.md.
+///
+public static class DebenuLicenseKeyResolver
+{
+ public static string? Resolve(string startDirectory)
+ {
+ var fromEnv = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY");
+ if (!string.IsNullOrWhiteSpace(fromEnv))
+ {
+ return fromEnv;
+ }
+
+ var directory = new DirectoryInfo(startDirectory);
+ for (var i = 0; i < 10 && directory is not null; i++, directory = directory.Parent)
+ {
+ var candidate = Path.Combine(directory.FullName, "key.txt");
+ if (File.Exists(candidate))
+ {
+ var content = File.ReadAllText(candidate).Trim();
+ if (!string.IsNullOrEmpty(content))
+ {
+ return content;
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli/Program.cs b/code/src/EnvelopeRenderer.Cli/Program.cs
index 262c5b4..1c36d24 100644
--- a/code/src/EnvelopeRenderer.Cli/Program.cs
+++ b/code/src/EnvelopeRenderer.Cli/Program.cs
@@ -1,3 +1,4 @@
+using EnvelopeRenderer.Cli;
using EnvelopeRenderer.Cli.Cli;
using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;
@@ -77,7 +78,7 @@ static int RunRender(CliArguments args)
var dllPath = Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");
- var licenseKey = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY");
+ var licenseKey = DebenuLicenseKeyResolver.Resolve(AppContext.BaseDirectory);
if (!DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError))
{
diff --git a/logs/technical_debt_log.md b/logs/technical_debt_log.md
index a8e9123..ed998b2 100644
--- a/logs/technical_debt_log.md
+++ b/logs/technical_debt_log.md
@@ -7,3 +7,4 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
| 2026-09-08 | `EnvelopeRenderer.Cli` exits `64` ("render not implemented") for any argument-valid run, since the Debenu render engine isn't wired in yet. Self-documenting and expected to disappear once "Render text-only PDFs through Debenu Quick PDF" (Sprint 1 Batch 2) lands and exit `0` becomes reachable. | Deliberate | Low | Paid Down | `RenderNotImplemented` branch removed from `Program.cs`; exit `0` is now reachable and exit `1` covers all template/CSV/render failures (see `code/CLI_CONTRACT.md`). |
| 2026-09-08 | `DebenuPdfRenderer` always embeds TrueType fonts fully (`AddTrueTypeFont(..., Embed: 1)`), cached once per unique font name per document. Fine at today's scale (one sample render: ~1.3 MB for 392 pages, one font) but full embedding could add up if a template ever uses many distinct fonts/styles at very high page counts, working against the sub-2GB PDF constraint. | Deliberate | Low | Open | Revisit if the "Time-box the first high-volume benchmark" story (not in this sprint) shows file size becoming an issue; Debenu also exposes `AddSubsettedFont` as a smaller-footprint alternative if needed. |
| 2026-09-08 | `EnvelopeRenderer.Desktop`'s Render button re-enables as soon as the CLI process is confirmed *started* (`CliProcessLauncher.LaunchAsync` returning), not once it finishes rendering — this story (Sprint 1 Batch 4) intentionally does not track render completion. An operator can click Render again (e.g. against the same output path) while a prior render is still in progress, since nothing yet observes the child process's lifetime or exit code. | Unavoidable (scope boundary of this story) | Low | Resolved | Sprint 1 Batch 5 ("Show render progress and completion summary") replaced `CliProcessLauncher.LaunchAsync`/`Launch` with `RunAsync`/`Run`, which stream the CLI's redirected stdout/stderr, wait for the process to actually exit, and return exit code + final progress event. `MainForm.OnRenderClick` now only re-enables the Render button in the `finally` block after that awaited call completes, not when the process starts — verified with a real launch against the sample CSV (button stayed disabled for the full render, both success and forced-failure runs). |
+| 2026-09-04 | `EnvelopeRenderer.Cli` only read `DEBENU_LICENSE_KEY` from the process environment. That's fine for `dotnet run --project ... --` (the CLI inherits the invoking shell's env directly), but `EnvelopeRenderer.Desktop` launches the CLI as a child process, which only inherits whatever environment variables were already present in whatever launched the desktop app itself (a double-clicked `.exe` or Start Menu shortcut typically has none) — so every desktop-launched render failed with Debenu error 999 regardless of a valid key existing on disk. Real-user-reported: the operator correctly guessed a `key.txt` dropped next to the exe should work (matching how the CLI's own test helper already resolved keys), but production code had no such fallback. This is a real Definition-of-Done verification gap from Sprint 1 Batches 4-5: the "real success run" verification used an in-process test harness with the env var set directly in that process, never the actual built `.exe` launched the way an operator would, so the gap wasn't caught before Sprint Review. | Unintentional | High (silently broke the desktop app's core success path for any non-`dotnet run` launch) | Resolved | Added `DebenuLicenseKeyResolver` (`code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs`) to the shipped CLI: env var first, then a `key.txt` walked up from the executable's own directory — the same rule the test-only helper already used, now shared via delegation instead of duplicated. Documented in `CLI_CONTRACT.md`'s "Debenu license key" section and `code/README.md`'s desktop-app instructions. Verified by running the actual built `EnvelopeRenderer.Cli.exe` from the Desktop app's own output folder with no environment variable set at all, `key.txt` sitting next to it: exit `0`, valid 1.3 MB `%PDF-1.4` output, all 392 records. 5 new unit tests added (`DebenuLicenseKeyResolverTests.cs`); full suite 111/111 passing. |
diff --git a/state.md b/state.md
index cb501d2..8bba6cd 100644
--- a/state.md
+++ b/state.md
@@ -11,7 +11,7 @@
**Sprint goal:** Prove a thin text-only desktop-to-CLI rendering spine so an operator can select files, launch a job, watch progress, and receive a PDF generated through Debenu from a known XML template.
**Current sprint backlog:** `backlog/sprints/sprint-1.md`
-**Next action:** All 5 committed Sprint 1 items are Done (exit criteria for Phase 3 met — see `backlog/sprints/sprint-1.md`). The walking skeleton is real end-to-end: an operator picks template/CSV/output paths in the WinForms desktop app (`EnvelopeRenderer.Desktop`), launches a render without freezing the UI, watches live progress parsed from the CLI's `PROGRESS ...` stdout stream, and gets a completion summary — verified with a real success run (392/392 pages, valid PDF) and a real forced-failure run against the actual sample CSV. 106 tests passing. `product-owner` runs Sprint Review next per `process/04_sprint_review.md`. Carry-forward items to raise at review: template-path and UNC-timeout impediments still open (`logs/impediment_log.md`); the 100k-record/300 DPI benchmark hasn't been re-run since Batch 2; no "warning" concept exists in the product yet, so the "warning count" acceptance task for the last story was satisfied by its documented absence rather than a real counter — product owner may want to scope that once image/overflow handling exists.
+**Next action:** All 5 committed Sprint 1 items are Done (exit criteria for Phase 3 met — see `backlog/sprints/sprint-1.md`). The walking skeleton is real end-to-end: an operator picks template/CSV/output paths in the WinForms desktop app (`EnvelopeRenderer.Desktop`), launches a render without freezing the UI, watches live progress parsed from the CLI's `PROGRESS ...` stdout stream, and gets a completion summary. Post-Sprint-Review-handoff, the user hit a real bug using the desktop app directly (Debenu error 999): the CLI only read `DEBENU_LICENSE_KEY` from the process environment, which a double-clicked desktop app never has — fixed same-day with `DebenuLicenseKeyResolver` (env var, then a `key.txt` walked up from the executable's own directory), logged in `logs/technical_debt_log.md`, verified against the actual built `.exe` (exit 0, valid 392-page PDF). 111 tests passing. `product-owner` runs Sprint Review next per `process/04_sprint_review.md`. Carry-forward items to raise at review: template-path and UNC-timeout impediments still open (`logs/impediment_log.md`); the 100k-record/300 DPI benchmark hasn't been re-run since Batch 2; no "warning" concept exists in the product yet, so the "warning count" acceptance task for the last story was satisfied by its documented absence rather than a real counter; and the license-key gap above is a signal that Sprint 2 should verify GUI stories against the actual built artifact, not just an in-process harness.
## Phase reference