Plan: Application catalogue entries (install_type: application)
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Phases 1-5 DONE and verified end to end on a cluster. Phases 6 and 7 open; one confirmation owed by UIS
Goal: Support a third entry kind in template-registry.json — an application, whose install
definition is an OCI artifact published beside the application's own image rather than living in this
repository — so uis template install atlas works.
Last Updated: 2026-09-09
Investigation: INVESTIGATE-application-catalogue-entries.md — decisions, rejected options, and the security reasoning. Read it before implementing; this file is the task list.
Authorization: Terje, 2026-09-09, at the pane: "i order you all to not wait for me regarding
getting atlas working." Under the fleet protocol's D10 seat that is authoritative for the scope it
states, and it replaces the plan-approval gate in the repository's CLAUDE.md for this
work only. It does not reach credentials (protocol §7 — the cross-org PAT for private
artifacts remains Terje's, and is not needed: atlas's artifact is public), production writes, public
exposure, or spending. It does not waive this repo's pre-push pipeline, which is a quality gate.
Cross-repo: UIS (helpers-no/urbalurba-infrastructure, tor-agent) is the consumer and has
already shipped its half — 1.6.25 list filter, 1.6.26 artifact/entry id identity check,
install_type: application legal inside an artifact, and info printing artifact/tag/pin/visibility.
Agreed on urb-agents #478, #479, #482.
🔴 The finding that shapes this plan: the branching is exclusion-style
Every consumer of the kind discriminator is written as "exclude the kinds I know, everything else is
an app", not as an exhaustive match. scripts/lib/build-architecture-mermaid.ts:191:
if (entry.install_type === 'overlay') return null;
if (entry.install_type === 'stack') return null;
// …falls through to the app path
So adding application to the two validator allowlists and nothing else does not produce an
error — it produces a complete, plausible-looking, wrong entry. An application would get app
architecture diagrams, an app expected-output block, an app templateRepoPath pointing at a
directory with no template in it, and a Files dropdown built from nothing. No validator objects, and
the page renders.
The investigation named four sites in one file. The real surface is twelve sites across five
files, listed below. That undercount is corrected here and was reported to tor-agent on #482.
Design rule for this plan — narrowed 2026-09-09
An earlier draft of this rule said "convert exclusion-style branches to exhaustive ones that fail loudly on an unknown kind", across all twelve sites. That is a refactor, and this plan is not one. Terje asked whether this was a rewrite or a fix; it should be a fix, and the rule is now:
Add
applicationas an explicit case at each site that would otherwise mis-handle it. Do not restructure branches that are working.
Why the narrower rule is the right one:
- The
app/stack/overlaypaths are correct today and covered by 92 passing tests. Rewriting them to exhaustive switches risks those paths to protect a kind that does not exist yet. - The severity that justified the wider rule is gone: a fall-through produces a wrong page, never
a wrong install (measured by
tor-agent, below). A website bug does not earn a cross-cutting refactor of five files. - An explicit
applicationcase at each site gets the same protection where it matters, because every site is one I am touching anyway to add the kind.
Scope of the actual change: roughly fifteen lines of edits across five files (widen two type
unions, one derivation case, five early-returns/guards, two allowlist entries), plus the one genuinely
new piece — source block validation, which is new behaviour rather than changed behaviour.
If exhaustive matching is still wanted, it is a separate cleanup with its own plan, worth doing when there is a second reason to touch those files — not smuggled in behind a feature.
Blast radius: the website, not an install — corrected 2026-09-09
An earlier draft of this line justified the rule by "the entry points at an artifact applied as
database owner". That conflated two different paths and overstated this one. tor-agent
measured what UIS actually reads from a registry entry (#482):
templateKind,source.{artifact,tag,digest},visibility,category, and the display fields forinfo. Nothing else.
Not templateRepoPath, not files, not filesMdx, not folder, not params, not provides —
install branches on templateKind before it would look at them. So a fall-through produces a wrong
page, never a wrong install. It cannot make UIS fetch the wrong artifact, apply the wrong SQL, or
record the wrong thing.
The database-owner risk is real but belongs to a different field: source.digest, which is why it
is committed rather than resolved (Decision 2). Keep the two separate — the design rule above stands
on "a plausible wrong page that no validator objects to survives review", which is enough, and does
not need borrowed severity.
✅ Toolchain: bun runs the whole pipeline (resolved 2026-09-09)
docker, node, npm, npx and yq are all absent from this host, and there is no other build
host in the fleet roster — so the devcontainer path in DEVCONTAINER.md is
genuinely unavailable, not merely unused. ops-dev confirmed this independently (urb-agents #484).
But bun 1.4.0 is installed at /opt/homebrew/bin/bun, and it runs every stage. Measured, whole
pipeline in CI order, PATH restricted to /opt/homebrew/bin:/usr/bin:/bin so no node was
reachable:
| stage | command | result |
|---|---|---|
| 1 | bash scripts/validate-metadata.sh | rc=0 — 6 categories, 10 templates |
| 2 | bun run scripts/generate-registry.ts | rc=0 — 6 categories, 10 templates |
| 3 | bash scripts/generate-docs-markdown.sh --force | rc=0 — 10 detail pages |
| 4 | bash scripts/generate-plan-indexes.sh | rc=0 |
| 5 | bash scripts/validate-docs.sh | rc=0 — 0 errors, 17 pre-existing warnings |
| 6 | bun run build (in website/) | rc=0 — [SUCCESS] Generated static files |
So the pre-push checklist can be satisfied on this host, substituting bun run for npx tsx and
npm run. bun install in website/ populates node_modules (1346 packages, ~7s), which is
gitignored.
One fix this required, and it is in this plan's spirit
scripts/validate-metadata.sh hardcoded node in two helpers that both send stderr to /dev/null.
With no node present it reported invalid YAML syntax on all 15 metadata files — every one of
which parses fine. A missing interpreter presented as fifteen syntax errors.
-
scripts/validate-metadata.sh— resolve a runtime once (node, elsebun), fail loudly with the actual cause if neither is present, and checkjs-yamlis installed - Falsified both ways: with only
bunonPATHit passes (rc=0); with neithernodenorbunit exits rc=1 printing "no JavaScript runtime on PATH" instead of fifteen fake syntax errors
This is the same failure shape as the fall-through this plan exists to prevent — a wrong answer that looks like a real one — so it is fixed here rather than filed.
⚠️ bun is not what CI uses. CI installs node 20 and runs npm ci / npx tsx / npm run build. The runtime detection above keeps node first, so CI behaviour is unchanged, but "passes
locally under bun" is not identical to "passes in CI" — a bun/node divergence would show up only in
CI. Worth recording in the project doc alongside the pre-push checklist.
Phase 1 — the uis-applications/ tree ✅ DONE and verified end to end
-
uis-applications/template-categories.yaml—context: uis, categoryAPPLICATION, order 1 -
website/static/img/categories/applications-logo.svg— matches the existing category-logo style (512×512, circle + two letters); XML-parse checked -
uis-applications/README.md— the stub contract, the twotemplate-info.yamlfiles trap, and whysource.digestis committed rather than resolved - Verified:
template-categories.yamlparses (ruby -ryaml),context: uis, one category id
No generator change, and no application directory yet — atlas has not published its artifact. The
tree is inert until phase 3.
Now verified (the expectation below was measured once bun was found, and held):
- The generator accepts a tree with a category and no templates: it logs
uis-applications/template-categories.yamland emits6 categories, 10 templates. The registry diff against the previous file is exactly the addedAPPLICATIONcategory plus thegeneratedtimestamp — nothing else moved. validate-metadata.shaccepts it:6 categories, 10 templates, all valid.generate-docs-markdown.shemits pages for the five categories that have templates and none forAPPLICATION. That is correct, not a gap: an empty category gets no page, and bothvalidate-docs.sh(0 errors, all internal links valid) and the Docusaurus build agree.- The site builds:
[SUCCESS] Generated static files. APPLICATIONwas givenorder: 1rather than0so it does not tie withDEMO— with a tie the relative order of twocontext: uiscategories depended on filesystem enumeration order.
Phase 2 — validation, failing tests first
-
scripts/test/— port the six falsification cases above into unit tests. They were run against the real generator by mutating the live entry and restoring it, which proves the rules but does not keep proving them. The one piece of phase 2 still outstanding. -
scripts/validate-metadata.sh—applicationadded to theinstall_typeallowlist. Thesourceblock is not re-validated here:generate-registry.tsis the authority and runs in the same CI job, so a second implementation would be two things to keep in agreement -
scripts/generate-registry.ts—applicationin the allowlist, plusvalidateSource(): artifact allowlist, mutable-tag refusal,sha256:+64hex digest,visibilityenum. Offline only -
source:is refused on any non-applicationkind - Falsified — six bad inputs, each rejected, good entry still generates:
artifact off allowlist ·
tag: latest· digest too short ·md5:prefix · badvisibility· digest absent
Allowlist defaults (UIS's, from #479): ghcr.io/helpers-no/*, ghcr.io/terchris/*. Keep the
allowlist in one place — scripts/lib/repo-constants.ts already centralises repo URLs and is the
right home.
Offline only. No network call in the generator: the digest is committed, never resolved. See the
investigation's Decision 2 and uis-applications/README.md.
Phase 3 — the discriminator: add a case at each site, restructure nothing
-
scripts/generate-registry.ts:172—type TemplateKind = 'app' | 'stack' | 'application' -
scripts/generate-registry.ts:685— extend the existing ternary with the one new case. Theinstall_typeallowlist invalidateTemplatealready rejects anything unknown before this line runs, so a mapping table plus afail()here would be a second guard on an impossible value:const templateKind: TemplateKind =raw.install_type === 'stack' ? 'stack': raw.install_type === 'application' ? 'application': 'app'; -
:689—serviceList:undefinedforapplication(services live in the artifact, not here) -
:740—templateRepoPath: null,files: [],filesMdx: nullfor an application -
:746— thefilesMdx === nullguard skipsapplicationrather than failing -
scripts/lib/build-architecture-mermaid.ts:67— widened theinstall_typeunion -
One case at the dispatcher, not four in the leaf builders.
buildArchitectureModel(:583) is the only way intobuildLocalDevFlowchart/SequenceandbuildDeployFlowchart/Sequence— there are four such pairs, not the three I first counted — soapplicationreturns{sections: []}there and never reaches any of them. Four edits collapsed to one, and the leaf builders were left untouched -
scripts/lib/build-expected-output.ts:48— returnsnullforapplication -
website/src/components/TemplateEnvironment/index.tsx:82— widened the exportedTemplateKind -
:145— heading treatsapplicationas "Provided to your cluster" -
:291—showInstallleft asstack-only, deliberately: an application has noexpectedOutputBlock, so the condition is already false and widening it would render an empty block
Already safe by exclusion, verify and leave alone: generate-registry.ts:344 and :698 both test
=== 'app', so an application is correctly excluded from the deployment-manifest read.
scripts/generate-docs-markdown.sh:244 passes templateKind through as JSON and needs no change —
confirm with a generated entry.
Phase 4 — the documentation page ✅ DONE
- The page renders from the entry's
readme:with no file tree, no diagrams and no expected-output block — every one of those isnull/empty in the entry, so the emitters suppress the sections rather than rendering empty ones - The Files dropdown is omitted, not empty (
filesMdx: null) -
sourceandvisibilityare stated in the atlas README, so the page says what an install would fetch. Not yet rendered from the entry by a component — the prose carries it. Worth revisiting when there is a second application and the duplication starts to cost -
validate-docs.sh: 0 errors; the site builds
⚠️ validate-docs.sh passed two broken links that the build caught
Both times, validate-docs.sh reported "All internal links valid" and bun run build then failed:
PLAN-application-catalogue.md→../../../../../CLAUDE.md, which escapes the docs rootREADME-atlas.md→../README.md, a repository file that does not exist as a docs page once the README is rendered at/docs/templates/application/atlas
So the validator does not check links that leave the docs tree, and the build is the real gate —
exactly why the pre-push checklist names npm run build and not just the validators. A README that
lives in the repo and is rendered on the site cannot use relative links to repository files. Worth
its own small fix in validate-docs.sh; filed as a follow-up rather than done here, since this plan
is meant to be a fix and not a rewrite.
Phase 5 — hand the generated registry to tor-agent
Superseded by stronger evidence — the fixture route was never needed. The plan was to stage a
fixture registry and have tor-agent install from it via file://. What actually happened is better:
- Checked against UIS's shipped 1.6.25
listfilter, before publishing anything -
tor-agentranlist,infoandinstall --dry-runagainst the published registry — no override, no local file — and confirmed the entry resolves to the same planimachad executed from a fixture (#486). The published route and the staged route are indistinguishable to the installer -
imacinstalled and removed it on a cluster beside a live tenant,EXIT=0both ways (#487) -
imacthen did a full wipe and a novice install from the published catalogue: install works, fourfirst_datajobs run clean, 10.8 min, 2,906,194 rows, 47 raw / 64 marts, 13api_v1views,brreg_enheter122 rows, meta endpoints 200 (#520)
Keep the file:// route documented anyway, for imac's reason rather than the original one: a
fixture that has actually been installed is evidence in a way a field table is not, and it is the
safer way to test a change before publishing it.
⚠️ The fixture must not be committed to a published tree. UIS reads
https://raw.githubusercontent.com/helpers-no/dev-templates/main/website/src/data/template-registry.json
with a one-hour cache and no version pin, so a fake atlas entry committed to main would publish a
bogus pointer to every UIS installation. Keep fixtures under scripts/test/.
Phase 6 — digest provenance check: answered, and it is one HTTPS GET
atlas emits the digest as a release asset (urb-agents #483, closed; relayed on #479). Stable,
unauthenticated URL per tag:
https://github.com/terchris/atlas/releases/download/<tag>/uis-artifact.json
{ "id", "tag", "artifact", "digest", "image", "commit", "published_at" }
tor-agent re-verified it independently rather than trusting atlas's build log: the digest recorded
in the asset equals GHCR's docker-content-digest. So the preferred option in Decision 2 is real —
no oras in either repository, no tag resolution, and this pipeline stays hermetic.
- A scheduled job that, per application entry, GETs
uis-artifact.jsonfor the committed tag and asserts itsdigestequals the committedsource.digest; alarm only, never a gate — UIS pulls by digest, so drift cannot change what runs, and blocking unrelated documentation deploys on a GitHub hiccup buys no integrity (agreed withtor-agent, #479) - Same job can check
links[]with a HEAD request — see the follow-up below; both are network-dependent staleness checks and belong in one alarm rather than two
⚠️ Treat a missing asset as "not adoptable yet", not as an error. v20260909-4b11f3f — the
artifact imac first tested — predates the release asset and has none. Every publish from 853c696
onward carries one. A generator or job that treats absence as fatal would fail on an application's
older tags (tor-agent, #479).
Answered questions
Diagrams — omit. Decided (tor-agent, #482). Not an assumption any more. A fifth archetype
invented blind against one example becomes a shape we then have to keep; there is nothing an
application's page needs a diagram for that its abstract cannot say. The diagram that would be
useful is platform-level (uis provisions / ArgoCD deploys, seam at uis configure), it belongs in
the UIS docs, and it already exists there. Revisit after the second and third applications, when
there is something to generalise from.
Entry version — do not validate it; it may lag. Decided (tor-agent, #482), and it corrected
my premise. The fields UIS reads from an artifact's template-info.yaml are exactly id,
install_type, kind, readme, service, params, provides, exports, requires — version
is not among them, and atlas's published definition carries no version: line at all. So "the
artifact carries its own version" was wrong, and there is nothing to disagree with.
The asymmetry with id is therefore principled rather than incidental:
id | version | |
|---|---|---|
| what reads it | applications.yaml, requires:, remove — the record key | uis template info, to print |
| a disagreement causes | an application recorded under a name its definition never claimed | a human reads a stale number |
| the pin | not id | not version — the pin is the digest |
- Keep requiring
versioninvalidateTemplate— already required, and it must stay.uis template infoprints\(.version)unguarded, so an entry without it rendersVersion: null. Recorded as a do-not-remove rather than as work. - Consider deriving the display version from
source.tagat generation time (tor-agent's suggestion, take-it-or-leave-it). Then nothing can disagree, no new validation is needed, and the number a human sees is the thing that was actually published. Decide in phase 3.
When to hold a pin bump
A pin bump normally needs no round-trip: the digest is verified from the release asset and GHCR, and publishing is a reviewed diff. Hold it in exactly one case — when the bump moves a container image that no cluster has loaded.
That condition is narrow and checkable before asking: compare the artifact's image:/tag: between
the current and proposed pins, and ask whether anyone has run it. A bump that moves only entry text
does not earn a round-trip.
The asymmetry that justifies it (agreed with imac, urb-agents #517): bad advice in an entry is
recoverable by the next bump; an image that fails to import is a broken install for whoever installs
next, and a code location that fails to import takes the whole location down. First applied to
v20260910-d7fa93c — cost one redeploy and about forty minutes, and it turned up that the new job
covers raw/brreg_enheter, which was the reason the previous pin's first_data was unachievable.
imac's method is the part to reuse: ask the product, not the pod. A code location whose module
fails to import sits in a Running pod while the location is in error, so loadStatus = LOADED and
the job list are the evidence; pod phase is not readiness.
Verifying a digest before pinning
Three independent sources, all at authoring time — never in the build:
# 1. the release asset (unauthenticated)
curl -sL https://github.com/terchris/atlas/releases/download/<tag>/uis-artifact.json
# 2. GHCR's own answer for that tag
TOK=$(curl -s "https://ghcr.io/token?scope=repository%3A<owner>%2F<image>%3Apull&service=ghcr.io" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOK" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
https://ghcr.io/v2/<owner>/<image>/manifests/<tag> | grep -i docker-content-digest
# 3. the artifact's own template-info.yaml, pulled at the digest and decoded --
# confirms kind/id and that its own tag matches the pin
Phase 7 — generate the operational section from the artifact (authoring time)
Why this is now the priority rather than a nicety. imac's novice run from the published
catalogue (urb-agents #520) measured that uis template info atlas renders none of the
artifact's operational block — 0 matches for operational|first_data|seed_sources|11 minutes| deploys in its complete output. So the four-job first_data list that atlas shipped, and that this
repository held a round-trip to pin, reaches nobody through the CLI.
The catalogue page is currently the only surface carrying it, and it carries it by hand:
README-atlas.md now duplicates operational.first_data with a ⚠️ naming the pin it was copied from
and the fact it can go stale. That is a deliberate stopgap, not the design.
- At authoring time — not build time — read
operational:from the artifact at the pinned digest and generate the section, so a pin bump produces one diff containing both the new pin and the operational text it implies - Treat a missing
operational:as "not adoptable yet", never as an error — see the contract below. If this catalogue ever requires it for listing, that is legitimate, but it must be stated as the catalogue's rule, not the platform's, so a tenant reading a refusal knows who is refusing (tor-agent, #540) - Assert
first_data.jobsis a sequence. Order is meaningful — both UIS renderers join it with->— and a tenant who writes a scalar there gets a single job name and no complaint from anything. This is the one shape worth checking at authoring time - Remove the hand-maintained duplication from
README-atlas.mdonce generated. Not urgent — a labelled copy that a novice can read today beats an empty section
The operational: contract, as measured by tor-agent (#540)
Supported, documented, optional. Nothing in UIS requires it: _validate_template_info does not
mention it, no path returns non-zero on its absence, and top-level keys are not allowlisted at all —
unknown ones are ignored by design. (config: keys are allowlisted, because a typo there silently
drops a configure flag.)
These twelve paths and no others:
operational.automation
operational.timezone
operational.install.{deploys, takes, note}
operational.first_data.{why, how, jobs, takes}
operational.cadence[].{cron, what}
operational.external_services
operational.unscheduled
⚠️ UIS reads them and validates nothing — no required subfields, no type checks beyond "a list joins, a scalar prints".
Why it is deliberately not required, and the argument is worth keeping: a mandatory field whose
content nothing can verify produces filler, and a wrong automation: line is worse than a missing
one — it is the line an operator acts on. An application that deploys one service and runs nothing
has nothing useful to say here and should not be made to say it.
🔴 The same critique applies to this repository's own validateSource()
atlas's formulation (#538), via tor-agent:
a guard that checks a field is well-formed does not check that it is true
validateSource() is exactly a well-formedness guard. It proves source.digest matches
sha256: + 64 hex; it proves nothing about whether that digest is the artifact the application
published. A digest for a different artifact, or a stale one, passes every check this repository
has. What actually establishes truth is the three-source comparison done by hand at authoring time —
which is discipline, not enforcement, and discipline is what fails on the tenth bump at 17:00.
- Phase 7 should therefore also fetch the release asset for the pinned tag and assert its
digestequals the committedsource.digest, failing the bump if they disagree. Same authoring-time moment, one more HTTPS GET, and it converts today's habit into a check
UIS's half is shipped — and I have not verified it
tor-agent reports uis template info rendering operational in 1.6.44 and the install
completion summary in 1.6.45, which between them should close the dead end imac measured.
⚠️ Taken on their word. There is no UIS on this host, so this repository cannot test either
surface. Given that this entire phase exists because a field was verified as correct and never
checked for being displayed, "tor-agent says it renders" is not the same class of evidence as
imac's 0-match grep that started it. Worth one confirming grep on a real install before anyone
records the dead end as closed.
Authoring time is the right moment because the moment the two could diverge is the bump itself. Build-time generation would need a network call in a pipeline deliberately kept hermetic; a hand-maintained copy can be fixed instantly but drifts silently. atlas named the real cost of generating (a generated section is only as fresh as the pin) and it is the correct trade only if generation happens where the pin changes.
The failure shape this belongs to
imac named it, and it is the third instance this week: a producer publishes a field, a consumer
never reads it, and both sides pass their own checks. atlas's gates prove first_data covers every
automated source; this repository's verification proves the blob decodes and the digest matches;
neither asks whether anything displays it. The other two: atlas's measured: field truncated by a
YAML comment with all four gates green, and a lint that resolved a path one directory short, found
nothing, and reported ALL TESTS PASSED.
Division of surfaces, to be agreed with tor-agent rather than assumed — the artifact currently has
a contract with nobody:
| surface | owner | should show |
|---|---|---|
uis template info <id> | UIS | operational — will-install, first-data jobs, takes |
| install completion summary | UIS | the same three lines, beside the endpoint it already prints |
| catalogue page / README | this repo | the same content, generated from the artifact at the pin |
Follow-ups this work exposed
Each is a real gap found while doing the above, kept out of scope deliberately so this stayed a fix:
validate-docs.shmisses links that leave the docs tree. It reported "All internal links valid" for two links the Docusaurus build then rejected —../../../../../CLAUDE.mdfrom a plan page, and../README.mdfrom a README that is rendered as a docs page. The build is the real gate.- Nothing checks external links at all. One shipped wrong today (a 404 source URL). Decision:
this does not go in
validate-metadata.sh— the pre-push pipeline stays offline and hermetic, for the same reason the digest is not resolved at build time. A dead link is staleness, so it wants an alarm; fold it into the phase 6 job. - Port the six
sourcefalsification cases into unit tests. They were proven by mutating the live entry and restoring it, which proves the rules once rather than continuously. TemplateHeadermulti-paragraph abstract — done as part of publishing atlas's prose, but it is the sort of thing that only surfaced because someone's abstract had four paragraphs. Worth a test.
Open questions
- Private artifacts — deferred; needs Terje for credentials, and atlas does not need it: its
artifact is
ghcr.io/terchris/*and public. - Rendering
sourcefrom the entry rather than from prose. The atlas README states the artifact, tag and pin in text. A component reading them from the entry would not drift. Worth it at the second application, not the first.
Sequencing
Phases 1-4 need no artifact to exist. Phase 5 needs atlas published.
Phase 1 is done and verified. The toolchain blocker is cleared: phases 2-4 are now ordinary work — write the failing test, make it pass, run the full pipeline before pushing — and no longer wait on anything. Phase 6 waits only on urb-agents #483.