Spec: Feature Add-on Packs — fetch, verify and load a feature's libraries in a frozen install¶
| Field | Value |
|---|---|
| ID | spec-feature-addon-packs |
| Status | Proposed |
| Date | 2026-10-20 |
| Decision record | ADR-028 |
| Related | ADR-019 (egress), ADR-018 (price first, no plug-ins), ADR-016 (dependency budget), ADR-027 (one-click Windows update, unmerged branch integ/wave3-2026-10-20) |
| Modules | new src/yazses/packs/ (pure core + orchestration), new PackRuntime Protocol in platform/base.py, platform/windows/packs.py, packaging/windows/rthook_yazses_packs.py, scripts/build-packs.py, packaging/packs/packs.toml |
Goal¶
When a user turns on a heavy feature in a frozen Windows install, YazSes shows the price, downloads that feature's add-on pack from the running version's GitHub release, verifies it against the hash built into the installed app, installs it per user and loads it on the next start. The base installer does not change.
Non-goals¶
- Third-party plug-ins. ADR-018 §3 stands. Only packs listed in the embedded index of the running build can be installed or loaded.
- Models. Packs carry libraries. Model weights keep their existing first-use fetch paths and egress rows.
- Background or automatic downloads, including after an update.
- Hot-mounting into a running daemon. Mounting happens at process start.
- MSIX, macOS
.app, snap and flatpak in Phases 0–2 (see ADR-028 §8). - In-app Sigstore/SLSA verification (rejected for now, ADR-028 §3).
Module list¶
The house split applies: pure modules with no I/O beside thin backends that are injected.
| Module | Kind | Responsibility |
|---|---|---|
packs/manifest.py | pure | Parse and validate pack.json, RECORD.json and the index (format: 1). Return typed dataclasses or a ManifestError with a user sentence. Never raise anything else. |
packs/abi.py | pure | BaseIdentity (build id, Python cache tag and patch version, platform, arch, app version), read from the embedded index plus sys facts that are passed in. match(pack, base, index_entry) -> Match returns ok, stale or refused, with one reason per field. |
packs/layout.py | pure | The paths below data_dir/packs, short components, and the 140-character budget. build_dir(build_id) uses the first 12 hex digits plus a collision guard read from state.json. |
packs/archive.py | pure | Zip member validation (names, kinds, counts, sizes, ratio) over a list of (name, size, compressed, is_dir, external_attr) tuples, so no real archive is needed in tests. check_tree(record, stat_fn). |
packs/catalog.py | pure | Feature slug to pack name, derived from _FEATURE_DEPS and the extras in pyproject.toml, plus the packs.toml decisions (pack, bundled, no-windows-wheel, unwired, deferred). |
packs/state.py | pure | The per-feature state machine in ADR-028 §5 and the state.json schema. Inputs: catalogue, index, installed trees, config. Output: one PackStatus per feature. |
packs/policy.py | pure | Disk-space arithmetic, the size-proportional deadline, the PACK_MAX_BYTES ceiling, GC and rollback choices (keep the current build, keep the previous build until started-ok, delete the rest), and the quarantine-or-stale decision. |
packs/fetch.py | I/O, egress | Builds the two URLs (SHA256SUMS.txt and the pack) for tag v<running version>, then calls ADR-027's fetch_checksums, expected_sha256 and download_verified. It is the only module that starts a pack download, and the FETCH_VIA entry in the egress test. |
packs/install.py | orchestration | The install transaction (ADR-028 §6) with injected fetch, extract, probe, replace and clock. Returns InstallResult(ok, title, message, steps, log_file). It never raises to the UI. |
packs/mount.py | pure decision + backend call | plan_mount(base, installed, env, elevated) -> MountPlan (paths to append, native dirs, quarantines). apply(plan, runtime). |
packs/probe.py | CLI-side | yazses packs probe <name> [--staged <dir>] mounts one tree and imports entry_modules. Exit 0, or a non-zero code naming the module that failed. |
platform/base.py | Protocol | PackRuntime: supported() -> bool, is_elevated() -> bool, add_native_dir(path) -> object (a handle that is kept alive), embedded_index_path() -> Path \| None. |
platform/windows/packs.py | backend | os.add_dll_directory, a token-elevation check (GetTokenInformation(TokenElevation) via pywin32, already a base dependency), and Path(sys._MEIPASS) / "yazses-packs-index.json". Registered in platform/factory.py. |
platform/{linux,macos}/… | backend | supported() -> False until their phase. |
packaging/windows/rthook_yazses_packs.py | runtime hook | Three lines: import, call mount.mount_at_startup(), swallow every exception. |
cli.py | surface (L3) | yazses packs [list] / install / remove / verify / path / probe / clean. features enable integration. |
tray/…, settingsui/controller.py | surface | States and actions from packs/state.py. Progress and cancel go through install.py callbacks. |
cameraperm/matrix.py | data | PackageProfile.can_add_packs. |
system/deps.py | integration | fixed_payload_reason returns None when the profile can add packs and the index has the feature's pack. |
scripts/build-packs.py | build | ADR-028 §7. Stdlib plus uv only, so it runs on the Windows runner without new dependencies. |
packaging/packs/packs.toml | build data | Per-extra decision, entry modules, approved_large, shared_with. |
A rule for every module above: nothing under packs/ branches on sys.platform. The OS facts come through PackRuntime (AGENTS.md rule 6).
On-disk layout (Windows)¶
%LOCALAPPDATA%\yazses\packs\
state.json installed packs per build, last results, started-ok
b-<12hex>\<name>\ one tree per pack per base build
COMPLETE written last
pack.json RECORD.json site\ stdlib\ LICENSES\
staging\<name>.<rand>\ extraction in progress; deleted on any failure
downloads\<asset>.part ADR-027 download_verified's bounded .part file
quarantine\<name>-<UTC>\ + reason.txt
<name>.lock O_CREAT|O_EXCL single-flight; stale after 2 h
Phases¶
Phase 0: spike on the Windows runner (no user-visible change)¶
The goal is to settle ADR-028's open risks with evidence before any surface is built.
- A throwaway workflow (
workflow_dispatchonly) builds the base as today, then builds theparakeetanddiarizationpacks with a first version ofscripts/build-packs.py, and runsyazses-cli.exe --cli packs probefor each, using a hand-written mount in a test entry point. - Record and decide:
- whether appending to
sys.pathimports pack modules in the frozen app (expected yes, per PyInstaller's bootstrap order); - stdlib gaps hit by each pack's imports, and whether the
stdlib/supplement is needed; importlib.metadatalookups against base distributions;- pruned submodules of base packages (
onnxruntime,huggingface_hub,numpy); - the DLL-basename collision report for
sherpa-onnx-coreagainst the base'sonnxruntime; - Windows pack sizes, which replace the Linux figures in ADR-028's table.
Acceptance:
- A written result, recorded in the ADR's Revision section, with the run id.
- A decided list of permitted base changes: metadata-only and/or
collect_submodulesfor named base distributions, each with its measured size cost. - If the collision is real, a decision for
diarization: a shared ONNX Runtime, an out-of-process route, or deferral.
Phase 1: mechanism + parakeet and diarization packs, Inno .exe only¶
Build:
scripts/build-packs.pyandpacks.tomlwith the two packs.build-windows.ymlsteps between PyInstaller and Inno (ADR-028 §7, steps 1–5).- Provenance attestation with
subject-path: dist/packs/*, and the…-packs.intoto.jsonlasset. checksums.yml: glob gains*.yazpackand*-packs.json, and the "prove" regex gains them too.scripts/verify-release-assets.pyARTIFACT_SUFFIXESgains.yazpack.release-complete.ymlchecks that every pack in the published index is attached and listed inSHA256SUMS.txt. The list is read from the index, not written by hand.- The runtime hook in
packaging/windows/yazses.spec.
Runtime:
- Every pure module,
packs/fetch.py,install.py,mount.pyandprobe.py, the WindowsPackRuntime,PackageProfile.can_add_packs(true forwindows-exeonly), andPACK_INSTALL_METHODS = {"windows-installer"}. winget rides with it (ADR-027: it cannot be told apart). - Surfaces:
- CLI:
yazses packs,install,remove,verify,path,probe,clean, plus thefeatures enableprompt with size,--yes, Ctrl-C cancel and the--no-installmessage. - Tray: Download & enable, progress balloons, Cancel menu item, and the post-update re-download notification.
- Settings: per-feature button, progress bar, Cancel, and the Add-ons section with location, size, Open folder and Remove unused.
[UninstallDelete]for{localappdata}\yazses\packsininstaller.iss.
Egress:
- The ADR-019 row and Class A recount.
- The fourth scan (
FETCH_VIA), the user-initiated closure test, and the captured-Requesttest. docs/privacy-statement.mdsentence;tests/test_docs_network_claims_are_complete.pyextended.
Docs:
CHANGELOG.md[Unreleased].- Regenerated
docs/cli-reference.mdanddocs/command-index.md(make docs,make man). - A user page
docs/addons.md: what an add-on is, where it lives, how to remove it, and manual download. docs/install-cost.mdrows for Windows.design/architecture.md(new module and the new invariant: "no stale pack is ever loaded").
Acceptance criteria:
- On a release build, the base-manifest invariance check passes. The
_internalDLL set and the distribution set equal those of the previous release, apart from the Phase 0 permitted changes. SHA256SUMS.txtlists both packs and the index.gh attestation verifysucceeds for each pack with--signer-workflow MSKazemi/yazses/.github/workflows/build-windows.yml.- The frozen probe passes on the runner for both packs, or the job fails and the release gets no packs. It never publishes a pack that did not probe.
- Enabling
stt-parakeetin a per-user.exeinstall shows exact sizes. A click downloads, verifies, probes and installs the pack, then writes config, then restarts the daemon. Parakeet then transcribes.yazses doctorshows the pack asok. - A corrupted archive (one byte flipped in the cache), a wrong
SHA256SUMS.txtline, a pack from another build id, a truncated download, a cancelled download and a full disk each end with nothing installed, config unchanged and an actionable message. - After a one-click update (ADR-027) to a build with a new id, the old pack is not loaded. The feature reads waiting for add-on, and one click re-downloads it. After the new build's first
started-ok, the old build's tree is removed. If the update fails and the old version relaunches, its packs still load. - Deleting one DLL from an installed pack shows damaged — reinstall at the next import attempt. The tree is in
quarantine\with a reason, and Reinstall restores the feature. - With
YAZSES_NO_PACKS=1, YazSes starts and every pack-backed feature reads waiting for add-on. - Every pure-module test runs in the ordinary offline suite.
ruffandpytestare green andmypy srcreports no issues.
Phase 2: the remaining qualifying packs, Chocolatey, Scoop, offline install¶
packs.tomladds whichever ofgaze,tts,prosody,denoise,moonshineandchinesepass, subject to the owner's answer on bundling small extras and the licence gate (ADR-028, open questions 1 and 7).PACK_INSTALL_METHODSaddschocoandscooponce their checklist items pass.yazses packs install --from <file>(if the owner accepts open question 5): the file name must equal the expected asset name and its SHA-256 must equal the embedded index entry. No network.cancelandReleaseAssetfrom the ADR-028 §4a follow-up refactor, if it has landed. Otherwise Phase 1's coarse cancel (raising fromprogress) is documented as a known limit.
Acceptance:
- Each new pack meets Phase 1 criteria 1–7.
gaze's pack loads OpenCV and MediaPipe fromnative_dirs, and the camera matrix,doctorandfeatures enableagree on every row (EYE-PERM-001).
Phase 3: large packs and other channels (each needs an owner decision)¶
voiceprint(torch), if open question 2 says yes.- MSIX, after the spike in ADR-028 §8 on
AddDllDirectoryinside a packaged process and DLL loading from virtualised AppData, and after the Store-policy question. - The macOS
.app, in an ADR-028 addendum.
Acceptance: whatever the deciding addendum defines. None of it is implied by this spec.
Contracts¶
Index (YazSes-<v>-windows-<arch>-packs.json, also _internal/yazses-packs-index.json)¶
{
"format": 1,
"app_version": "2.44.0",
"base_build_id": "sha256:…",
"python": {"cache_tag": "cpython-312", "version": "3.12.9", "abi": "cp312"},
"platform": "win-amd64",
"arch": "x64",
"packs": [
{"name": "parakeet", "asset": "YazSes-2.44.0-windows-x64-pack-parakeet.yazpack",
"sha256": "…", "size": 312345, "unpacked_bytes": 1234567, "files": 210,
"features": ["stt-parakeet"]}
]
}
Rules:
- Unknown
format: refuse every pack, and say "this add-on index needs a newer YazSes". - Unknown keys are ignored. Missing required keys: refuse.
assetmust equalYazSes-<app_version>-<platform os>-<arch>-pack-<name>.yazpack, re-derived and compared rather than trusted.
features enable in a pack-capable install¶
- The unwired and experimental refusals stay as they are.
- If
_missing_feature_deps(feat)is empty, the existing path runs. - Otherwise, look up the catalogue:
- no pack for this build: the existing
fixed_payload_reasonmessage, reworded for the reason (no-windows-wheel,deferred); - pack stale or absent: price, confirm,
install.py; on success write config and restart; on failure write nothing.
Error semantics¶
Every user-facing entry point returns a result object, as ADR-027 does. Exceptions are for programming errors only. Every message names the next step.
Tests¶
Offline, in tests/:
test_packs_manifest.py: valid, unknown format, missing keys, wrong types, the asset name re-derivation.test_packs_abi.py: every field mismatch yields its own reason; equal build id with a different Python is still refused (defence in depth);staleversusrefused.test_packs_archive.py:../x,C:\x,\\server\share\x, an absolute path, a symlink attribute, a name over budget, count or size different from the manifest, ratio over the cap, a duplicate name, case-insensitive duplicates (A.pyanda.py).test_packs_layout.py: path budget at the worst-case%LOCALAPPDATA%length; build-dir prefix collision.test_packs_state.py: every state in ADR-028 §5 from fixtures; waiting for add-on never reads ason.test_packs_policy.py: disk-space math, deadline, GC and rollback, quarantine versus stale.test_packs_catalog.py: every wired_FEATURE_DEPSslug has apacks.tomldecision; everypackextra exists inpyproject.toml; nopackrow for an unwired slug. It fails on an empty catalogue (guard the guard).test_packs_install.py: the transaction with fakes. A failure injected at each step leaves no staging dir, no download and no config write. Single-flight lock, cancel.test_packs_mount.py: with a fakePackRuntime: appended (never prepended) order, handles retained, elevated means no mount,YAZSES_NO_PACKSmeans no mount, a missingCOMPLETEmeans no mount, and an exception inside the plan never propagates out ofmount_at_startup.test_packs_egress.py: the capturedRequestobjects (URLs and exact headers). Noapi.github.com.test_egress_inventory.py: theFETCH_VIAscan (equality in both directions, fails on an empty scan) and the closure test (daemon, watcher and runtime hook cannot reachpacks.fetchorpacks.install).test_build_packs.py: collision checks on synthetic--targettrees (module, DLL with equal or different bytes, distribution overlap),.pthhandling, deterministic zip (two runs give equal bytes), path budget.test_checksums_cover_packs.py:checksums.yml's glob and prove regex, andverify-release-assets.py, cover.yazpackand-packs.json. Derived from the asset namesbuild-packs.pyproduces, not from a second hand-written list.test_frozen_bundles_ship_the_gui.py/test_windows_build_architectures.py: extended so the runtime hook is in the spec andbuild-windows.ps1still syncs only--extra desktop.
On the Windows CI runner: the frozen probe per pack and the base-manifest invariance check, both in build-windows.yml, failing the job.
Manual Windows checklist (definition of done for the first release that carries packs):
- Per-user
.exe: enable Parakeet from the tray. Size shown → download → balloon → restart → dictation uses Parakeet. Then repeat from Settings and fromyazses features enable stt-parakeet. - Cancel at about 50 %: no
.part, no config change. - Unplug the network after confirming: an actionable message, and nothing changed.
- Flip a byte in
downloads\*.partduring the download: mismatch, deleted, nothing installed. - Delete one DLL from the installed diarization pack: damaged at the next use, the tree is quarantined, Reinstall works.
- One-click update (ADR-027) to the next version: the old pack is not loaded, one click re-downloads it, and the old tree is gone after the first good start. Force a failed update: the old version relaunches with its packs.
- A user name with a space, an apostrophe and a non-ASCII letter: every path survives.
- Microsoft Defender with real-time protection: note first-load time and any quarantine.
- Smart App Control (evaluation and enforce): note whether pack DLLs are blocked. Record it in the ADR either way.
- All-users install: packs install per user and load unelevated. Run
yazseselevated: no pack is mounted and the features say why. YAZSES_NO_PACKS=1: starts clean.- Uninstall (per-user):
packs\removed; config and models follow the current policy. - winget-installed copy: identical behaviour to item 1.
Live smoke evidence¶
To be filled in by the Phase 0 spike and the Phase 1 checklist: run ids, Windows build numbers and measured sizes.