From 2051a84a90f19df82e3f714f381349703baa949e Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 20:58:26 +1000 Subject: [PATCH 01/12] WhyBuildTimeVerification --- .gitignore | 1 + docs/WhyBuildTimeVerification.md | 233 +++++++++++++++++++++++++++++++ readme.md | 2 + 3 files changed, 236 insertions(+) create mode 100644 docs/WhyBuildTimeVerification.md diff --git a/.gitignore b/.gitignore index c55e2f6..bc042d0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ obj/ nugets/ *~* nul +/.claude diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md new file mode 100644 index 0000000..b4da25e --- /dev/null +++ b/docs/WhyBuildTimeVerification.md @@ -0,0 +1,233 @@ +# Why build-time verification, not a readme ask alone + +OSS sustainability mechanisms sit on a spectrum. At one end is **no enforcement** — ship the package and state, via the readme, social media, and release notes, that sponsorship is wanted. At the other is **full commercial licensing** — per-consumer license keys, a billing system, rotation, and support. The readme's [Why this approach](../readme.md#why-this-approach) section compares the three points at a high level. This document zooms in on the lower end: it justifies, scenario by scenario, why relocating the sponsorship ask into the build loop converts consumers that a readme/social/release-notes ask does not — and is honest about exactly where it stops working. + +The charts below are **justification / contrast views**: they map what each world produces and what trace it leaves behind. They are a different altitude from the mechanism flowchart in [How it works](../readme.md#how-it-works), which shows the verifier's internal decision logic. That chart is not repeated here. + +> **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This +> matters because the central lever is *not* that the honest path is cheaper — it is not. The documented +> bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to +> sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what +> they leave in the build log, in CI, and in code review. Read the inversion as a *visibility* inversion, not +> a cost one. + + +## 1. The premise: discovery is a single probabilistic event with no retry + +The fatal property of the no-enforcement world is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and nothing anywhere records that they did. + +SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs `BeforeTargets="BeforeBuild"` with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay. + +```mermaid +flowchart TD + Dep(["Consumer takes a dependency"]) + + subgraph WorldA["World A - no enforcement (ask via readme / social / release notes)"] + A1{"Discovers the
sponsorship ask?"} + A1 -->|"Yes - via readme, release notes or social (each low-probability, no retry)"| A2{"Willing AND
acts right now?"} + A1 -->|"No - the common case; transitive-only deps almost never"| ASilent(["Silent free use forever
zero awareness, no artifact"]) + A2 -->|"No - defers, intent decays, never re-prompted"| AForget(["Willing-but-forgetful drop-off
the core leak: would have paid"]) + A2 -->|"Yes"| ASponsor(["Sponsors - the only win
one-time, no retry if missed"]) + end + + subgraph WorldB["World B - SponsorCheck (verifier runs BeforeBuild, every build, every config incl. Debug)"] + B1(["First build"]) --> B2{"License mode
declared?"} + B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] + BSC001 -->|"forces a choice"| B2 + B2 -->|"Yes"| BChoose(["Consumer must pick a mode
see Chart 2"]) + end + + Dep --> A1 + Dep --> B1 +``` + +The willing-but-forgetful drop-off on the left is the most damning leak: a consumer who *intended* to pay, saw the ask once, deferred it, and was never prompted again. On the right that leak is closed — not because the consumer is forced to pay, but because the reminder lives in the build loop instead of the readme, so the intention cannot quietly expire. + +| Discovery channel (World A) | Consumer effort | Probability the ask lands | Durable artifact | +| --- | --- | --- | --- | +| Readme | Zero (incidental to reading docs they wanted) | Low — read once at evaluation, funding section below usage, often skipped via IDE/CLI install | None | +| Release notes | Zero | Low — only on upgrade, and bot-driven bumps (Dependabot/Renovate) read no prose | None | +| Social media | Zero | Lowest — small overlap between followers and users; decays in hours | None | +| Transitive-only dependency | Zero | Near zero — the consumer never visits the package at all | None | + + +## 2. The structural fix, and three facts it rests on + +The argument is "a recurring touchpoint beats a one-time impression." It rests on three properties of the +bundled verifier: + +1. **It runs on every build, in every configuration.** There is no Release-only gate on the verifier (only the author-side bundler is Release-gated). The prompt is unavoidable and recurring rather than a single impression that scrolls away. +2. **`SC001` (no mode declared) is a default error that fails the build.** A consumer cannot proceed without consciously picking a mode — sponsor, license, or the explicit ignore. This is what converts the inattentive majority. +3. **The produced package carries no runtime dependency on SponsorCheck.** The verifier and its task DLL ship embedded in the nupkg; nothing reaches the consumer's runtime. The blast radius of being wrongly nagged is one build-time message, never a shipped dependency — which is why it is safe to err toward surfacing. + +### Severity is one knob that slides the same tree from hard gate to soft nudge + +The author tunes severity per overrideable code (`error` / `warning` / `message`) at pack time. The *same* decision tree runs in every case; only the forcefulness changes. Crucially, even the softest setting still beats no enforcement, because it still recurs in the build loop on every build. + +| `NoLicenseSpecifiedSeverityOverride` | Consumer experience | Still beats no-enforcement? | +| --- | --- | --- | +| `error` (default) | Build fails until a mode is declared — hard gate | Yes | +| `warning` | Build passes; a warning recurs on every build (and is escalated by warnings-as-errors CI) | Yes | +| `message` | Build passes; a high-priority message recurs on every build | Yes — still a recurring touchpoint, unlike a readme | + + +## 3. Once forced to choose: every escape hatch leaves a trace + +When `SC001` forces a decision, the consumer has several ways to resolve it. Exactly **one** terminal is a silent, zero-trace pass — a real sponsor match. Every other resolution either passes the build while leaving a durable artifact, or fails safe. This is the transition from *discovery* to *legibility*. + +```mermaid +flowchart TD + Root([SC001 forces a choice:
consumer picks a mode]) --> Sponsor + Root --> Start + Root --> License + Root --> Ignore + Root --> Bad + + Sponsor["Declare a matching sponsor account
cost: one attribute + platform signup"] --> SMatch{"Hash in the
bundled list?"} + SMatch -->|"Yes"| SPass(["PASS, silent
the only zero-trace pass"]) + SMatch -->|"No"| SC007["SC007 ERROR - account not in list
lapsed / never / typo / wrong platform
see Chart 3"] + + Start["Joined after pack date:
add SponsorshipStart
cost: one attribute, honor-system"] --> SFuture{"Start in
the future?"} + SFuture -->|"Yes"| SC015["SC015 ERROR - fails safe"] + SFuture -->|"No"| SAfter{"Start > PackDate?"} + SAfter -->|"Yes"| SC017(["PASS + SC017 audit message
consumer-local log only; author never sees it"]) + SAfter -->|"No (on or before, strict)"| SMatch + + License["Time-bounded private license
SponsorshipLicensedUntil=yyyy-MM
cost: one string, no keys or servers"] --> LExp{"Expired?"} + LExp -->|"No"| LPass(["PASS, silent
through end of month UTC"]) + LExp -->|"Yes"| SC009(["SC009 ERROR
renewal forcing function"]) + + Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005(["PASS, but SC005 WARNING every build
durable breach-of-license marker
see Chart 4"]) + + Bad["Malformed or conflicting config
SC003 two modes, SC011 / SC013 bad date
cost: fix the config"] --> BadEnd(["ERROR - fails safe
never a silent pass"]) +``` + +The honor-system `SponsorshipStart` path passes without any sponsorship lookup — it only checks that the attested date is not in the future and is strictly later than the package's pack date (so the bundled list *could not* contain the account). It logs an `SC017` audit message, but only in the consumer's own build log; the author never sees it. It is necessary for honest recent joiners, and it is also a stealthier free-ride path — both are true, and the doc does not pretend otherwise. + +The terminal codes shown are the non-CPM (``) variants. A CPM consumer emits the `+1` sibling of each; an owner-mode consumer emits the `SC02x` equivalent. See [Verifier diagnostic codes](VerifierDiagnosticCodes.md). + + +## 4. Honesty, not DRM + +Two facts must sit together so the document does not over-claim. + +### 4a. Enforcement applies to new releases, not continued use + +The bundled hash list is frozen per package version at pack time. The verifier has no notion of "currently sponsoring" — only "was this account's hash in *this version's* list." That produces a deliberate asymmetry: a version a consumer was bundled into stays buildable **forever**, even after sponsorship lapses. The lapse only bites on **upgrade**, when a newer version's frozen list no longer contains them. And a consumer can always revert downward to escape entirely. + +```mermaid +flowchart TD + Lapse(["Bundled into vN while sponsoring,
then sponsorship lapses"]) --> Which{"Which version
does the build use?"} + + Which -->|"Stay on vN (effort: zero)"| Stay(["vN builds PASS forever
paid versions stay paid"]) + + Which -->|"Upgrade to vN+1 packed after the lapse"| Up{"Hash in vN+1
frozen list?"} + Up -->|"Yes"| UpPass(["PASS - re-bundled"]) + Up -->|"No"| SC007b["SC007 ERROR
lapse surfaces at the upgrade touchpoint"] + SC007b -->|"re-sponsor (lands in next pack)"| UpPass + SC007b -->|"switch to LicensedUntil / Ignored"| Modes(["Chart 2 terminals"]) + SC007b -->|"revert downward"| Revert + + Which -->|"Revert to vN or a pre-adoption version"| Revert(["PASS, clean
pre-adoption versions have no verifier at all
cost: forfeits all future updates and security fixes"]) +``` + +Reverting is cheap in keystrokes — one `Version` string edited downward — but the real deterrent is not a build failure; it is the **compounding opportunity cost** of freezing out of all future updates and security fixes. Enforcement effectively scales with the value a consumer is actively extracting (new releases), not with mere continued use of what they already have. The author has no recall mechanism short of yanking the package. + +### 4b. The conceded bypasses + +SponsorCheck explicitly concedes that a determined free-rider can opt out trivially. Hashing is light obfuscation (48-bit truncated SHA-256), **not** a security boundary, and `SponsorshipLicenseIgnored="true"` is the documented, sanctioned bypass. Foregrounding this is what makes the rest credible: the design target is the inattentive majority, never the adversary. + +- **`SponsorshipLicenseIgnored="true"`** — one free line. Strictly cheaper than sponsoring. Passes the build, but stamps an `SC005` "in breach of license" **warning on every build**, and the attribute itself is committed to the repo. +- **Revert the version** — see 4a. Clean build, no recurring `SC0xx`, at the cost of stale dependencies (which software-composition-analysis and Dependabot tooling will themselves flag). +- **Strip the verifier targets or forge a hash** — *more* work than sponsoring, and pointless, because the ignore bypass is free and sanctioned. No rational actor takes it, and an anti-SponsorCheck override target in `Directory.Build.targets` is exactly what a reviewer notices. + + +## 5. Who this actually converts + +The good actor converts in both worlds, so they are not the differentiator. SponsorCheck recovers the **inattentive majority** (who never found out) and the **org-compliance actor** (who had nothing to operationalize against). The **determined free-rider** is openly conceded — but their own bypass becomes the compliance actor's conversion trigger. + +```mermaid +flowchart LR + subgraph Good["Good actor - converts in both worlds (not the differentiator)"] + G0(["Wants to do the right thing"]) + G0 -->|"World A: reads readme, sponsors"| GW(["WIN"]) + G0 -->|"World B: prompted by SC001, sponsors"| GW + end + + subgraph Inatt["Inattentive majority - PRIMARY TARGET"] + I0(["Would sponsor if they clearly knew"]) + I0 -->|"World A: never learns the author wants it"| ILoss(["LOSS - silent free use"]) + I0 -->|"World B: red SC001 build cannot be ignored"| IWin(["WIN - converts"]) + end + + subgraph Org["Org-compliance actor - SECONDARY TARGET"] + O0(["Funds what its process can operationalize"]) + O0 -->|"World A: no machine-readable obligation to gate on"| OLoss(["LOSS - intent stalls"]) + O0 -->|"World B: SC001 / SC005 is a CI-greppable, PR-reviewable artifact"| OWin(["WIN - gate escalates it"]) + end + + subgraph Free["Determined free-rider - OPENLY CONCEDED"] + F0(["Will not pay regardless"]) + F0 -->|"World A: does not pay, invisibly"| FLoss(["LOSS - no marker"]) + F0 -->|"World B: SponsorshipLicenseIgnored, one free line"| FIgn(["LOSS on revenue,
but leaves a recurring SC005 breach marker"]) + F0 -->|"strip targets / forge a 48-bit hash"| FStrip(["more work than sponsoring,
and pointless - Ignored is free"]) + end + + FIgn -.->|"the free-rider's own SC005 marker feeds the compliance gate"| OWin +``` + +The strongest non-obvious argument is the dotted edge: in an organization, the free-rider's `SC005` "in breach of license" warning is not the end of the story. Because diagnostics are emitted through standard MSBuild `LogWarning`/`LogError`, a warnings-as-errors CI gate, a license-compliance scan, or an ordinary PR review can escalate that marker into enforcement **at the consumer org's own discretion**. The individual's opt-out exposes the obligation that the organization then acts on. SponsorCheck does not coerce — it makes the decision *legible* and lets the consumer's own controls do the rest. + + +## 6. Author cost, and an honest limitation + +Why an author picks this middle point on the spectrum at all comes down to cost. Adoption is near-zero: one development `PackageReference` plus a CI token, with the platform (GitHub Sponsors / Open Collective / Polar) still doing all the signup, billing, and rotation. Onboarding a sponsor is "they click Sponsor"; offboarding is "they stop sponsoring" — the next pack picks up the change automatically. No license keys are ever issued, in contrast to the large, ongoing cost of a full commercial licensing system. + +One limitation, stated plainly so the document does not over-claim: **the author gets no per-consumer telemetry.** `SC005` and `SC017` are consumer-local — they live in the consumer's build log and never phone home. SponsorCheck cannot tell the author who ignored, who attested, or who reverted. It closes the *discovery* and *legibility* gaps; it does not give the author a dashboard of who is and is not paying. That is a shared limitation with the no-enforcement world, by design (no runtime callback, no tracking). + + +## End-state catalogue + +Every distinct terminal state across both worlds, with the consumer's effort and what it leaves behind. + +### World A — no enforcement + +| End state | Consumer effort | Build-log artifact | Maintainer outcome | +| --- | --- | --- | --- | +| Silent free use forever (never discovered the ask; includes transitive-only deps) | Zero; no decision ever made | None | Loss — the exact inattentive majority SponsorCheck targets | +| Willing-but-forgetful drop-off (deferred, intent decayed, never re-prompted) | Intended to pay; never got a second touchpoint | None | Loss — a consumer who would have paid | +| Knowing free-ride (saw the ask, declined) | Zero ongoing; the decline is invisible | None — indistinguishable from an honest sponsor | Loss (conceded in both worlds) | +| Former sponsor lapses unnoticed (card expires, reorg) | Zero — lapse needs no action | None | Loss — no renewal touchpoint | +| Org wants to fund but has nothing to act on | Prohibitive manual per-dependency audit | None to gate or budget on | Loss — corporate intent stalls | +| Conscientious consumer sponsors proactively | ~3 clicks + payment, self-motivated, one-time | None (and none needed) | Win — conditioned on discover AND willing AND act-now firing at once | + +### World B — SponsorCheck + +| End state | Consumer effort | Build-log artifact | Maintainer outcome | +| --- | --- | --- | --- | +| PASS, silent — real sponsor hash match | One attribute (copy-pasteable from SC001) + signup | None after the one-time SC001; a clean log is the reward | Win | +| PASS + SC017 audit message — `SponsorshipStart` > pack date | One extra attribute; no proof; self-expires on upgrade | SC017 high-priority message, consumer-local only | Neutral — needed for honest recent joiners; also a stealthier free-ride | +| PASS, silent — valid time-bounded private license | One `yyyy-MM` string; no keys/servers/rotation | Clean while valid; the value is visible in the repo | Win — B2B path without license infrastructure | +| PASS, but SC005 WARNING every build — explicit ignore | One free line — cheaper than sponsoring | Recurring SC005 breach marker in log/CI/PR + committed attribute | Neutral/loss on revenue, win on legibility — feeds the compliance gate | +| SC001 ERROR — no mode declared (default) | Zero to reach; one attribute to resolve | SC001 error with full remediation block + sponsor URLs | Win — the recurring forced prompt | +| SC007 ERROR — declared account not in the frozen list | Re-sponsor, switch mode, attest, or revert | SC007 error naming the tried accounts | Win — surfaces the lapse at the upgrade touchpoint | +| SC009 ERROR — time-bounded license expired | Update one `yyyy-MM` or sponsor | SC009 error naming the end-of-month UTC date | Win — calendar-driven renewal forcing function | +| SC015 ERROR — future `SponsorshipStart` (fails safe) | Use a real, non-future date | SC015 error — abuse attempt recorded, not honored | Win — fail-closed guard on the honor-system path | +| Malformed / conflicting config (SC003 / SC011 / SC013) | Fix the config | Recorded error — never degrades to a silent pass | Neutral — reinforces fail-closed auditability | +| SC018 ERROR — bundled hash file missing (corrupt install) | Restore/repair the package | SC018 error naming the missing path | Neutral — absence of audit substrate is itself surfaced | +| vN builds PASS forever after lapse — paid versions stay paid | Zero — stay on the bundled version | Clean build (hash still in vN's frozen list) | Neutral — deliberate; no recall short of yanking | +| Revert to an already-bundled or pre-adoption version | Edit one `Version` down; forfeits all future updates and security fixes | No recurring SC0xx, but the pinned/stale version is visible to SCA tooling | Loss — the conceded escape; deterrent is compounding security debt | +| Strip the verifier / forge a 48-bit hash | More work than sponsoring, and pointless | A suspicious anti-SponsorCheck override a reviewer can spot | Loss — strictly dominated; hashing is not a security boundary | +| Stop using the package entirely | Rewrite/replace the dependency (highest effort) | None | Loss — nudged away rather than converted | + +### Shared limitation (both worlds) + +| End state | Artifact | Outcome | +| --- | --- | --- | +| Author never learns whether they are under-funded | None — SC005/SC017 are consumer-local; the author never sees them | Shared limitation, not a SponsorCheck win | + +> One upstream precondition gates the entire World-B column: the author's pack must succeed. A missing +> platform credential (`SC102`), no configured platform account (`SC101`), or a platform fetch failure +> (`SC100`) is surfaced in the author's *own* pack log (see +> [Bundler diagnostic codes](BundlerDiagnosticCodes.md)) and determines whether a correct verifier ships at +> all. diff --git a/readme.md b/readme.md index 1583384..d3ca4cb 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,8 @@ The maintainer keeps using GitHub Sponsors / Open Collective / Polar and ships a The trade for staying frictionless is honesty: hashing is not a security boundary, `SponsorshipLicenseIgnored="true"` is the documented bypass, and anyone determined to free-ride can do so trivially. The intent is to convert the inattentive majority — teams that would happily sponsor if they knew the maintainer wanted them to — not to extract revenue from adversaries. +For a scenario-by-scenario walk-through of why a recurring build-loop nudge converts consumers that a readme/social/release-notes ask does not — mapped as flowcharts across discovery, actor type, effort, version-reverting, and every end state — see [Why build-time verification](docs/WhyBuildTimeVerification.md). + ## Consumer Usage From 186c2e25a2bc6170189e10c3cce148d3d83ec60e Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:02:23 +1000 Subject: [PATCH 02/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index b4da25e..6acd699 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -20,7 +20,7 @@ SponsorCheck changes one thing: it moves the ask from a place read once (the rea ```mermaid flowchart TD - Dep(["Consumer takes a dependency"]) + Dep(["Consumer takes or updates a dependency"]) subgraph WorldA["World A - no enforcement (ask via readme / social / release notes)"] A1{"Discovers the
sponsorship ask?"} @@ -31,7 +31,7 @@ flowchart TD end subgraph WorldB["World B - SponsorCheck (verifier runs BeforeBuild, every build, every config incl. Debug)"] - B1(["First build"]) --> B2{"License mode
declared?"} + B1(["Each build"]) --> B2{"License mode
declared?"} B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] BSC001 -->|"forces a choice"| B2 B2 -->|"Yes"| BChoose(["Consumer must pick a mode
see Chart 2"]) From 69d97c4e82c4a7b386e368d4b6fe997aee6533a1 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:03:27 +1000 Subject: [PATCH 03/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 6acd699..53d9ce1 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -30,7 +30,7 @@ flowchart TD A2 -->|"Yes"| ASponsor(["Sponsors - the only win
one-time, no retry if missed"]) end - subgraph WorldB["World B - SponsorCheck (verifier runs BeforeBuild, every build, every config incl. Debug)"] + subgraph WorldB["World B - SponsorCheck (runs on build)"] B1(["Each build"]) --> B2{"License mode
declared?"} B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] BSC001 -->|"forces a choice"| B2 From 5c2ef4ec9565e3ad169e26ab578187786c15cf9a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:13:29 +1000 Subject: [PATCH 04/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 53d9ce1..2852429 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -22,15 +22,15 @@ SponsorCheck changes one thing: it moves the ask from a place read once (the rea flowchart TD Dep(["Consumer takes or updates a dependency"]) - subgraph WorldA["World A - no enforcement (ask via readme / social / release notes)"] + subgraph WorldA["Ask only (readme / social / release notes)"] A1{"Discovers the
sponsorship ask?"} - A1 -->|"Yes - via readme, release notes or social (each low-probability, no retry)"| A2{"Willing AND
acts right now?"} + A1 -->|"Yes - via the above (each low-probability, no retry)"| A2{"Willing AND
acts right now?"} A1 -->|"No - the common case; transitive-only deps almost never"| ASilent(["Silent free use forever
zero awareness, no artifact"]) A2 -->|"No - defers, intent decays, never re-prompted"| AForget(["Willing-but-forgetful drop-off
the core leak: would have paid"]) A2 -->|"Yes"| ASponsor(["Sponsors - the only win
one-time, no retry if missed"]) end - subgraph WorldB["World B - SponsorCheck (runs on build)"] + subgraph WorldB["Build-time check"] B1(["Each build"]) --> B2{"License mode
declared?"} B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] BSC001 -->|"forces a choice"| B2 @@ -43,7 +43,7 @@ flowchart TD The willing-but-forgetful drop-off on the left is the most damning leak: a consumer who *intended* to pay, saw the ask once, deferred it, and was never prompted again. On the right that leak is closed — not because the consumer is forced to pay, but because the reminder lives in the build loop instead of the readme, so the intention cannot quietly expire. -| Discovery channel (World A) | Consumer effort | Probability the ask lands | Durable artifact | +| Discovery channel (Ask only) | Consumer effort | Probability the ask lands | Durable artifact | | --- | --- | --- | --- | | Readme | Zero (incidental to reading docs they wanted) | Low — read once at evaluation, funding section below usage, often skipped via IDE/CLI install | None | | Release notes | Zero | Low — only on upgrade, and bot-driven bumps (Dependabot/Renovate) read no prose | None | @@ -150,26 +150,26 @@ The good actor converts in both worlds, so they are not the differentiator. Spon flowchart LR subgraph Good["Good actor - converts in both worlds (not the differentiator)"] G0(["Wants to do the right thing"]) - G0 -->|"World A: reads readme, sponsors"| GW(["WIN"]) - G0 -->|"World B: prompted by SC001, sponsors"| GW + G0 -->|"Ask only: reads readme, sponsors"| GW(["WIN"]) + G0 -->|"Build-time check: prompted by SC001, sponsors"| GW end subgraph Inatt["Inattentive majority - PRIMARY TARGET"] I0(["Would sponsor if they clearly knew"]) - I0 -->|"World A: never learns the author wants it"| ILoss(["LOSS - silent free use"]) - I0 -->|"World B: red SC001 build cannot be ignored"| IWin(["WIN - converts"]) + I0 -->|"Ask only: never learns the author wants it"| ILoss(["LOSS - silent free use"]) + I0 -->|"Build-time check: red SC001 build cannot be ignored"| IWin(["WIN - converts"]) end subgraph Org["Org-compliance actor - SECONDARY TARGET"] O0(["Funds what its process can operationalize"]) - O0 -->|"World A: no machine-readable obligation to gate on"| OLoss(["LOSS - intent stalls"]) - O0 -->|"World B: SC001 / SC005 is a CI-greppable, PR-reviewable artifact"| OWin(["WIN - gate escalates it"]) + O0 -->|"Ask only: no machine-readable obligation to gate on"| OLoss(["LOSS - intent stalls"]) + O0 -->|"Build-time check: SC001 / SC005 is a CI-greppable, PR-reviewable artifact"| OWin(["WIN - gate escalates it"]) end subgraph Free["Determined free-rider - OPENLY CONCEDED"] F0(["Will not pay regardless"]) - F0 -->|"World A: does not pay, invisibly"| FLoss(["LOSS - no marker"]) - F0 -->|"World B: SponsorshipLicenseIgnored, one free line"| FIgn(["LOSS on revenue,
but leaves a recurring SC005 breach marker"]) + F0 -->|"Ask only: does not pay, invisibly"| FLoss(["LOSS - no marker"]) + F0 -->|"Build-time check: SponsorshipLicenseIgnored, one free line"| FIgn(["LOSS on revenue,
but leaves a recurring SC005 breach marker"]) F0 -->|"strip targets / forge a 48-bit hash"| FStrip(["more work than sponsoring,
and pointless - Ignored is free"]) end @@ -190,7 +190,7 @@ One limitation, stated plainly so the document does not over-claim: **the author Every distinct terminal state across both worlds, with the consumer's effort and what it leaves behind. -### World A — no enforcement +### Ask only | End state | Consumer effort | Build-log artifact | Maintainer outcome | | --- | --- | --- | --- | @@ -201,7 +201,7 @@ Every distinct terminal state across both worlds, with the consumer's effort and | Org wants to fund but has nothing to act on | Prohibitive manual per-dependency audit | None to gate or budget on | Loss — corporate intent stalls | | Conscientious consumer sponsors proactively | ~3 clicks + payment, self-motivated, one-time | None (and none needed) | Win — conditioned on discover AND willing AND act-now firing at once | -### World B — SponsorCheck +### Build-time check | End state | Consumer effort | Build-log artifact | Maintainer outcome | | --- | --- | --- | --- | @@ -226,7 +226,7 @@ Every distinct terminal state across both worlds, with the consumer's effort and | --- | --- | --- | | Author never learns whether they are under-funded | None — SC005/SC017 are consumer-local; the author never sees them | Shared limitation, not a SponsorCheck win | -> One upstream precondition gates the entire World-B column: the author's pack must succeed. A missing +> One upstream precondition gates the entire Build-time check column: the author's pack must succeed. A missing > platform credential (`SC102`), no configured platform account (`SC101`), or a platform fetch failure > (`SC100`) is surfaced in the author's *own* pack log (see > [Bundler diagnostic codes](BundlerDiagnosticCodes.md)) and determines whether a correct verifier ships at From a39acb421d18f40bccf2154ce2e73534fcaf8f4a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:18:14 +1000 Subject: [PATCH 05/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 2852429..b14b518 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -24,17 +24,17 @@ flowchart TD subgraph WorldA["Ask only (readme / social / release notes)"] A1{"Discovers the
sponsorship ask?"} - A1 -->|"Yes - via the above (each low-probability, no retry)"| A2{"Willing AND
acts right now?"} - A1 -->|"No - the common case; transitive-only deps almost never"| ASilent(["Silent free use forever
zero awareness, no artifact"]) - A2 -->|"No - defers, intent decays, never re-prompted"| AForget(["Willing-but-forgetful drop-off
the core leak: would have paid"]) + A1 -->|"Yes - via the above (each low-probability, no retry)"| A2{"Willing AND
acts now?"} + A1 -->|"No - common case; transitive deps almost never"| ASilent(["Silent free use forever
zero awareness, no artifact"]) + A2 -->|"No - defers, intent decays, never re-prompted"| AForget(["Willing-but-forget drop-off
the core leak: would have paid"]) A2 -->|"Yes"| ASponsor(["Sponsors - the only win
one-time, no retry if missed"]) end subgraph WorldB["Build-time check"] B1(["Each build"]) --> B2{"License mode
declared?"} B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] - BSC001 -->|"forces a choice"| B2 - B2 -->|"Yes"| BChoose(["Consumer must pick a mode
see Chart 2"]) + BSC001 -->|"Forces a choice"| B2 + B2 -->|"Yes"| BChoose(["Consumer picks a mode
see Chart 2"]) end Dep --> A1 From 71f64bfe005630b5cdaaba609c2e1bfbd562e38e Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:23:18 +1000 Subject: [PATCH 06/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index b14b518..55566c7 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -4,12 +4,7 @@ OSS sustainability mechanisms sit on a spectrum. At one end is **no enforcement* The charts below are **justification / contrast views**: they map what each world produces and what trace it leaves behind. They are a different altitude from the mechanism flowchart in [How it works](../readme.md#how-it-works), which shows the verifier's internal decision logic. That chart is not repeated here. -> **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This -> matters because the central lever is *not* that the honest path is cheaper — it is not. The documented -> bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to -> sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what -> they leave in the build log, in CI, and in code review. Read the inversion as a *visibility* inversion, not -> a cost one. +> **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This matters because the central lever is *not* that the honest path is cheaper — it is not. The documented bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what they leave in the build log, in CI, and in code review. Read the inversion as a *visibility* inversion, not a cost one. ## 1. The premise: discovery is a single probabilistic event with no retry @@ -226,8 +221,4 @@ Every distinct terminal state across both worlds, with the consumer's effort and | --- | --- | --- | | Author never learns whether they are under-funded | None — SC005/SC017 are consumer-local; the author never sees them | Shared limitation, not a SponsorCheck win | -> One upstream precondition gates the entire Build-time check column: the author's pack must succeed. A missing -> platform credential (`SC102`), no configured platform account (`SC101`), or a platform fetch failure -> (`SC100`) is surfaced in the author's *own* pack log (see -> [Bundler diagnostic codes](BundlerDiagnosticCodes.md)) and determines whether a correct verifier ships at -> all. +> One upstream precondition gates the entire Build-time check column: the author's pack must succeed. A missing platform credential (`SC102`), no configured platform account (`SC101`), or a platform fetch failure (`SC100`) is surfaced in the author's *own* pack log (see [Bundler diagnostic codes](BundlerDiagnosticCodes.md)) and determines whether a correct verifier ships at all. From c5aa8b493e25522a79ca410d85248c60cb3c03e0 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:34:44 +1000 Subject: [PATCH 07/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 55566c7..615c74e 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -4,12 +4,12 @@ OSS sustainability mechanisms sit on a spectrum. At one end is **no enforcement* The charts below are **justification / contrast views**: they map what each world produces and what trace it leaves behind. They are a different altitude from the mechanism flowchart in [How it works](../readme.md#how-it-works), which shows the verifier's internal decision logic. That chart is not repeated here. -> **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This matters because the central lever is *not* that the honest path is cheaper — it is not. The documented bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what they leave in the build log, in CI, and in code review. Read the inversion as a *visibility* inversion, not a cost one. +> **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This matters because the central lever is *not* that the honest path is cheaper — it is not. The documented bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what they leave in the build log, in CI, and in code review. What matters is not what each choice costs but what it records — and that record is the consumer's own, never the maintainer's. ## 1. The premise: discovery is a single probabilistic event with no retry -The fatal property of the no-enforcement world is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and nothing anywhere records that they did. +The fatal property of the no-enforcement world is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and their own build, CI, and review hold no record of it — nothing the consumer's own process could later act on. SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs `BeforeTargets="BeforeBuild"` with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay. @@ -27,9 +27,9 @@ flowchart TD subgraph WorldB["Build-time check"] B1(["Each build"]) --> B2{"License mode
declared?"} - B2 -->|"No"| BSC001["SC001 ERROR - build fails by default
recurs every build, never decays"] + B2 -->|"No"| BSC001[SC001 ERROR - build fails by default
recurs every build, never decays
] BSC001 -->|"Forces a choice"| B2 - B2 -->|"Yes"| BChoose(["Consumer picks a mode
see Chart 2"]) + B2 -->|"Yes"| BChoose([Consumer picks a mode
see Chart 2
]) end Dep --> A1 @@ -80,19 +80,19 @@ flowchart TD Sponsor["Declare a matching sponsor account
cost: one attribute + platform signup"] --> SMatch{"Hash in the
bundled list?"} SMatch -->|"Yes"| SPass(["PASS, silent
the only zero-trace pass"]) - SMatch -->|"No"| SC007["SC007 ERROR - account not in list
lapsed / never / typo / wrong platform
see Chart 3"] + SMatch -->|"No"| SC007[SC007 ERROR - account not in list
lapsed / never / typo / wrong platform
] Start["Joined after pack date:
add SponsorshipStart
cost: one attribute, honor-system"] --> SFuture{"Start in
the future?"} - SFuture -->|"Yes"| SC015["SC015 ERROR - fails safe"] + SFuture -->|"Yes"| SC015[SC015 ERROR - fails safe] SFuture -->|"No"| SAfter{"Start > PackDate?"} - SAfter -->|"Yes"| SC017(["PASS + SC017 audit message
consumer-local log only; author never sees it"]) + SAfter -->|"Yes"| SC017([PASS + SC017 audit message
consumer-local log only, author never sees it
]) SAfter -->|"No (on or before, strict)"| SMatch License["Time-bounded private license
SponsorshipLicensedUntil=yyyy-MM
cost: one string, no keys or servers"] --> LExp{"Expired?"} LExp -->|"No"| LPass(["PASS, silent
through end of month UTC"]) - LExp -->|"Yes"| SC009(["SC009 ERROR
renewal forcing function"]) + LExp -->|"Yes"| SC009([SC009 ERROR
renewal forcing function
]) - Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005(["PASS, but SC005 WARNING every build
durable breach-of-license marker
see Chart 4"]) + Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005([PASS, but SC005 WARNING every build
durable breach-of-license marker
]) Bad["Malformed or conflicting config
SC003 two modes, SC011 / SC013 bad date
cost: fix the config"] --> BadEnd(["ERROR - fails safe
never a silent pass"]) ``` @@ -118,9 +118,9 @@ flowchart TD Which -->|"Upgrade to vN+1 packed after the lapse"| Up{"Hash in vN+1
frozen list?"} Up -->|"Yes"| UpPass(["PASS - re-bundled"]) - Up -->|"No"| SC007b["SC007 ERROR
lapse surfaces at the upgrade touchpoint"] + Up -->|"No"| SC007b[SC007 ERROR
lapse surfaces at the upgrade touchpoint
] SC007b -->|"re-sponsor (lands in next pack)"| UpPass - SC007b -->|"switch to LicensedUntil / Ignored"| Modes(["Chart 2 terminals"]) + SC007b -->|"switch to LicensedUntil / Ignored"| Modes([Chart 2 terminals]) SC007b -->|"revert downward"| Revert Which -->|"Revert to vN or a pre-adoption version"| Revert(["PASS, clean
pre-adoption versions have no verifier at all
cost: forfeits all future updates and security fixes"]) From 72051afab76974100fb78a0bf8323ef6720b1c16 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:42:48 +1000 Subject: [PATCH 08/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 615c74e..8d1a50c 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -51,7 +51,7 @@ The willing-but-forgetful drop-off on the left is the most damning leak: a consu The argument is "a recurring touchpoint beats a one-time impression." It rests on three properties of the bundled verifier: -1. **It runs on every build, in every configuration.** There is no Release-only gate on the verifier (only the author-side bundler is Release-gated). The prompt is unavoidable and recurring rather than a single impression that scrolls away. +1. **It runs on every build.** The prompt is unavoidable and recurring rather than a single impression that scrolls away. 2. **`SC001` (no mode declared) is a default error that fails the build.** A consumer cannot proceed without consciously picking a mode — sponsor, license, or the explicit ignore. This is what converts the inattentive majority. 3. **The produced package carries no runtime dependency on SponsorCheck.** The verifier and its task DLL ship embedded in the nupkg; nothing reaches the consumer's runtime. The blast radius of being wrongly nagged is one build-time message, never a shipped dependency — which is why it is safe to err toward surfacing. @@ -62,7 +62,7 @@ The author tunes severity per overrideable code (`error` / `warning` / `message` | `NoLicenseSpecifiedSeverityOverride` | Consumer experience | Still beats no-enforcement? | | --- | --- | --- | | `error` (default) | Build fails until a mode is declared — hard gate | Yes | -| `warning` | Build passes; a warning recurs on every build (and is escalated by warnings-as-errors CI) | Yes | +| `warning` | Build passes; warns on every build (and is escalated by warnings-as-errors CI) | Yes | | `message` | Build passes; a high-priority message recurs on every build | Yes — still a recurring touchpoint, unlike a readme | @@ -112,21 +112,21 @@ The bundled hash list is frozen per package version at pack time. The verifier h ```mermaid flowchart TD - Lapse(["Bundled into vN while sponsoring,
then sponsorship lapses"]) --> Which{"Which version
does the build use?"} + Lapse(["Bundled into vN
while sponsoring,
then sponsorship lapses"]) --> Which{"Which version
is used?"} Which -->|"Stay on vN (effort: zero)"| Stay(["vN builds PASS forever
paid versions stay paid"]) Which -->|"Upgrade to vN+1 packed after the lapse"| Up{"Hash in vN+1
frozen list?"} Up -->|"Yes"| UpPass(["PASS - re-bundled"]) Up -->|"No"| SC007b[SC007 ERROR
lapse surfaces at the upgrade touchpoint
] - SC007b -->|"re-sponsor (lands in next pack)"| UpPass + SC007b -->|"re-sponsor
(lands in next pack)"| UpPass SC007b -->|"switch to LicensedUntil / Ignored"| Modes([Chart 2 terminals]) SC007b -->|"revert downward"| Revert Which -->|"Revert to vN or a pre-adoption version"| Revert(["PASS, clean
pre-adoption versions have no verifier at all
cost: forfeits all future updates and security fixes"]) ``` -Reverting is cheap in keystrokes — one `Version` string edited downward — but the real deterrent is not a build failure; it is the **compounding opportunity cost** of freezing out of all future updates and security fixes. Enforcement effectively scales with the value a consumer is actively extracting (new releases), not with mere continued use of what they already have. The author has no recall mechanism short of yanking the package. +Reverting is cheap in keystrokes — one `Version` string edited downward — but the real deterrent is not a build failure; it is the **compounding opportunity cost** of freezing out of all future updates and security fixes. Enforcement effectively scales with the value a consumer is actively extracting (new releases), not with mere continued use of what they already have. The author has no recall mechanism short of unlisting the package version. ### 4b. The conceded bypasses @@ -210,7 +210,7 @@ Every distinct terminal state across both worlds, with the consumer's effort and | SC015 ERROR — future `SponsorshipStart` (fails safe) | Use a real, non-future date | SC015 error — abuse attempt recorded, not honored | Win — fail-closed guard on the honor-system path | | Malformed / conflicting config (SC003 / SC011 / SC013) | Fix the config | Recorded error — never degrades to a silent pass | Neutral — reinforces fail-closed auditability | | SC018 ERROR — bundled hash file missing (corrupt install) | Restore/repair the package | SC018 error naming the missing path | Neutral — absence of audit substrate is itself surfaced | -| vN builds PASS forever after lapse — paid versions stay paid | Zero — stay on the bundled version | Clean build (hash still in vN's frozen list) | Neutral — deliberate; no recall short of yanking | +| vN builds PASS forever after lapse — paid versions stay paid | Zero — stay on the bundled version | Clean build (hash still in vN's frozen list) | Neutral — deliberate; no recall short of unlisting | | Revert to an already-bundled or pre-adoption version | Edit one `Version` down; forfeits all future updates and security fixes | No recurring SC0xx, but the pinned/stale version is visible to SCA tooling | Loss — the conceded escape; deterrent is compounding security debt | | Strip the verifier / forge a 48-bit hash | More work than sponsoring, and pointless | A suspicious anti-SponsorCheck override a reviewer can spot | Loss — strictly dominated; hashing is not a security boundary | | Stop using the package entirely | Rewrite/replace the dependency (highest effort) | None | Loss — nudged away rather than converted | From 499ee4203061f708e07d161a9d1f39b514157f41 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 21:47:49 +1000 Subject: [PATCH 09/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 8d1a50c..7f364d9 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -11,7 +11,9 @@ The charts below are **justification / contrast views**: they map what each worl The fatal property of the no-enforcement world is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and their own build, CI, and review hold no record of it — nothing the consumer's own process could later act on. -SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs `BeforeTargets="BeforeBuild"` with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay. +Automation makes this worse, not better. Adding or updating a dependency increasingly happens with no human in the loop — Dependabot and Renovate bumps, and AI coding agents that run `dotnet add package` and upgrade versions on a developer's behalf. None of them read a readme funding section, a changelog, or a social post, so the share of installs and upgrades where anyone even encounters the ask keeps shrinking. + +SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs `BeforeTargets="BeforeBuild"` with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay — and the build is the one channel automation cannot skip: an AI agent or a CI bump still has to compile, so the verifier surfaces in exactly the output they read back. ```mermaid flowchart TD @@ -40,8 +42,8 @@ The willing-but-forgetful drop-off on the left is the most damning leak: a consu | Discovery channel (Ask only) | Consumer effort | Probability the ask lands | Durable artifact | | --- | --- | --- | --- | -| Readme | Zero (incidental to reading docs they wanted) | Low — read once at evaluation, funding section below usage, often skipped via IDE/CLI install | None | -| Release notes | Zero | Low — only on upgrade, and bot-driven bumps (Dependabot/Renovate) read no prose | None | +| Readme | Zero (incidental to reading docs they wanted) | Low — read once at evaluation, funding section below usage, skipped entirely by IDE/CLI or AI-agent installs | None | +| Release notes | Zero | Low — only on upgrade, and automated bumps (Dependabot/Renovate, AI agents) read no prose | None | | Social media | Zero | Lowest — small overlap between followers and users; decays in hours | None | | Transitive-only dependency | Zero | Near zero — the consumer never visits the package at all | None | From 486a51b7ac26d6287e6c9eb7100bc138953c78c5 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 22:08:10 +1000 Subject: [PATCH 10/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 7f364d9..47da5f1 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -2,18 +2,18 @@ OSS sustainability mechanisms sit on a spectrum. At one end is **no enforcement** — ship the package and state, via the readme, social media, and release notes, that sponsorship is wanted. At the other is **full commercial licensing** — per-consumer license keys, a billing system, rotation, and support. The readme's [Why this approach](../readme.md#why-this-approach) section compares the three points at a high level. This document zooms in on the lower end: it justifies, scenario by scenario, why relocating the sponsorship ask into the build loop converts consumers that a readme/social/release-notes ask does not — and is honest about exactly where it stops working. -The charts below are **justification / contrast views**: they map what each world produces and what trace it leaves behind. They are a different altitude from the mechanism flowchart in [How it works](../readme.md#how-it-works), which shows the verifier's internal decision logic. That chart is not repeated here. +The charts below are **justification / contrast views**: they map what each scenario produces and what trace it leaves behind. They are a different altitude from the mechanism flowchart in [How it works](../readme.md#how-it-works), which shows the verifier's internal decision logic. That chart is not repeated here. > **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This matters because the central lever is *not* that the honest path is cheaper — it is not. The documented bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what they leave in the build log, in CI, and in code review. What matters is not what each choice costs but what it records — and that record is the consumer's own, never the maintainer's. ## 1. The premise: discovery is a single probabilistic event with no retry -The fatal property of the no-enforcement world is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and their own build, CI, and review hold no record of it — nothing the consumer's own process could later act on. +The problem with the no-enforcement scenario is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and their own build, CI, and review hold no record of it — nothing the consumer's own process could later act on. Automation makes this worse, not better. Adding or updating a dependency increasingly happens with no human in the loop — Dependabot and Renovate bumps, and AI coding agents that run `dotnet add package` and upgrade versions on a developer's behalf. None of them read a readme funding section, a changelog, or a social post, so the share of installs and upgrades where anyone even encounters the ask keeps shrinking. -SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs `BeforeTargets="BeforeBuild"` with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay — and the build is the one channel automation cannot skip: an AI agent or a CI bump still has to compile, so the verifier surfaces in exactly the output they read back. +SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs on build with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay — and the build is the one channel automation cannot skip: an AI agent or a CI bump still has to compile, so the verifier surfaces in exactly the output they read back. ```mermaid flowchart TD @@ -29,7 +29,7 @@ flowchart TD subgraph WorldB["Build-time check"] B1(["Each build"]) --> B2{"License mode
declared?"} - B2 -->|"No"| BSC001[SC001 ERROR - build fails by default
recurs every build, never decays
] + B2 -->|"No"| BSC001[No license declared - build fails by default (SC001)
recurs every build, never decays
] BSC001 -->|"Forces a choice"| B2 B2 -->|"Yes"| BChoose([Consumer picks a mode
see Chart 2
]) end @@ -54,7 +54,7 @@ The argument is "a recurring touchpoint beats a one-time impression." It rests o bundled verifier: 1. **It runs on every build.** The prompt is unavoidable and recurring rather than a single impression that scrolls away. -2. **`SC001` (no mode declared) is a default error that fails the build.** A consumer cannot proceed without consciously picking a mode — sponsor, license, or the explicit ignore. This is what converts the inattentive majority. +2. **By default, the build fails until sponsorship is addressed (SC001).** A consumer cannot proceed without consciously picking a mode — sponsor, license, or the explicit ignore. This is what converts the inattentive majority. 3. **The produced package carries no runtime dependency on SponsorCheck.** The verifier and its task DLL ship embedded in the nupkg; nothing reaches the consumer's runtime. The blast radius of being wrongly nagged is one build-time message, never a shipped dependency — which is why it is safe to err toward surfacing. ### Severity is one knob that slides the same tree from hard gate to soft nudge @@ -70,11 +70,11 @@ The author tunes severity per overrideable code (`error` / `warning` / `message` ## 3. Once forced to choose: every escape hatch leaves a trace -When `SC001` forces a decision, the consumer has several ways to resolve it. Exactly **one** terminal is a silent, zero-trace pass — a real sponsor match. Every other resolution either passes the build while leaving a durable artifact, or fails safe. This is the transition from *discovery* to *legibility*. +When an undeclared license (`SC001`) forces a decision, the consumer has several ways to resolve it. Exactly **one** terminal is a silent, zero-trace pass — a real sponsor match. Every other resolution either passes the build while leaving a durable artifact, or fails safe. This is the transition from *discovery* to *legibility*. ```mermaid flowchart TD - Root([SC001 forces a choice:
consumer picks a mode]) --> Sponsor + Root([No license declared (SC001)
consumer picks a mode]) --> Sponsor Root --> Start Root --> License Root --> Ignore @@ -82,19 +82,19 @@ flowchart TD Sponsor["Declare a matching sponsor account
cost: one attribute + platform signup"] --> SMatch{"Hash in the
bundled list?"} SMatch -->|"Yes"| SPass(["PASS, silent
the only zero-trace pass"]) - SMatch -->|"No"| SC007[SC007 ERROR - account not in list
lapsed / never / typo / wrong platform
] + SMatch -->|"No"| SC007[Account not licensed - no match in bundled list (SC007)
lapsed / never / typo / wrong platform
] Start["Joined after pack date:
add SponsorshipStart
cost: one attribute, honor-system"] --> SFuture{"Start in
the future?"} - SFuture -->|"Yes"| SC015[SC015 ERROR - fails safe] + SFuture -->|"Yes"| SC015[Sponsorship start in the future - fails safe (SC015)] SFuture -->|"No"| SAfter{"Start > PackDate?"} - SAfter -->|"Yes"| SC017([PASS + SC017 audit message
consumer-local log only, author never sees it
]) + SAfter -->|"Yes"| SC017([Passes on trusted attestation (SC017)
consumer-local log only, author never sees it
]) SAfter -->|"No (on or before, strict)"| SMatch License["Time-bounded private license
SponsorshipLicensedUntil=yyyy-MM
cost: one string, no keys or servers"] --> LExp{"Expired?"} LExp -->|"No"| LPass(["PASS, silent
through end of month UTC"]) - LExp -->|"Yes"| SC009([SC009 ERROR
renewal forcing function
]) + LExp -->|"Yes"| SC009([Time-bounded license expired (SC009)
renewal forcing function
]) - Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005([PASS, but SC005 WARNING every build
durable breach-of-license marker
]) + Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005([Passes, but warns on every build (SC005)
durable breach-of-license marker
]) Bad["Malformed or conflicting config
SC003 two modes, SC011 / SC013 bad date
cost: fix the config"] --> BadEnd(["ERROR - fails safe
never a silent pass"]) ``` @@ -120,7 +120,7 @@ flowchart TD Which -->|"Upgrade to vN+1 packed after the lapse"| Up{"Hash in vN+1
frozen list?"} Up -->|"Yes"| UpPass(["PASS - re-bundled"]) - Up -->|"No"| SC007b[SC007 ERROR
lapse surfaces at the upgrade touchpoint
] + Up -->|"No"| SC007b[Account not licensed - no match in vN+1 list (SC007)
lapse surfaces at the upgrade touchpoint
] SC007b -->|"re-sponsor
(lands in next pack)"| UpPass SC007b -->|"switch to LicensedUntil / Ignored"| Modes([Chart 2 terminals]) SC007b -->|"revert downward"| Revert @@ -203,15 +203,15 @@ Every distinct terminal state across both worlds, with the consumer's effort and | End state | Consumer effort | Build-log artifact | Maintainer outcome | | --- | --- | --- | --- | | PASS, silent — real sponsor hash match | One attribute (copy-pasteable from SC001) + signup | None after the one-time SC001; a clean log is the reward | Win | -| PASS + SC017 audit message — `SponsorshipStart` > pack date | One extra attribute; no proof; self-expires on upgrade | SC017 high-priority message, consumer-local only | Neutral — needed for honest recent joiners; also a stealthier free-ride | +| Passes on trusted attestation (SC017) — `SponsorshipStart` > pack date | One extra attribute; no proof; self-expires on upgrade | SC017 high-priority message, consumer-local only | Neutral — needed for honest recent joiners; also a stealthier free-ride | | PASS, silent — valid time-bounded private license | One `yyyy-MM` string; no keys/servers/rotation | Clean while valid; the value is visible in the repo | Win — B2B path without license infrastructure | -| PASS, but SC005 WARNING every build — explicit ignore | One free line — cheaper than sponsoring | Recurring SC005 breach marker in log/CI/PR + committed attribute | Neutral/loss on revenue, win on legibility — feeds the compliance gate | -| SC001 ERROR — no mode declared (default) | Zero to reach; one attribute to resolve | SC001 error with full remediation block + sponsor URLs | Win — the recurring forced prompt | -| SC007 ERROR — declared account not in the frozen list | Re-sponsor, switch mode, attest, or revert | SC007 error naming the tried accounts | Win — surfaces the lapse at the upgrade touchpoint | -| SC009 ERROR — time-bounded license expired | Update one `yyyy-MM` or sponsor | SC009 error naming the end-of-month UTC date | Win — calendar-driven renewal forcing function | -| SC015 ERROR — future `SponsorshipStart` (fails safe) | Use a real, non-future date | SC015 error — abuse attempt recorded, not honored | Win — fail-closed guard on the honor-system path | +| Passes, but warns on every build (SC005) — explicit ignore | One free line — cheaper than sponsoring | Recurring SC005 breach marker in log/CI/PR + committed attribute | Neutral/loss on revenue, win on legibility — feeds the compliance gate | +| No license declared — build fails by default (SC001) | Zero to reach; one attribute to resolve | SC001 error with full remediation block + sponsor URLs | Win — the recurring forced prompt | +| Account not licensed — declared account not in the frozen list (SC007) | Re-sponsor, switch mode, attest, or revert | SC007 error naming the tried accounts | Win — surfaces the lapse at the upgrade touchpoint | +| Time-bounded license expired (SC009) | Update one `yyyy-MM` or sponsor | SC009 error naming the end-of-month UTC date | Win — calendar-driven renewal forcing function | +| Sponsorship start in the future — fails safe (SC015) | Use a real, non-future date | SC015 error — abuse attempt recorded, not honored | Win — fail-closed guard on the honor-system path | | Malformed / conflicting config (SC003 / SC011 / SC013) | Fix the config | Recorded error — never degrades to a silent pass | Neutral — reinforces fail-closed auditability | -| SC018 ERROR — bundled hash file missing (corrupt install) | Restore/repair the package | SC018 error naming the missing path | Neutral — absence of audit substrate is itself surfaced | +| Bundled hash file missing — corrupt install (SC018) | Restore/repair the package | SC018 error naming the missing path | Neutral — absence of audit substrate is itself surfaced | | vN builds PASS forever after lapse — paid versions stay paid | Zero — stay on the bundled version | Clean build (hash still in vN's frozen list) | Neutral — deliberate; no recall short of unlisting | | Revert to an already-bundled or pre-adoption version | Edit one `Version` down; forfeits all future updates and security fixes | No recurring SC0xx, but the pinned/stale version is visible to SCA tooling | Loss — the conceded escape; deterrent is compounding security debt | | Strip the verifier / forge a 48-bit hash | More work than sponsoring, and pointless | A suspicious anti-SponsorCheck override a reviewer can spot | Loss — strictly dominated; hashing is not a security boundary | From 373c54fc9f1d3003e5c1155030d5abf72d9aa45d Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 22:14:25 +1000 Subject: [PATCH 11/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index 47da5f1..ffcd979 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -29,7 +29,7 @@ flowchart TD subgraph WorldB["Build-time check"] B1(["Each build"]) --> B2{"License mode
declared?"} - B2 -->|"No"| BSC001[No license declared - build fails by default (SC001)
recurs every build, never decays
] + B2 -->|"No"| BSC001[No license declared - build fails by default #40;SC001#41;
recurs every build, never decays
] BSC001 -->|"Forces a choice"| B2 B2 -->|"Yes"| BChoose([Consumer picks a mode
see Chart 2
]) end @@ -38,7 +38,7 @@ flowchart TD Dep --> B1 ``` -The willing-but-forgetful drop-off on the left is the most damning leak: a consumer who *intended* to pay, saw the ask once, deferred it, and was never prompted again. On the right that leak is closed — not because the consumer is forced to pay, but because the reminder lives in the build loop instead of the readme, so the intention cannot quietly expire. +The willing-but-forgetful drop-off under **Ask only** is the most damning leak: a consumer who *intended* to pay, saw the ask once, deferred it, and was never prompted again. Under **Build-time check** that leak is closed — not because the consumer is forced to pay, but because the reminder lives in the build loop instead of the readme, so the intention cannot quietly expire. | Discovery channel (Ask only) | Consumer effort | Probability the ask lands | Durable artifact | | --- | --- | --- | --- | @@ -74,7 +74,7 @@ When an undeclared license (`SC001`) forces a decision, the consumer has several ```mermaid flowchart TD - Root([No license declared (SC001)
consumer picks a mode]) --> Sponsor + Root([No license declared #40;SC001#41;
consumer picks a mode]) --> Sponsor Root --> Start Root --> License Root --> Ignore @@ -82,19 +82,19 @@ flowchart TD Sponsor["Declare a matching sponsor account
cost: one attribute + platform signup"] --> SMatch{"Hash in the
bundled list?"} SMatch -->|"Yes"| SPass(["PASS, silent
the only zero-trace pass"]) - SMatch -->|"No"| SC007[Account not licensed - no match in bundled list (SC007)
lapsed / never / typo / wrong platform
] + SMatch -->|"No"| SC007[Account not licensed - no match in bundled list #40;SC007#41;
lapsed / never / typo / wrong platform
] Start["Joined after pack date:
add SponsorshipStart
cost: one attribute, honor-system"] --> SFuture{"Start in
the future?"} - SFuture -->|"Yes"| SC015[Sponsorship start in the future - fails safe (SC015)] + SFuture -->|"Yes"| SC015[Sponsorship start in the future - fails safe #40;SC015#41;] SFuture -->|"No"| SAfter{"Start > PackDate?"} - SAfter -->|"Yes"| SC017([Passes on trusted attestation (SC017)
consumer-local log only, author never sees it
]) + SAfter -->|"Yes"| SC017([Passes on trusted attestation #40;SC017#41;
consumer-local log only, author never sees it
]) SAfter -->|"No (on or before, strict)"| SMatch License["Time-bounded private license
SponsorshipLicensedUntil=yyyy-MM
cost: one string, no keys or servers"] --> LExp{"Expired?"} LExp -->|"No"| LPass(["PASS, silent
through end of month UTC"]) - LExp -->|"Yes"| SC009([Time-bounded license expired (SC009)
renewal forcing function
]) + LExp -->|"Yes"| SC009([Time-bounded license expired #40;SC009#41;
renewal forcing function
]) - Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005([Passes, but warns on every build (SC005)
durable breach-of-license marker
]) + Ignore["SponsorshipLicenseIgnored=true
cost: one free line, cheaper than sponsoring"] --> SC005([Passes, but warns on every build #40;SC005#41;
durable breach-of-license marker
]) Bad["Malformed or conflicting config
SC003 two modes, SC011 / SC013 bad date
cost: fix the config"] --> BadEnd(["ERROR - fails safe
never a silent pass"]) ``` @@ -120,7 +120,7 @@ flowchart TD Which -->|"Upgrade to vN+1 packed after the lapse"| Up{"Hash in vN+1
frozen list?"} Up -->|"Yes"| UpPass(["PASS - re-bundled"]) - Up -->|"No"| SC007b[Account not licensed - no match in vN+1 list (SC007)
lapse surfaces at the upgrade touchpoint
] + Up -->|"No"| SC007b[Account not licensed - no match in vN+1 list #40;SC007#41;
lapse surfaces at the upgrade touchpoint
] SC007b -->|"re-sponsor
(lands in next pack)"| UpPass SC007b -->|"switch to LicensedUntil / Ignored"| Modes([Chart 2 terminals]) SC007b -->|"revert downward"| Revert From b90fd0090eca08d79302cc5f151111e590511b72 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 9 Jun 2026 22:20:25 +1000 Subject: [PATCH 12/12] Update WhyBuildTimeVerification.md --- docs/WhyBuildTimeVerification.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/WhyBuildTimeVerification.md b/docs/WhyBuildTimeVerification.md index ffcd979..3def1d8 100644 --- a/docs/WhyBuildTimeVerification.md +++ b/docs/WhyBuildTimeVerification.md @@ -7,13 +7,13 @@ The charts below are **justification / contrast views**: they map what each scen > **Throughout, every effort annotation is paired with the durability of the artifact it leaves.** This matters because the central lever is *not* that the honest path is cheaper — it is not. The documented bypass (`SponsorshipLicenseIgnored="true"`) is a single free line, strictly cheaper than signing up to sponsor. The lever is **visibility**: the honest path and every bypass differ not in keystrokes but in what they leave in the build log, in CI, and in code review. What matters is not what each choice costs but what it records — and that record is the consumer's own, never the maintainer's. -## 1. The premise: discovery is a single probabilistic event with no retry +## 1. The premise: discovery is a probabilistic event with no retry The problem with the no-enforcement scenario is not that consumers are hostile — most are not. It is that the ask must be **discovered** through an out-of-band channel, discovery happens at most once, and it produces **zero durable artifact**. A consumer who would gladly sponsor can fall out of the funnel at every gate, and their own build, CI, and review hold no record of it — nothing the consumer's own process could later act on. Automation makes this worse, not better. Adding or updating a dependency increasingly happens with no human in the loop — Dependabot and Renovate bumps, and AI coding agents that run `dotnet add package` and upgrade versions on a developer's behalf. None of them read a readme funding section, a changelog, or a social post, so the share of installs and upgrades where anyone even encounters the ask keeps shrinking. -SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs on build with **no configuration gate**, so it fires on *every* build in *every* configuration — including Debug, not at pack time or in Release alone. The prompt recurs and cannot decay — and the build is the one channel automation cannot skip: an AI agent or a CI bump still has to compile, so the verifier surfaces in exactly the output they read back. +SponsorCheck changes one thing: it moves the ask from a place read once (the readme) to a place read on every build (the build log). The verifier runs on build with **no configuration gate**, so it fires on *every* build in *every* configuration. The prompt recurs and cannot decay — and the build is the one channel automation cannot skip: an AI agent or a CI bump still has to compile, so the verifier surfaces in exactly the output they read back. ```mermaid flowchart TD