| status | landed |
|---|---|
| summary | Shipping FOREIGN packages through the store (landed 8a280ae1) — tools whose bytes and version are owned upstream (TDXMap first). One new package kind, a catalog `source` block, a shell-side sync step, a self-managed updater state, and new curated fields for every package (author, homepage, changelog) editable in the content CMS and shown by the picker and website. Nothing changes in the upstream repo. |
| since | 2026-09-02 |
| skill | fns-packaging |
Goal: a tool that is NOT a depth-1 suspect of this project — its bytes,
version and licensing owned by another repo and another release rail — can
be listed in the catalog, picked in the installer, mirrored in the bucket,
authored in FNS_CMS and documented on the website, without pretending it
is one of ours. TDXMap is the first and the composite proof
(EntitlementFunnelPlan.md, "TDXMap is the composite proof").
Non-goal: replacing the upstream tool's own updater, paywall or trial. The store installs; the tool governs itself afterwards.
- Identity is the live COMP:
build_manifest.Packages()enumerates depth-1pi_suspectCOMPs withexternaltoxunder/FNSTools. Nothing else is a package. FNS_CMS_packages()is a copy of the same filter, so every CMS tab except Entitlement is blind to anything not live here. - Version is read live off the COMP (
Pkgversion, FNS_About first); artifacts are exported from live TD intopackaging/dist/;Stage()refuses a name that vanished fromPackages()unless declaredretired;release_onebumps the live par. - The store refresh only fetches artifact URLs under the configured
base_url(ExtUpdater._artifactRel); a foreign URL is ignored. - The retire flow treats "not in the live set" as retirable — a catalog entry with no live COMP looks retirable at any time.
- TDXMap specifics: dev COMP
/TDXMapinC:/VJ/TD/Projects/TDMap(read-only Envoy on port 9123). Shell carriesVersion(1.2.0), notPkgversion, noFNS_About; shortcutTDMap. Its release manager exports one locked tox to a PUBLIC R2 bucket with a flat manifest{latest, version, url, sha256, notes_url, published}. Entitlement (Patreon / Gumroad / 14-day demo) is enforced at runtime inside the locked tox; its own updater rewritesexternaltoxto<palette>/TDXMap/TDXMap.toxand reloads in place.
Corrected while building (2026-09-02): the doc first said "a new package
kind". kind is the installer's and picker's STRUCTURAL axis (core vs
tool) and is tested in eight places; a third value would have had to be
threaded through every one of them to behave like a tool. Foreign is a
PROVENANCE axis, so the row keeps kind: tool and carries foreign: true
plus updates; only the release rail and the CMS branch on it.
A foreign package is declared, not discovered. The declaration is the catalog entry — the catalog is already "what a machine cannot know":
"TDXMap": {
"category": "Control",
"description": "MIDI controller mapping with a web UI: multiple devices, banks, Smart Learn, button actions, 14-bit controls.",
"placement": "root",
"source": { "manifest": "https://pub-6d70b11fbdda47e1bbbb33f079ca8a7d.r2.dev/manifest.json", "tox": "TDXMap.tox" },
"updates": "self",
"help_url": "https://tdxmap.functionstore.xyz/docs/",
"min_td_build": "2025.30000"
}Authority rules (preflight-enforced, so authority never forks):
sourceis what makes an entry foreign. Shape:{manifest, tox}for an upstream flat manifest (TDXMap's shape); a{file}variant for a hand-dropped artifact can come later if ever needed.help_urlandmin_td_buildare accepted ONLY on foreign entries. On a live package they come fromFNS_About.Helpurland the export build; preflight refuses the catalog keys there.updates: self | store.self= the tool runs its own updater after install; the store only installs.store= the FNS updater tracks it like a tool (needs aPkgversionpar inside the artifact — TDXMap does not ship one, so it isself).access,license,seats,recommended,placementkeep their meaning. A foreign entry CAN be gated, but gating TDXMap contradicts its public R2 URL and tool-governed trial; leave itfreeuntil the trial vocabulary (funnel plan 2.1) lands.
Opening the catalog a little is the point of this work. The fields below are curated (a machine cannot know them), ride into the manifest, and are therefore visible to the installer picker, the website and both CMS surfaces. All of them are editable; none is derived.
| Key | Meaning | Consumers | Allowed on |
|---|---|---|---|
author {name, url} |
who made it; the link is optional | picker byline ("by …" next to the description, configurator/index.html), website badge, FNS_CMS roster |
all packages; defaults to Function Store when absent |
homepage |
the product's own site, when it has one | picker and website link-outs | all |
changelog_url |
release notes when they live upstream | picker "what's new", website; foreign_sync may prefill it from the upstream notes_url |
all |
help_url |
the docs page override | picker docs link, website | foreign only (live packages: FNS_About.Helpurl) |
min_td_build |
install floor | installer refusal, picker badge | foreign only (live packages: the export build) |
source |
upstream artifact pointer | foreign_sync, Build, Stage | foreign only (its presence defines foreign) |
updates |
self or store |
updater compare state | foreign only (live packages are always store-tracked) |
Not the recommendations lane. packaging/recommendations.json already
carries "tools by other creators" — as LINKS: never hosted, never
versioned, its validator refuses every package-shaped field, and it
publishes without a release. A foreign package is the opposite on both
counts: we host a mirror of the bytes (pinned by sha in our bucket) and we
version it from an upstream manifest we poll. The line is whether we poll
a source and keep a copy current. Both lanes stay; neither absorbs the
other.
Conflict surfaced, not averaged: author/credit has two possible homes.
Today five docs carry a credit {name, url} frontmatter block
(packaging/docs/TDX_SearchPalette.md et al.) that only the website reads.
The picker reads the manifest, so a byline in the installer needs the field
in the catalog. Pick the catalog as the ONE home: build-site.mjs reads
p.author from the manifest, the five credit blocks migrate into
catalog.json author entries, credit leaves FM_ORDER, and the site
build refuses a doc that still carries it (same discipline as the
package/filename check). Flagged as a cleanup item in the order of work.
packaging/foreign_sync.py [names]: for each foreign entry, fetch the
upstream manifest, download the tox to packaging/dist/<Name>.tox when
the sha differs, verify sha256, and write packaging/foreign.lock.json:
{ "TDXMap": { "version": "1.2.0", "sha256": "…", "url": "…/TDXMap.tox",
"notes_url": "…/changelog", "fetched_at": 1787188130 } }Build() reads ONLY the lock — no network I/O on TD's main thread, which
is FNS_CMS's standing rule (upload and check_pins are shell commands for
the same reason). Stage() refuses a foreign entry with no lock, or whose
dist bytes do not match the lock's sha.
Emits rows from lock + catalog: kind: foreign, upstream version,
help_url and min_td_build from the catalog, surfaces / hotkeys /
requires / integrates_with empty, ops 0, tox_carrier: own,
updates carried through, artifact from dist like any other row.
Packages() is untouched. Stage's vanished-package check and
release_one's bump exclude foreign names. Upload changes nothing: the
artifact sits in dist beside the manifest, so the bucket layout, the
store refresh and _artifactPath all work unchanged.
- Installer: a foreign row installs like any tool of its
placement(TDXMap:root, beside the toolkit container at/, whereop.TDXMapexpects it). Embedded bind by default; the tool's own updater may repointexternaltoxafterwards — that is its business. Compare()rendersupdates: selfas a newself-managedstate (sibling of the existingcomponentstate for pane placements): reported, never offered an update, never touched byUpdateProject. The installed COMP needs noPkgversion. Removal works normally._isGated/_entitledunchanged.
There are two CMSes and the split already exists; foreign packages add to both rather than inventing a third place:
- Content CMS (
website/tools/cms.mjs) authorscatalog.jsonand the docs. Its package editor already writescategory,description,recommendedandplacement. It gains every field in §1b (author,homepage,changelog_url, and for foreign entriessource,updates,help_url,min_td_build), with the allowed-on rules enforced in the editor so a live package cannot be handed ahelp_url. Gating stays routed throughgate_package.py, as today. - FNS_CMS (
FNS_CMS/CmsExt.py) stays the release cockpit. Foreign rows come through_pkgMod('build_manifest.py')['ForeignPackages']— ONE definition shared with the build, never a private list in the CMS.
What is read-only for a foreign row is exactly what comes from upstream or from live TD: version, sha, artifact URL, the bump, the export, PI save and dirt. Everything curated is editable, the same as for a live package.
- Release table: foreign rows show lock version vs published, a
"foreign · updated upstream" badge, and a Sync foreign button running
foreign_sync.pyas the same detached subprocess pattern/api/uploaduses, watched through the log. Selecting one for a release stages its mirrored artifact; it never bumps. - Packages tab: shows the §1b fields per row (author, homepage, help URL effective vs override) and links to the content CMS editor for them; Parameters and Hotkeys tabs skip foreign rows (nothing to reflect).
- Retire: a foreign package retires by deleting its catalog entry; the retire flow learns that and stops treating "not live" as retirable for foreign names.
- Entitlement tab: badge foreign rows so a tier grant is a deliberate act
(the same observability gap
TDXLUGateIntegration.mdnames forTDXLU_Pro).
packaging/docs/<Name>.mdrequired as usual (site build hard-fails otherwise). Author name and link come from the manifest'sauthor(§1b), not from doc frontmatter.kind: foreignmay render as a "family product" badge;help_urllinks out to the upstream docs instead of the derived toolkit page;homepageandchangelog_urlbecome link-outs on the package card.- The installer picker (
packaging/configurator/index.html) shows the byline next to the description and the same link-outs beside its existing "docs ↗" link, for every package that declares them.
Next to tests/test_publish_guards.py and tests/test_gate_package.py:
foreign resolution against a fixture manifest, sha mismatch refusal,
Stage exemption, catalog key validation (help_url on a live package
refused), and the self-managed compare state.
packaging/build_manifest.py:CURATED_LINK_KEYS,FOREIGN_ONLY_KEYS,CuratedLinks,ForeignEntries,ForeignLock,CatalogProblems,ForeignPackages;Build()appends foreign rows after the live loop and returnsforeign+catalog_problems.packaging/foreign_sync.py(new) +packaging/foreign.lock.json(committed). Understands the flat upstream shape (TDXMap's) and our own manifest shape (source.packagenames the row). Refuses unpinned upstreams and sha mismatches, leaving dist untouched.- 2026-09-23: a GitHub release is a third upstream shape. Point
source.manifestathttps://api.github.com/repos/<owner>/<repo>/releases/latestand name the asset insource.tox. The tag is the version (a leadingvis dropped), the asset'sbrowser_download_urlis the artifact, and the asset'sdigest(sha256:<hex>, which GitHub serves for every uploaded asset) is the pin; the release page isnotes_urland the release body rides asnotes. No catalog key was added: the release document is the manifest. A release whose asset carries no digest is refused exactly like a flat manifest withoutsha256. First entry: ParHoverMIDI_VSN1 (updates: self,placement: root,nopick: true), whose own repo publishes tagged releases and nothing else.
- 2026-09-23: a GitHub release is a third upstream shape. Point
packaging/release_one.py: Preflight blocks onCatalogProblemsand unmirrored foreign;ReleaseManyships foreign names without bump, export or PI save, refusing before the label moves when the mirror is broken.Stage()needed NO change: a foreign row without an artifact is "no artifact in manifest", the same refusal a failed export gets, and the removed-guard applies unchanged (retire = delete the catalog entry AND declare it, which the CMS retire action does in one motion).FNS_Updater/ExtUpdater.py:Compare()stateself-managed; the console files it under "Up to date" as "updates itself".FNS_CMS/CmsExt.py: foreign rows in/api/dirty(lock vs published, mirrored/not, problems),/api/foreignsync(detached subprocess, same pattern as upload) +/api/foreignlog; retire understands a foreign name. There is no FNS_CMS page —website/tools/cms.htmlis the one UI and proxies/api/td/*.website/tools/cms.mjs+cms.html: package editor gains author, website, changelog, and a "Foreign package" block (source manifest, artifact name, updates, minimum TD build, docs URL) with the allowed-on rules enforced server-side (applyCurated); release table renders foreign rows with Sync + Retire; FOREIGN list tag;creditleftFM_ORDERand is stripped on save.website/tools/build-site.mjs: author from the catalog; a doc still carryingcreditfails the build; family-product / website / changelog badges.packaging/configurator/index.html: byline, site/changelog link-outs, "updates itself" chip.- Catalog: five
creditblocks migrated toauthor;TDXMapentry (Control, root placement,updates: self, min TD 2023.12120 per its README);packaging/docs/TDXMap.md. tests/test_foreign_packages.py: the offline contract across all of the above, including a file:// upstream round-trip of the sync.
Still open: the cold test (needs the release uploaded), and the
min_td_build of a foreign package is hand-declared — the upstream
manifest carries none.
- Catalog vocabulary (§1, §1b) + preflight validation of the allowed-on
rules (
build_manifest,pre_release_common);author,homepage,changelog_urlpass through to every manifest row. - Author migration: the five doc
creditblocks move intocatalog.jsonauthor;build-site.mjsreads the manifest;creditleavescms.mjsFM_ORDERand the site build refuses it. - Content CMS package editor gains the new fields with the allowed-on
rules; picker byline and link-outs in
configurator/index.html. foreign_sync.py+ lock file +Stage()guards.ForeignPackages()and the manifest row;release_oneexclusion.- Updater
self-managedstate. - FNS_CMS rows, Sync button, retire flow, entitlement badge.
- TDXMap catalog entry +
packaging/docs/TDXMap.md;npm run pages. - Cold test: bare project, bootstrapper, pick TDXMap, lands at
/TDXMap, its auth gate opens, its own Check / Install works, FNS updater showsself-managed, picker shows the byline and link-outs.
Estimate: three to four days on this repo. Nothing changes in the TDMap repo.
- Palette-folder disk placement (
<palette>/TDXMap/) as an installer step — TDXMap's own updater does that copy on first update; the placement vocabulary inEntitlementFunnelPlan.mdremains to be settled with the trial marker. - Gating foreign packages through the worker map; gate-minted trials.
- Foreign packages with
updates: store(needsPkgversioninside the upstream artifact — a one-par change upstream when wanted).