Phase log 44: Phase 31: the provider catalogue (2026-10-08)
Goal
Phase 31 (AGENTS.md: "envvault site as the public enrichment source"; design in the session records/23-2026-08-31-review-01-defect-fixes-the-key-pools-sidebar.md under its oldest number, Phase 36). Make envv enrich's issuer-prefix table updatable without a release. Design constraints taken from phase log 23: static/Pages, fetched WHOLE never per provider, bundled snapshot stays, signed with a key pinned in the binary, no secrets/user data, envv catalogue update|show|diff, same over IPC (invariant 10). The phase log 23 blocker (domain / name collision with envvault.dev) was closed as no-rename in Phase 20; the default URL is the repo's own Pages site, NOT envvault.dev.
What was built (every file and why)
vault-core/src/catalogue.rs(new, owns all rules):Provider,Catalogue, wire form{payload: <JSON string>, signature: <b64>}so the signature covers exact bytes.verify(Ed25519 against a key arg),sign(seed),store(verify, refuse rollback bygenerated_at, atomic write 0600),load_cached(re-verifies every call),cache_path(ENVV_CATALOGUE_FILEoverride, elsedirs::data_dir()/io.envvault/catalogue.json, same dir the app/CLI already share).validateruns AFTER the signature: schema==1, fixed-widthgenerated_at, 1..20000 providers, prefix 3..64 graphic ASCII, issuer/icon length caps,secret_typemust exist insecret_types::find, all URLs https. 4 unit tests.vault-core/Cargo.toml:ed25519-dalek = "2"(already in Cargo.lock via ssh-key 0.6.7, no new crate downloaded; Cargo.lock got the dependency edge).vault-core/src/lib.rs:pub mod catalogue;.envv-cli/src/catalogue_cmd.rs(new):CatalogueCmd=update [--url|--file],show,diff,export(compiled table as providers JSON),sign <in> --key-file -o. https only,build_public_client(the one allowed CA-validated client, per the guard test in tests/agent.rs), 4 MiB cap. Public reference data, so stdout is allowed (unlike emit).envv-cli/src/main.rs:Commands::Catalogue, handled beforerun()likeDescribeso it never asks for a password; added to the no-op arm indispatch.envv-cli/src/lib.rs,envv-cli/Cargo.toml(hex = "=0.4.3", same pin as vault-core).envv-cli/src/enrich.rs: new ownedHit,catalogue()(OnceLock, loaded once per process),lookup()= longest matching prefix across cached catalogue and compiledSIGNATURES, tie to the catalogue;plan_entryuses it;bundled_providers()public for export/diff.AXIS_SIGNATURES(acts_as/exposure) is still compiled-only and matched by prefix equality, so a catalogue-only prefix gets type/icon/url/env/tag but no axes. Compiled order-sensitivity (sk-ant-beforesk-) is unchanged for the compiled half.src-tauri/src/lib.rs:catalogue_status,catalogue_update(url)(https only, 20 s, 4 MiB, thencatalogue::store), registered ingenerate_handler!.catalogue/providers.json(new, 331 lines): seed source, produced byenvv catalogue exportfrom the compiled table (NOT retyped). This is the file maintainers edit..github/workflows/docs.yml: daily cron + a "Sign the provider catalogue" step writingsite/catalogue/catalogue.json; skips cleanly when theCATALOGUE_SIGNING_KEYsecret is absent.book/src/adr/0139-...md+SUMMARY.mdline; AGENTS.md phase row 31 and "next handoff" updated;the session records/INDEX.md.
Decisions and rejected alternatives
- Ed25519 over minisign/cosign: dalek already in the lock; ~40 lines; no external tool in the client.
- Payload-as-string: rejected signing canonical JSON (re-serialisation drift is a classic verify bug).
- Re-verify the cache on every load: rejected "verify on download only" because a writable data dir would then be a trust root.
- Lookup picks longest prefix across both sets instead of "catalogue first": a catalogue
sk-must not shadow compiledsk-ant-. - Stamped daily rather than every 15 min (phase log 23 intention): content only changes on commit; a daily re-stamp keeps staleness visible without churn.
- Catalogue lives in the existing docs Pages deployment (Pages has one deployment; a second workflow would race it).
Verification
cargo test -p vault-core catalogue: 4 pass (tamper, wrong key, 1-char prefix and http URL refused at sign, rollback refused).cargo clippy --workspace --all-targets -- -D warnings: clean.cargo test --workspace: all result lines ok (no FAILED).- E2E with the REAL pinned key:
catalogue export, appendedzzt_live_,signwith the generated seed,update --file(49 providers cached, file mode 0600),show,diff(added: zzt_live_),enrichon a scratch vault proposed type/icon/api_url/tag from the catalogue-only prefix; with the cache absentenrichsaid nothing to enrich. A one-letter edit of the signed file: "signature does not match the pinned key", exit 10. - Fault injection NOT done on the rollback guard by reverting it; the unit test exists and passes, but I did not break
store_withto watch it fail. npm test/ ui-lab not run (no TS touched).
Not done / traps
- THE SIGNING KEY: the keypair was generated in this session's scratchpad (
.../scratchpad/catalogue-signing.pemandcatalogue-signing.seed.hex), outside the repo. The user must move the 64-hex seed into the GitHub secretCATALOGUE_SIGNING_KEYand delete the scratch copies. Until then Pages publishes no catalogue and nothing breaks. If the user prefers their own key, replacePINNED_KEY_HEXand release. - Default URL
https://darthdemono.github.io/EnvVault/catalogue/catalogue.jsonis unverified against a live deployment (Pages enablement is still manual, Phase 24). - No UI: the IPC commands exist but no button or Settings row calls them (invariant 10 gap, record as such).
tests/ipc-contract.test.tsonly checks frontend->registered, so it does not notice. - Catalogue
docs_url/rotate_url/revoke_urlare carried and validated but nothing consumes them yet; axes (acts_as/exposure) are compiled-only. - The catalogue content is only the 48 compiled prefixes; "a large set of providers" needs real curation, with sources, outside this change.
timeis pinned =0.3.37 and used only for formattinggenerated_at.- Do not add a catalogue prefix shorter than 3 chars;
signrefuses it by design.
Addendum (same session): 31.1 and 31.2, and the remaining work as phases
Requested: write down what is left, map the UI button and the content to phases, do them. AGENTS.md now has rows 31.1, 31.2 (done) and 31.3 (planned).
- 31.1 UI:
index.htmlrow#s-catalogue-status+#s-catalogue-updateafter the Setup wizard row;wireCatalogueRow()insrc/ts/settings-panel.ts(assigned handlers, invariant 9;inTaurifalse -> button disabled with text pointing atenvv catalogue update).catalogue_updatenow takesOption<String>and defaults to the newvault_core::catalogue::DEFAULT_URL, whichenvv-clire-exports (one constant, was going to be three).tests/catalogue-row.test.ts: markup ids exist, browser branch disabled, IPC branch shows Rust's refusal verbatim (stub bridge installed beforevi.resetModules()import). a11y + ipc-contract suites still pass (65 tests across the three files). - 31.2 consumers:
Provider+Hitcarryacts_as,exposure,console_url;validaterejects unknown axis values and non-https console URL;plan_entryuses the catalogue's axes first, elseAXIS_SIGNATURES;console_urlproposed.bundled_providers()now fills axes fromAXIS_SIGNATURES, socatalogue exportcarries them andcatalogue/providers.jsonwas re-exported (not retyped). New unit testan_unknown_axis_value_is_refused. clippy clean,cargo test -p envv-cliall ok. - 31.3 content: NOT done on purpose. Writing issuer prefixes from memory would put unverified claims into a signed file. Needs web research with primary sources per issuer; rules are in the AGENTS.md row. The maintainer-only items (Pages, secret, URL check) are in the same row and in the main handoff above.
- Not verified: the UI row in a real Tauri window (jsdom only);
docs_urletc. still unused by any reader.