---
status: open
summary: The plan that closes the entitlement funnel — three-slice review verdict (TD client, picker/installer, gate worker) and the phased work: stop the funnel-breaking bugs, make degraded states honest, add the missing conversion rungs. Companion to EntitlementPathReview.md, which it re-checked.
since: 2026-08-29
skill: fns-packaging
---
Goal: every user state, on every surface, gets (a) correct behavior, (b) the true reason when refused, (c) a next step that moves them toward entitled-and-installed.
This plan follows a full three-slice review of the paid path (TD client,
picker/installer/website, Cloudflare Worker gate) conducted 2026-08-29
against EntitlementPathReview.md. The review's verdict, condensed:
- Every claim in the review doc held up. Both test suites pass in
full (
python tests/test_picker_flavors.py,node worker/test/gate.test.mjs); the live wiring for defect #1 was confirmed in TD (FNS_Updater.extensionscontainsExtAuth,_auth()returns it, no promoted-name collisions). - The happy path is closed for the four canonical states: anonymous,
signed out, signed-in-unentitled, entitled. Partial entitlement (Base
holder, Pro tool) resolves correctly per package at every layer — the
tier ladder is tested, the chip and
MissingFor()are per-package. - Every degraded state leaks, and always in the same way the original work fixed: the wrong refusal reason, or a checksum-flavored failure, while the honest cause sits in a return value no surface shows. The phases below are ordered by how badly each hole bleeds.
- The Gumroad lane is gate-solid but surface-absent. The worker
treats a licence key as a first-class entitlement source —
productsFormerges Patreon tiers and Gumroad products into one claim, mixed accounts work, a key-holding session is exempt from the 30-day stale-trust cutoff, and re-checking a held key spends no activation (all tested,gate.test.mjssection 8). The client rail exists (ExtAuth.RedeemKeyvia theLicensekey/Licenseproductpars). But the PICKER — where the funnel lands people — has no redeem control at all, and every refusal sentence assumes Patreon. The amendments below (1.1, 1.3, 2.1, 2.5) carry the key-buyer persona through.
The findings live inline with their fixes; where a finding is recorded but not scheduled, it says so.
Each item lands with its own pinning test.
The website fix (08b690f) was never mirrored on the served rail. The
served picker's selection() (packaging/configurator/index.html,
selection() near the plusPicks split used only by installScript)
puts unentitled Plus picks straight into install; the updater skips
them into job['gated']; the page's /status poll then reports
"Download failed: unknown — see UPDATER status" while UPDATER status
reads as success. Net: nothing installs at all, wrong cause, status
contradicts it, retry loops. This is the surface the website's own
closing message funnels users toward ("hit Pick Tools and click Sign
in").
Fix, both halves:
selection()splits Plus picks the account is not entitled to out ofinstall(the page hasaccessandFNS_ACCOUNT.products; it can computeentitled(p)locally) — this also fixes the website's "Download selection.json" path, which shares the function./status(packaging/InstallerExt.py) readsjob['gated']and answers withMissingFor()wording;readymeans "all non-gated picks arrived" so free picks still install.
Tests: extend tests/test_picker_flavors.py — lift selection() into
node across all four account states, assert gated picks never enter
install and free picks do. Also pin the installScript() SEL/PLUS
split, which is currently untested (it was verified once with an ad-hoc
node + ast.parse one-liner and never again).
Field-confirmed 2026-08-29, first real walk: picking FNS_TimelineTools produced a refusal visible only in the Textport — exactly this defect. No longer theoretical.
Live check: signed-out mixed pick in TD → free tools install, Plus tool named with the sign-in sentence.
refreshEntitlement (worker/src/index.js, token-exchange block): when
the exchange succeeds but the identity call fails, the rotated
ex.tok.refresh_token is never saved. Patreon rotates refresh tokens on
use, so the kept old token can be dead — and its next use returns
invalid_grant, which the code correctly treats as permanent
revocation. A transient Patreon blip launders a paying supporter into
the revoked path.
Fix: persist ex.tok.refresh_token whenever the exchange succeeds,
regardless of identity outcome.
Test: the missing gate.test.mjs case — exchange ok + identity 5xx →
token saved, entitlement kept, next refresh succeeds.
modules/suspects/FNSTools/FNS_Updater/ExtUpdater.py, two localized
fixes:
_onGateDeniedalso fires for transport failure ("could not reach the gate") and gate 401 ("Sign in again"), but_report()recomputes the reason from local state viaMissingFor()— so an offline or session-expired supporter is told "Your current tier does not include X.", the exact inversionauth_client_callbacks.pywarns must never happen. Stamp the actual reason per dropped package in_onGateDenied;_report()uses it._needed()setsplan_namesbefore the gated filter, so gated skips still flow into_apply, which fails them with "no artifact in the store" and flipsok:False— a contradictory double report the code's own comment forbids. Exclude gated skips from the apply plan.
Verify: stub the three deny causes, read _report() output for each.
Found on the first real walk (2026-08-29): FNS_Hub / FNS_Console show
nothing — the picker page never renders — until the browser's
render-gating is switched off by hand. Mechanism
(/FNSTools/webBrowser/watch_rules): Active follows
(Watchwindow AND panel.winopen) OR (Watchviewer AND activeViewer).
Embedded in a Hub/Console PANE, neither is ever true, so the sign-in
surface — the funnel's front door in TD — is dark by default and only
an operator who knows the toggles can open it. The component
anticipated half the fix: Windowowner exists so an embedder points
the watcher at ITS window — the Hub must set it. The pane case needs a
visibility signal the watcher does not have yet; verify the right one
against the TD wiki (panel values / pane state) rather than guessing,
then add it as a third opt-in watcher. Interim: rail instances keep
the watchers ON only where a window/viewer actually hosts them; a
Hub-embedded instance must not ship dark. Fix belongs to the vendored
webBrowser master + _ensureBrowserPolicy in build_installer.py
(rails rebuild follows, per 0-milestone mechanics).
Status 2026-08-29: LANDED. Third watcher Watchpane ("Render Only
While Shown In Pane") + Paneowner on the master; the rule counts
only open PaneType.PANEL panes (a network editor's owner is a plain
COMP — often / — and counting those would mean always-on) and
matches owner by path prefix either direction. The Hub case is wired
by pointing the master's Paneowner at FNS_Hub, because the Select
COMP mirror means the browser is not in the Hub's hierarchy. Verified
live: dark with no pane, lit by a floating PANEL pane owning FNS_Hub,
dark one frame after close (the existing per-frame poll's cadence);
ColorUI's clone gained the pars and kept its own all-off values. Root
suspect PI-saved, vendored tox re-exported, _ensureBrowserPolicy
ships Watchpane on, rails rebuilt clean. The pane signal is
wiki-verified (Pane.open, Pane.owner, PaneType.PANEL).
Not an asterisk: a LAUNCH BLOCKER, because the rescue that saved the
first walk (a dev-side pull seeding the shared store) does not exist
on a customer machine. Observed once: fresh bootstrap project,
entitled, gated pick → "Downloading… 0 done, 1 to go" forever. What
is EXCLUDED by measurement: the shipped code (the identical sequence
— token-less scoped refresh → in-pass authorise → gated download →
verify — completes cleanly BOTH in the dev updater and on a live
copy of the shipped v3.0.5 bootstrap loaded into /sys/quiet), the
gate, the artifact, and the store. What remains: something about the
instance/process environment of a standalone TD running the
bootstrap. The wedged job's memory was lost to the successful retry,
so the next occurrence must be captured live — repro protocol:
delete FNS_TimelineTools.tox from the palette store, fresh (or the
existing test) project, pick it, Install; on wedge, drop
Embody/Envoy into that project and autopsy _job
(stage/queue/inflight), the auth client, and
fileDownloader.stateDict. Ship-with observability rides the same
fix: /status must expose the pass STAGE (authorising is not
"Downloading") and per-item downloader state, so a customer's wedge
names its own hop instead of looping a counter.
Field batch, 2026-08-29 evening (0e81f94) — four findings from the
first customer-shaped install, all landed and live-verified in BOTH
the dev project and the FNSTest2 test install (hot-patched): the
browser's visibility watchers cannot see an openViewer window at all
(measured — Active flipped off one frame after Pick Tools, the
console, or the viewer toggle turned it on), so serving flows now
declare a Holdactive that the installer's Configure and the
console's panel-open set and their server shutdowns release; the
paste script lands the bootstrap at / always instead of the current
pane's owner; a Plus pick makes the script open the picker BY ITSELF
150 frames in (the Textport line is a signpost, not the funnel); and
Open Settings answers a real sentence instead of the empty string the
page renders as "could not open the console". Rails rebuilt; ships
with the next release. 0.5's wedge stays open — the wedged pass this
morning eventually completed (likely un-stuck by this session's store
seeding), so the corpse was never captured.
After 0.1–0.3: run wrangler deploy (source TIERS is already real —
8323905/8291595/9796651 in worker/wrangler.toml; only the
deployed state is unverified), then walk the paid path with the
creator account — what the creator short-circuit (5917c0e) exists to
enable. Success = sign in, see "unlocked", watch a gated .tox arrive
and install.
Coordinate first: the ledger's in-flight domain-migration task
(functionstr.com → functionstore.tools) holds scopes over worker/,
website/, and packaging/. Land after it, or rebase onto it.
Status 2026-08-29, end of day: the release is REAL. v3.0.4 staged
complete (49 packages, TimelineTools on the plus rail, rails rebuilt),
uploaded — the bucket serves the v3.0.4 rolling manifest with
TimelineTools' gated URL (verified live). Getting there burned through
three Windows layers in the upload chain (see the CMS section) and
surfaced the version-persistence gap (Phase 3, PI dirt). The walk
itself is PART-DONE: the signed-out lanes were exercised for real and
paid immediately — three field defects found, two fixed same-day (0.4
the dark front door; the stranded download window: Showprogress now
defaults off, AbortAll closes it, late disconnects guarded —
3d1b97c) and one confirmed (0.1's Textport-only refusal). Still
open to close the milestone: wrangler deploy (the live worker
predates /session/recheck), then the ENTITLED walk — sign in as
creator, watch the gated tox arrive and install.
MILESTONE WALKED, 2026-08-29 evening: v3.0.5 released (updater 3.0.3), uploaded (plus/ canary private), worker deployed, site production-deployed — and the full customer path ran: /get/ → paste rail → bootstrap into a fresh project → picker → sign-in (three measured client bugs fixed on the way: Web Server DAT query params live in request['pars'] not the uri; Web Client request() neither writes par.url nor honours the auth pars — bearer must ride kwargs) → entitled → gated FNS_TimelineTools downloaded sha-true through the token rail and installed. One asterisk, launch-blocking: the fresh instance's OWN in-pass download wedged at "0 done, 1 to go" and was rescued by a dev-side pull into the shared store + retry; the exact sequence reproduces CLEANLY in the dev instance (token-less scoped refresh → in-pass authorise → download → verify), so the wedge is environmental to the bootstrap instance and needs a live autopsy (drop Envoy into the wedged project; inspect _job stage/queue/ inflight and the auth client). Also: /status says "Downloading" while the pass is authorising — conflates two states; give authorising its own words.
Phase 0 status, night of 2026-08-29 (branch dev25-entitlement-funnel):
ALL LANDED. 0.1 e2a9a84 — selection() splits gated picks in every
flavor, /status reports gated + gated_why and ready stops waiting,
the page speaks the sentence; pinned across all four account states,
and the paste script's SEL/PLUS split is pinned for the first time.
0.2 0abf563 — the rotated refresh token persists on exchange
success; tested through the full half-outage round trip. 0.3
9bf0908 — stamped drop reasons speak verbatim, gated names filtered
from the apply plan at consumption; verified live. 0.4 was landed
earlier (ed6a37b). Still open in this section: the milestone walk
(deploy + entitled sign-in — the user's morning).
- 1.1 Session death resurfaces Sign in. On gate 401
signed_out,OnTokenResponse/OnRecheckResponse(FNSTools/FNS_Updater/ExtAuth.py) mark the local record signed out, soFNS_ACCOUNTgoesnulland the picker's existing rail re-offers Sign in. Today the stale keystore keeps claiming "N unlocked" and the Sign in button stays hidden; the only escape is discovering the Signout pulse. Origin-aware: the remedy must match how the account was made — "sign in" for a Patreon half, "redeem your key again" for a key-only holder (free by design: re-checking a held key spends no activation). If the local record does not carry its origin, add it when storing. - 1.2 Lapsed entitlement corrects the local record. The 403
no_entitlementbody deliberately carriesproducts: [];OnTokenResponsecurrently discards non-200 bodies. Pass it through_rememberProductsso the picker stops claiming "unlocked" forever. Safe for key holders: worker responses always carry the MERGED products list (Patreon + Gumroad), so this cannot wipe purchased products. - 1.3 A dead grant gets a distinct signal. After permanent
invalid_grantthe worker strips the refresh token; recheck then returnsproducts: []forever — indistinguishable from "never pledged". Addconnected:false(or similar) to session/recheck responses so the client can say "your Patreon link is broken — sign in again". This is the original commercial dead end, one layer down. Only fire the wording for sessions that HAVE a Patreon half; a key-only session has no grant to be dead. - 1.4 No unauthenticated gated fetches.
_downloadwith an empty_gatedToken()sends the request bare; the 401 JSON body then fails the sha check and reports as "hash mismatch" — website defect #4's class, still alive on this rail (reachable when the 15-minute token expires mid-pass). Refuse the item with an auth message, or re-request a token. - 1.5 The refresh-kind report speaks too.
_report()'s refresh branch dropsjob['gated']entirely, andStoreStatus()counts deliberately-skipped gated packages as store-broken "missing" (ok:False). Emitgated/gated_whyfrom the refresh branch; give gated skips their own bucket inStoreStatus. - 1.6 Recheck honesty. Worker: when a recheck serves cached data
during a Patreon outage, say so (
stale:true/verified_at) — today it burns a throttle slot to return a silent stale "no". Picker: surface the real recheck outcome (throttle included) instead of the unconditional "Checking with Patreon…", and stop the success dialog unhiding a spurious Install button (showDialog(res.text, false)).
Phase 1 status, same night: ALL LANDED. 1.1+1.2 9b670e9 — a gate
refusal is data: the 403 body's merged products list corrects the
local record, a 401 clears the dead session locally and re-offers
both ways back in. 1.3+1.6(worker+client) 8574a64 — recheck answers
carry connected / stale / verified_at, the client picks the
sentence by structure, and recheck persists the response's own tiers.
1.4 0b2e17b — a gated item with no valid token is dropped with an
auth sentence before it can go out bare. 1.5 e55e225 — the
refresh-kind report speaks gated reasons via the shared _gatedWhy,
and StoreStatus gives gated-not-entitled absences their own bucket.
1.6(picker) fc41e05 — /auth/status serves the outcome, the page
polls it and reloads itself on product changes, and the spurious
Install button is gone. Every worker change is tested in
gate.test.mjs; every client change verified live in TD.
- 2.1 Name the routes, not just the tier. The data exists —
accessnames a tier id in the manifest — but no surface renders it: the picker collapses it toisPlus(),MissingFor()doesn't name the needed tier, the gate's 403 returns the tiers you have. Ship tier labels in the manifest'stoolkitblock (from the gate'sTIER_LADDER, alongside the already-plannedsupport_url), and stamp per-package purchasability at publish time (publish.pyalready readsGUMROAD_PRODUCTSto validate reachability — reuse that to emit abuy_url/flag). Then: chip tooltip andMissingFor()say "X unlocks at the Pro tier" — "or buy a lifetime key" when a Gumroad row exists — and the button reads Upgrade (not "Become a supporter") for a user already holding a lower tier. Wording must reflect the ladder ("this tier or higher"). - 2.2 Wanted-Plus picks survive. A website user's Plus picks live
one Textport line, then vanish from every later surface.
selection.jsonalready records them intools; the served picker pre-checks locked wanted tools from it (alongsideFNS_INSTALLED), so after sign-in the original intent is waiting. - 2.3
checked_atend-to-end or out. Emitted but always 0 (storage writesstored_at; nothing writeschecked_at) and never rendered. Either store it in_rememberProductsand render freshness in the picker, or delete the field from the emission. - 2.4 Truthful 403 tiers. Return trusted tiers (or a distinct
stale-trust code) instead of raw stored tiers, so the 30-day
stale-trust case stops reading as "tier not mapped". And don't stamp
verified_atat sign-in when the identity call actually failed. - 2.5 Redeem key on the picker rail. The picker offers Sign in /
Become a supporter / Check again — a lifetime buyer has no door at
all;
/plus/even promises keys are "redeemed in the same place as the Patreon connection", but that place is the updater's parameter page, which the funnel never points at. Add a Redeem key control to the picker's account rail (a new/auth/redeemroute inInstallerExt.ServeRequestcallingExtAuth.RedeemKey, same shape as/auth/signinand/auth/recheck), hidden on the public build by the samehasAuthRailguard the other remedy controls use — extend the flavors test's hidden-on-site checks to cover it. Keys are per-tool: the control should pre-fillproductfrom the locked pick where possible instead of asking the user to type a product id. - 2.6 The storefront must deploy when the mirror publishes. Found
live:
functionstore.tools/get/served a months-old build — no Plus chips, download buttons pinned to a release the bucket never held — while every push and CLI deploy landed as a Vercel PREVIEW, because the project's production branch ismainand the mirror pushesdev25. "Push didn't do anything" must be impossible to repeat: set the Vercel production branch todev25(Settings → Git) so everypublish_public.py --pushIS a production deploy, built from the full checkout — which also letsbuild-site.mjsregenerate/get/frompackaging/configurator/instead of warning "/get/ not built" and shipping the committed copy (the CLI-deploy payload cannot seepackaging/).vercel --prodfromwebsite/is the interim lever only. Bonus riding on any deploy: pin 2 comes alive (vercel.jsonrewrites/.well-known/fnstools.jsonto the bucket's). Pin 3 still needs itsfnstools-linksrepo pushed.
Phase 2 status, same night: CODE-COMPLETE except 2.6's dashboard
half. 2.1 718743c — the manifest's toolkit block carries the routes
projection (SUPPORT_URL owned here; the tier ladder's labels from
gate_package; per-package key_available from GUMROAD_PRODUCTS),
MissingFor and the locked chip name the entry tier ("or higher") and
the key where one exists, and a tier-holding unentitled account is
offered Upgrade, not Become a supporter. 2.2 5aaf5aa — FNS_WANTED
resurfaces wanted-but-locked picks. 2.3 7832228 — checked_at is
written on every gate answer and rendered as an age. 2.4 14a2f7e —
refusals show TRUSTED tiers; a half-failed sign-in stamps no
verification and retries soon (tested through recovery). 2.5
128e3af — Redeem key on the picker rail: inline DOM form, buyer
names the tool, the gate resolves the product id through its own
one-to-one map; dormant until GUMROAD_PRODUCTS gains rows. 2.6
remains: flip the Vercel production branch to dev25 (dashboard) —
plus one wording fix here: the committed-copy claim below predates
learning that generated pages are untracked; the CLI payload ships
the on-disk BUILD output, not a committed copy. All nine test suites
pass; every hot-synced client edit verified live in TD.
A third acquisition route exists that the phases above do not name:
tools that govern their own trial — TDXMap is the live example
(website/content/family.json: "14-day Pro trial · Base and Pro with a
membership or a licence key"), currently a family product with its own
site, its own licensing, entirely outside the store, manifest and gate.
Nothing in catalog.json carries a trial today, so this is vocabulary
and contract work, not a bug fix — but it must be settled before the
first trial-bearing tool ships through the store, because every one of
its decisions touches surfaces the phases above are already changing.
The model, as decided today: the tool governs the trial; the gate stays out of it. That fixes what each layer means:
- The artifact is not the paywall — the runtime is. A trial tool's
bytes must be downloadable without entitlement (else there is no
trial), so its manifest entry is NOT
access: <tier>in the current sense: the gated-skip logic (_needed), the picker's locked chip and the website's Plus-pick deferral must all let it through. It needs its own marker — e.g.access: <tier>plustrial: <days>, read as "install freely, the tool enforces" — soisPlus()/_isGated()don't misclassify it. Decide the exact vocabulary WITH 2.1, which is already touching howaccessrenders. - "Try free for N days" is a chip state and a funnel rung. The strongest conversion copy the picker and website can show; today the vocabulary cannot express it. Chip: "Pro · 14-day trial" instead of "Plus · locked"; the pick installs immediately, signed in or not.
- The handoff back is the part that must not dead-end. A trial
expiring inside the tool is the same moment as the picker's "locked"
state, one surface deeper. The tool needs a runtime entitlement
contract to ask "is this account entitled to X, and if not, what are
the routes?" — i.e. a promoted, documented API on the auth extension
(products lookup +
MissingFor-style routed refusal + the 2.1 route data), so an expired trial shows the SAME funnel (upgrade tier / buy key / sign in) instead of each tool inventing its own. Design the surface per/parameter-design+ extension conventions when it lands; the enforcement stance is honesty-box (same as the rest of the toolkit — the gate protects bytes, not runtime). - Explicitly deferred: gate-minted trial entitlements (time-boxed
products issued per account, one per user, server-enforced). More
honest than tool-side clocks but real worker surface (trial claims,
expiry, one-shot issuance, abuse throttles). Do not build it
implicitly through any of the above; if wanted, it is its own
research doc. Update 2026-08-29: wanted — the TDXLU Gate Contract
requests exactly this (
/trial/start, Turnstile-gated, KV anchor written once), andTDXLUGateIntegration.mdaccepts it with conditions. The trial lane therefore FORKS by design: TDXMap holds its own clock, TDXLU uses the gate-minted model; either family product may later converge on/trial/startonce it exists.
TDXMap itself stays outside the store either way; this section exists so that when a store tool (or a store-distributed TDXMap) wants the same funnel shape, the toolkit's surfaces already have the words for it.
Today both placement axes have exactly one hardcoded answer: every
package lands inside THE toolkit container (InstallerExt resolves the
container that ships the installer, else the project's, else a new one
beside Embody), and every artifact lives in the flat machine store at
<palette>/FNSTools/store — by contract a MIRROR of the bucket,
where "nothing in it is anyone's work." Products are coming that break
both assumptions, TDXMap first among them. Two independent axes to make
explicit:
- Network destination. Three policies: inside the toolkit
container (today's only mode, stays the default); beside it — a
sibling at the container's parent, for products that are their own
top-level thing (TDXMap next to FNSTools, not inside it); and at the
cursor — wherever the user's network editor's owner is at install
time (
ui.panes.current.owner), for drop-anywhere tools. The third is really a per-INSTALL choice a package can declare it wants, not a fixed path — and it needs a fallback for headless installs (the paste rail has no pane). - Disk destination. TDXMap.tox wants
<palette>/TDXMap/, not the FNSTools store. Keep the invariant rather than bending it: the store stays the flat, sacred bucket mirror (download, verify, one copy of each package —_inStoreand "stale cache, never a modification" depend on this), and a new INSTALL-time placement step copies the verified artifact to the package's declared palette folder. Per-package store paths would break the mirror contract for the worst possible reason; a placement step breaks nothing.
What carrying these two axes touches:
- Manifest vocabulary: a per-package
placementblock (network policy + palette folder), decided together with 2.1 and the trial marker — three vocabulary changes to the same catalog rows should land as one schema, one migration, one CMS authoring pass (picker discipline; placement is packaging authoring, so it lives on the FNS_CMS side of the split). - Update tracking:
UpdateProjectcompares what THIS project recorded at install time against the store — that record must carry WHERE the package landed (network path and disk path), or updates will never find a sibling-landed or cursor-landed install. Same for palette-shared installs whoseexternaltoxpoints at the store artifact: a TDXMap pointing at<palette>/TDXMap/is a second externaltox convention the reload path must honour. - The gated lane composes with placement, unchanged: a gated outside-lander still downloads through the token rail into the store first; placement is a local copy after verification. No new gate surface.
- Preflight coherence (the same Stage-time gate the CMS section extends): refuse placement declarations that cannot work — a cursor-landing package that stamps a registry host expecting the toolkit container, a palette folder that collides with another package's, a beside-lander with no container to be beside.
- Conformance:
/fns-packagingand the registry rules assume the container in places (parent shortcuts,/sysstamping, Packages() enumeration explicitly EXCLUDES things outside/FNSTools— which is the point for FNS_CMS, and exactly why a beside-lander needs its own answer for "am I installed / current"). Walk those contracts once with a beside-lander before declaring the vocabulary done.
TDXMap is the composite proof. It is gated (Base/Pro), trial- bearing (14 days, tool-governed), key-purchasable, beside-landing, and palette-foldered — one product exercising every section of this plan at once. When it ships through the store, that shipment is the integration milestone for the trial lane, the Gumroad lane, and placement together; do not certify any of the three vocabularies as done before that walk.
The funnel has two halves: what the customer sees (the phases above)
and where the operator authors it — and the second half already has an
owner. FNS_CMS is the entitlement-authoring surface (gate_package
wrapped as pickers: package roster from catalog.json, tiers from
TIER_LADDER, names not ids — f1f0ea0), while cms.mjs owns content
and deliberately declined entitlement ("does not offer to invent a
tier id"). Every plan item that adds funnel vocabulary must land on the
right side of that split, with these specifics:
- 2.1's new manifest fields need authoring homes. Tier labels come
from
TIER_LADDER(already CMS-rendered); per-package purchasability is derived fromGUMROAD_PRODUCTSat publish (no new authoring); butsupport_urlin the toolkit block needs an owner — either a CMS field or a declared constant, not an unowned key someone hand-edits into a manifest. - The CMS entitlement table should preview the CUSTOMER sentence.
It already shows the operator's view ("Unlocks from: Base"; "Tiers
that grant it: …"). Once 2.1 ships routed refusals, show the same
line the picker/
MissingForwill render ("X unlocks at the Pro tier — or buy a lifetime key") so gating mistakes are caught at authoring time, before a customer reads the wrong sentence. Single-source rule applies (the DOCS_SITE drift inCmsExt._docsSiteis the cautionary tale): the preview must read the same published fields the picker reads, never a parallel copy of the ladder. - The trial marker is entitlement, not content. When the trial
vocabulary lands (
trial: <days>besideaccess), it is authored in FNS_CMS's entitlement tab with the same picker discipline — no typed-from-memory fields — and NOT incms.mjs, per the declared split.cms.mjskeeps descriptions, categories, docs prose. - Preflight/Stage is the funnel's last gate.
publish.pyalready refuses a gated package no tier and no Gumroad row grants; as 2.1 and the trial marker add fields, extend the same preflight to refuse incoherent combinations (atrialmarker on a free package, abuy_urlwith no Gumroad row) so the CMS surfaces the refusal at Stage time, in the operator's face, not in a customer's picker. - The CMS must name its motions truthfully. Its release action is
labelled "Publish selected" while
_apiReleasehard-codesupload=Falseand deliberately does not offer upload — so the CMS can do everything EXCEPT ship, and its button claims the one verb it cannot perform. Observed cost, both directions: the operator stages and believes the last step is done, or presses "Publish" and believes it shipped when nothing reached the bucket. Fix per the naming doctrine (name the effect): reserve publish/ship for motions that reach the bucket, rename the CMS action to what it does (release & stage), state on the page where the motion ENDS (packaging/publish/, nothing uploaded) and where shipping lives (PI's Publish-to-Bucket, orupload.pyin a shell) — ideally with a read-only view of the upload log so the CMS can at least REPORT ship state it refuses to own. Status 2026-08-29: the copy is fixed inwebsite/tools/cms.html(button "Release & stage selected", hint says where the motion ends and where shipping lives, dialog/toast verbs match) — riding uncommitted with the CMS session's in-flight work on that file. The upload-log view remains open. - The CMS MAY ship — honestly. Decided: the objection to upload in
the CMS was watchability, not capability, and PI already ships via
StartUpload()— a DETACHED subprocess logging topackaging/publish/.upload.log, not in-process network I/O. So the CMS gains a separate, truthfully-named Upload to bucket action that kicks the sameStartUploadand tails the log into the page — the read-only log view above, made load-bearing. Release & stage and Upload stay two buttons, never one: the two-step is the honesty. Prerequisite fix for BOTH surfaces:StartUploadspawnspython3, which does not reliably resolve on Windows — PI's Publish-to-Bucket has the same latent failure; fix the interpreter discovery once inrelease_one.py. (packaging/is inside the domain-migration task's claimed scopes — land through or after it.)check_pins.pystays a shell step. Status 2026-08-29: LANDED —/api/upload+/api/uploadlogon FNS_CMS, Upload-to-bucket + log-tail + PI-Save-unsaved on the release view, endpoint verified live. The release also now PI-saves the packages it bumps (release_one.py), closing the where-are-my-tox-diffs gap. The first REAL upload then peeled three Windows layers off the chain, each found only by running it:which()trusted the App-Store stub aliases, so candidates are now validated by execution — a python is real only if it prints 1 (f789b4c); barenpxisnpx.cmd, which the shell resolves and CreateProcess does not (a790066); andtext=Truedecoded wrangler's UTF-8 with cp1252, shredding a healthy run — encoding pinned end to end (17b2f78). UI edits ride uncommitted with the CMS session's in-flight work. - Coordination: the CMS is under active development in a parallel
session (uncommitted
CmsExt.py/cms.html/cms.mjswork in the tree at the time of writing) — land CMS-side amendments through or after that work, not blind beside it.
- Tests: the Phase 0/1 pins above; the flavors test's guard-ordering check gains the signin unhide; broaden the "token never leaves the updater" grep beyond two literal strings.
- Doc corrections to
EntitlementPathReview.md(written to be re-checked, so keep it checkable):_makeQueue→_needed; "same counter as redeem" → same limit/mechanism, separate bucket; note the Gumroad redeem as a third products-write path. - PI dirt is blind to script-written pars (paid for on v3.0.4):
a
p.valwrite from the release rail bumped 49 live versions and PI marked 13 dirty — "Save unsaved" honestly saved what PI saw and the other 36 tracked toxes kept the old version, FNS_Updater included. The release self-saving its bumps (landed) closes the release case; the residual hazard is any OTHER scripted par write trusted to PI dirt. Either teach PI's dirt to see par writes, or teach the CMS release view to flag rows whose live version disagrees with the committed suspect (it has both numbers). - The dev installer does not hot-sync, and testing forgets it (paid
for on the walk, twice-shaped like 0.4's dark door): the live
FNS_Installer's
InstallerExtandconfigurator_htmlare embedded SNAPSHOTS by design — what ships is what they hold — so editingpackaging/InstallerExt.pyor the configurator changes nothing live untilEnsureDevRails()re-embeds them, and the browser panel additionally holds the old DOM until reloaded. The walk hit a server without/auth/*answering an empty dialog. Candidate fix: the preflight (or a test) compares the LIVE installer's snapshot against the repo file the way_staleRailscompares the rails, so a stale dev installer blocks a release instead of confusing a walk. - A browser address outlives its server (third walk finding, same
family as the dark front door): the shared webBrowser keeps whatever
Addressthe last serving flow wrote — the walk found it holding a long-dead Console port (36710) with the picker's server not running at all, every click landing in the void as an empty dialog. The ports themselves are fine (installer 36760 + bind-walk, console 36710–59, CMS 36770–79 — multiple open projects are designed for). Landed: the page'spost()failure now names the remedy ("press Pick Tools") instead of showing a raw or empty error. Still open: the Hub's tabs could pulse their flow's serve on tab focus, so a stale address self-heals instead of relying on the user knowing the front door. Landed:HubExt._serveConsolere-serves on every console-tab expose. A fourth member of this family surfaced and fell 2026-08-30 (field report: "the browser does not activate again on the console tab"): a windowCOMP window satisfies NONE of the browser's visibility watchers either — the hub's own comment knew (winopenstays 0 under a windowCOMP) — so_expose's directActivewrite was reverted bywatch_rulesone frame later, measured live. Fix: hub exposure of a Holdactive-bearing browser declares the HOLD (the same contract every serving flow uses) and releases it on tab-leave / window-close; watch_rules owns Active either way. - Auto-resume deferred Plus picks (LANDED
15d0ce5, 2026-08-30; shipped in v3.0.9's rails and WALKED the same night on customer rails: paste with a Plus pick → adoption → gated download → installed, zero clicks.tests/test_picker_flavors.py§8 pins the guards. Post-walk polishbad7be9: the auto pass's install report appends "already in the project: …" so the one-line Plus report does not read as the whole install): a paste with a Plus item checked defers it intoFNS_WANTED— by design, since the paste script can never carry a token — but today the funnel stops at a pre-ticked picker and the user must still click Review install → Install. The consent is already given (they checked the item on /get), so the picker page should finish the pick itself: on load — and again wheneverwatchAuthreloads after a sign-in/adoption — if everyFNS_WANTEDitem is entitled and installable, plan and install straight through, no countdown (operator decision 2026-08-30: the free picks in the same paste installed without ceremony; the Plus pick was deferred for token reasons, never consent — the dialog narrates every hop instead). This covers BOTH store states with one rule: bytes present → install-only; bytes absent → the same POST /install path downloads them over the authenticated rail first (progress visible in the page — never a silent background install, per the 0.5 wedge class). Any partial state (an item the tier doesn't cover, a failed download) drops back to the manual picker with the honest per-item labels. Two timelines, same rule: entitled-at-paste-time (adopted machine session) auto-completes immediately; sign-in-later completes the moment products change. Only ever auto-installs what the user explicitly checked. - The install plan should admit an update exists (operator
finding, 2026-08-30): a row reading "present, kept" while the store
holds a newer version reads as nothing-to-do — the plan dialog is
where the eyes are, and it should annotate
"present — 3.0.3 → 3.0.4 available (updates run from the console)"
in
_planText. Install and update stay separate MOTIONS; only the sentence gets honest. - Cosmetics: the "page reloads itself" comment in
InstallerExt.py(made TRUE by 1.6 rather than deleted);slow_downvsrate_limitedunification; the three/session/*routes missing fromworker/README.md's route table; the strayNoneTypethird entry inFNS_Updater.extensions; recheck's redundant double-hash and double-save; recheck persisting stale local tiers instead of the fresh payload's.
- Artifacts for FNS_TimelineTools and the seven other catalogued-but- unexported packages — release track.
- Populating
GUMROAD_PRODUCTS(ships empty{}inworker/wrangler.toml, so nothing is key-purchasable today) — same bucket as the tier-map deploy;gate_package.py --gumroadis the tool. Note 2.1's manifest stamping reads this map at publish time, so it only lights up once rows exist. - Real-customer end-to-end (needs a real pledged account; unlocked by the Phase 0 milestone).
- Patreon-latency tuning of the recheck throttle — revisit with real supporter data.
ExtUpdater.py/ExtAuth.pyare hot-synced into the live TD session: land file-by-file,get_op_errorsafter each, perworktree-td-safety.worker/andwebsite/are inert on disk and safe to batch.- Sequence everything behind (or rebase onto) the in-flight domain migration; its ledger scopes overlap every directory this plan touches.
- Success criteria per phase are the named tests plus the live checks; the plan is done when the Phase 0 milestone walk succeeds and every degraded state in the review's funnel matrix shows its true reason and a next step.