Process & Registry
Adopted here means the repo has the container-rebuild skill and backlog tooling this tier requires — not the same as “live” (see Family Overview's Status). Website and Rissbrook are both genuinely live with Adopted: No, purely for missing tooling. Login means the site has accounts at all. OIDC isn't shown separately — every login-enabled repo is a client of Amoleo Accounts' own OIDC provider now, not a distinguishing fact per repo (Accounts itself is the one exception, being the provider).
| Repo | Tier | Adopted | Login |
|---|---|---|---|
| Amoleo-Website | static | Yes | — |
| Amoleo-Rissbrook | static | Yes | — |
| Amoleo-Pets | transactional | Yes | Yes |
| Amoleo-Connections | transactional | Yes | Yes |
| Amoleo-Accounts | transactional | Yes | Yes |
| Amoleo-Todo | transactional | — | Yes |
| Amoleo-Editions | undecided | — | Yes |
| Amoleo-Upnext | transactional | — | Yes |
| Amoleo-Collections | transactional | — | Yes |
| Amoleo-Registry | transactional | — | Yes |
| Amoleo-Communications | transactional | — | Yes |
| Amoleo-Reviews | transactional | — | Yes |
| Amoleo-Images | transactional | Yes | Yes |
| Amoleo-Recommendations | undecided | — | Yes |
| Amoleo-Worldbook | transactional | — | Yes |
| Amoleo-Health | transactional | — | Yes |
The stack — which one a site gets, and what every repo carries
Five sites on five stacks would be five sets of problems. There are two stacks, and which one a site gets is decided once, by one question, before any code is written.
This document is the authority on why. tools/stack.json is the authority on which repo is which, and tools/check-stack.mjs is what notices when a repo stops being what it says it is.
0. Status — where each repo actually is
| Repo | Tier | Stack | Hosted | Login | Backlog | Container skill |
|---|---|---|---|---|---|---|
Amoleo-Website |
Static | Hand-written HTML/CSS/JS + a small npm install build for @valadamoleo/family-css (8 Sept 2026) |
Cloudflare Pages | No | Yes | n/a |
Amoleo-Rissbrook |
Static | Astro 7, static output, no adapter | Cloudflare Pages | No | Yes | n/a |
Amoleo-Pets |
Transactional | React + Vite / Express / MongoDB | Docker on the Proxmox LXC | Own table, local passwords | Yes | Yes |
Amoleo-Connections |
Transactional | React + Vite / Express / MongoDB | Docker on the Proxmox LXC | Own table, local passwords | Yes | Yes |
Amoleo-Accounts |
Transactional | React + Vite / Express / MongoDB | Docker on the Proxmox LXC | It is the login | Yes | Yes |
One gap left, recorded rather than hidden: Pets and Connections still run
their own password tables and haven't moved to Accounts OIDC, even though
Accounts itself is now built and live. check-stack prints that as
pending, and --strict prints it as the failure it will become. The backlog
column used to read Missing for all three of Website, Rissbrook and
Accounts — all three have since run /github-project-backlog (Website
Project #13, Rissbrook Project #14, Accounts Project #7), but the table and
tools/stack.json went stale after that setup landed and weren't corrected
until 3 Sept 2026, which is exactly the kind of drift this document exists to
catch.
Accounts' tier is decided:
Amoleo-Accounts/docs/Decisions.md, 10
puts it on the transactional tier, same shape as Pets and Connections, on the
family's own Docker host — chosen over handing identity to a managed provider
(Auth0, Clerk, a hosted Authentik/Keycloak/Zitadel). The container has since
been built: adopted in stack.json flipped to true via #9, and CI (#18)
publishes the image it pulls. This row said "design only, no code yet" for
some time after that stopped being true — corrected 3 Sept 2026, prompted by
a user catching the same drift in this repo's backlog-tooling claim.
1. Two tiers, and the question that picks one
Does the site keep state that a visitor can change?
If no, it is static. If yes, it is transactional. There is no third answer, and "not yet, but maybe later" is a no — a site that grows a reason to store something gets that decision made properly at the time, in writing, not smuggled in as a serverless function on a Friday.
Static tier
Plain files, served from Cloudflare Pages. No Dockerfile, no server/, no
database, no runtime anything. Static tier is not the same claim as
"no build step" — Amoleo-Rissbrook has always had one (Astro, static
output, no adapter), and Amoleo-Website gained a small one 8 Sept 2026
(npm install && npm run build, copying family-css/*.css out of an
installed package — see docs/family-css-system.md's npm-distribution
pilot). What actually defines the tier is §1's own question above ("does
the site keep state a visitor can change?"), not whether a build step
exists at all.
What this buys, and why it is worth defending:
- Nothing to keep up. No container to patch, no dependency with a CVE in a request path, no 3am restart. Rissbrook in particular is meant to still work in twenty years, which nothing with a running process manages.
- Free, and fast everywhere. Pages puts the files on a CDN and charges nothing for a site nobody has to run.
- The repo is the site. What you see in
gitis what is served, which makes every review a real review.
The moment you want a form that posts, a login, a comment, or a counter, you are not on this tier any more. Say so out loud and move the site properly; do not bolt a Worker onto it and call it static.
Transactional tier
One shape, and both apps already have it:
| Part | What |
|---|---|
| Client | React, built by Vite, served as static files by the app's own server |
| Server | Node + Express, ESM ("type": "module") |
| Data | MongoDB via Mongoose, in its own container with its own volume |
| Packaging | A multi-stage Dockerfile on node:22-alpine — client build stage, then runtime |
| Running | Docker on the Proxmox LXC, managed through Arcane |
| Environments | Branch-driven: main → prod, develop → dev, test → test |
Corrected 4 Sept 2026: this table pinned node:20-alpine until now, and
that pin was stale, not current. Node 20 left maintenance in April 2026 and
receives no further security patches, including the bundled OpenSSL —
confirmed via a pentest-code-reviewer security review of
Amoleo-Communications#3,
finding C32. node:22-alpine was already Amoleo-Accounts' own deviation
from this pin (Decisions.md, 12,
originally justified by oidc-provider's Node floor rather than the EOL
problem) — that deviation is now promoted to the family default rather than
staying a one-repo exception. This is a "new work starts on 22" correction,
not a claim that every live container already matches it: Amoleo-Pets and
Amoleo-Connections still run node:20-alpine in their own Dockerfiles and
each need their own backlog item to bump, same shape of follow-up as the
scoped-Mongo-credential item recorded above (§1, "Three things that make
sharing safe").
No SSR, no Next, no serverless. The server serves the built client because that is one process and one origin, which removes CORS, cookie-domain and first-paint-ordering problems rather than solving them.
Amoleo-Images is the named exception, on purpose, for a reason specific
to it: a CDN edge cannot evaluate a live share-token expiry or a sitter
grant, so private and public image bytes have to live in genuinely separate
trust boundaries, not just separate routes on one origin. That's Cloudflare
R2 (two buckets) plus a Cloudflare Worker for edge-side token verification
on the private path — real serverless, real CDN-native storage, unlike
every other repo in the family. See Amoleo-Images/docs/Architecture.md
and its tools/stack.json entry for the full reasoning. No other repo gets
this exception without the same kind of structural reason (a CDN literally
cannot do the check another way) — "it would be cheaper" or "it would be
faster to build" don't qualify on their own.
Mongo is the only database. Not because it is the best fit for every query anyone will ever write, but because two databases in a five-repo family is four things to back up and two sets of habits. Anything else needs an ADR in the repo that wants it, and a row in the table above.
One Mongo container per environment, shared by every app in it
There is one MongoDB container per environment on the LXC, not one per app. Every transactional app in that environment connects to it over the shared Docker network and takes its own database inside it:
| Environment | Network | Mongo container | Pets | Connections |
|---|---|---|---|---|
| Production | prod-net |
mongo-prod |
petweight |
kdrama |
| Development | dev-net |
mongo-dev |
devpetweight |
… |
| Test | test-net |
mongo-test |
… | … |
So a connection string is always mongodb://mongo-<env>:27017/<app-database>.
Both apps already do this; it had simply never been written down, which is the
state a rule is in just before someone breaks it.
Why one container:
- One thing to back up per environment, and one to restore. Five apps with five Mongo containers is five backup jobs, of which four get set up and one gets remembered after the disk fails.
- One version. Separate containers drift to separate Mongo majors, and the first time that matters is when a query behaves differently in one app for reasons nobody can see from that app's repo.
- The LXC has a fixed amount of RAM. Each
mongodreserves its own cache. A fifth idle Mongo is memory spent on nothing.
Three things that make sharing safe, and are not optional:
- The database name is unique across the family. Sharing a container means
two apps are one typo away from sharing a database, and Mongo will not
complain — it will create the collection and let two schemas interleave in
it.
check-stacktreats a collision between repos as a failure. - Recreating an app never touches Mongo. The Mongo container is separate
precisely so that
docker rmon an app container is boring. Every container skill has to say so out loud, because the moment someone doubts it they hesitate over a routine deploy. - The container requires authentication, and every app connects as a user
scoped to only its own database. This was true in theory and false in
practice until Amoleo-Accounts' Phase 1 refinement caught it:
mongo-devandmongo-prodran with no auth at all, which is a fine risk to carry while every database in the container holds one app's own data, and stops being one the moment a database in that same container holds a password hash. Enabling auth on a sharedmongodis atomic across every app connected to it — a repo cannot adopt this on its own schedule, only as part of a coordinated cutover across every app sharing that environment's container. See Amoleo-Accounts/docs/Decisions.md, 11 for the reasoning. Corrected 4 Sept 2026 — this section previously saidAmoleo-PetsandAmoleo-KDramaConnectionseach still needed their own backlog item before the cutover could ship; both since have, andmongo-dev/mongo-prodnow require authentication with every connected app — Accounts, Pets, Connections and, as of the same date, Amoleo-Communications — holding a user scoped to only its own database. A new app onboarding onto either container gets its own scoped user created the same way, following Accounts' original precedent; there is no longer a credential-free path onto either container for anyone.
The docker-compose.yml in Amoleo-Connections runs its own mongo:7 beside
the server. That is the local stack — a laptop with nothing else running —
and it is correct there. It is not how the LXC is deployed, and the compose file
should never grow into the deployment path.
If an app ever genuinely needs isolation — a noisy-neighbour problem, an incompatible Mongo version, a legal reason to hold data apart — that is a real answer, and it goes in this section with the reason, not in a container someone quietly stood up.
2. Login — Accounts is the identity provider, the app still owns its user row
Any site with a login uses Amoleo Accounts as its OpenID Connect provider. No app implements its own password flow, its own reset email, or its own password hashing. Today both apps do exactly that; that is the migration, not the pattern. See Amoleo-Accounts/docs/Architecture.md and docs/Migration.md.
Each app keeps its own user table anyway. That is not a contradiction, it is the split:
| Lives in Accounts | Lives in the app's user table |
|---|---|
Who this person is: the sub, email, verification, password, sessions |
Everything this app needs about them: preferences, tier, sort orders, their pets, their board |
Three rules that make the split hold:
- The app's row is keyed by the OIDC
sub, not by email. Email changes;subis forever. A table keyed on email quietly becomes two accounts the first time someone changes theirs. - The app never stores a credential. No password hash, no reset token, no "just in case" copy. If the app can authenticate someone without Accounts, Accounts is not the identity provider — it is a suggestion.
- Profile fields are cached, never authoritative. An app may hold a copy of the display name to render a page without a round trip; when it disagrees with Accounts, Accounts wins.
A static-tier site cannot have a login. If it wants one, see §1 — it has changed tier.
3. What every repo carries, regardless of tier
A GitHub repository
Under ValadAmoleo, named Amoleo-<Product> — which for three of the five is
not the folder name on disk. The mapping is in the family CLAUDE.md. Local
-only work is not a state any of these repos are allowed to be in: the backlog,
the issues and the review history all live at the remote, and a repo without one
has none of them.
The backlog skill
Every repo runs the /github-project-backlog workflow: a GitHub Projects v2
board with the Status/Priority/Effort fields, and scripts/backlog-tools/ in
the repo so work can be picked up, reordered and closed without a human relaying
it. All five repos on this page have it — Pets and Connections first, then
Website (Project #13), Rissbrook (Project #14) and Accounts (Project #7).
This is the one item on the list that looks like overhead for a small repo and is not. A static site's work is small, infrequent and therefore forgettable — the SEO fix noticed in June and remembered in October is the normal outcome without a board.
A container skill, if it has a container
Every Docker repo carries a recreate-prod-container command in
.claude/commands/ that produces the full docker run invocation — every
volume, every env var, every secret prompted for rather than assumed. Both apps
have one, and both should keep them in step with the Dockerfile in the same
commit that changes it.
The reason is narrow and real: recreating a container is a thing done rarely, under pressure, and an env var forgotten during it is a feature that silently stops working. The skill also has to state plainly what is not destroyed — Mongo lives in a separate container and is never touched by recreating the app.
A CLAUDE.md pointing here
Every repo's CLAUDE.md names Amoleo-Family and says what lives there. See
§5.
4. The rest of it, written down once
Smaller than the tier decision, big enough to diverge on:
One language. JavaScript, ESM everywhere,
"type": "module". TypeScript only where a framework brings it (Astro'stsconfig.json); nobody adds a build step to a plain Node server for types.Node 22, pinned in the Dockerfile, on the
-alpinebase. This wasnode:20-alpineuntil corrected 4 Sept 2026 — Node 20 was EOL (see §1's transactional-tier table for the full reasoning and the C32 citation), and the "moves in both apps in the same week" line never actually held: Accounts moved to 22 alone, for its own reason, first. Pets and Connections are still onnode:20-alpineand owe their own bump.npm, with
package-lock.jsoncommitted. No yarn, no pnpm. A second package manager in the family is a second set of lockfile conflicts.npm testis the entry point, and it goes green before a commit. Pets is on Vitest; Connections runs hand-writtennodescripts under the same command. Converging Connections onto Vitest is the right next step, and the sharednpm testcontract means nothing else has to know.Secrets arrive as environment variables at container run. Never in the repo, never baked into the image, never defaulted to something that works.
docker-compose.ymlusing${JWT_SECRET:?JWT_SECRET must be set}is the pattern: fail loudly rather than boot insecure.Uploads go on a Docker volume, never into Mongo and never into the repo. Photos are the reason
pet-weight-photos-prodexists as a volume rather than as a directory in the container. Amoleo-Images is the migration target that replaces this rule for image uploads specifically — Pets and Connections move their volumes onto it over time (see Amoleo-Images' backlog); this line describes what's true until each app's migration lands, not a permanent instruction to keep inventing new Docker volumes for images.Mongo's container is not the app's container, and it is not the app's Mongo either — it is the environment's, shared by every app in it. See §1.
Hostnames are
<product>.amoleo.com, withdev/testas prefixes (devpets.amoleo.com). Rissbrook is the exception on purpose: it iswww.rissbrook.co.uk, because it is a memorial to someone's work and not an Amoleo product. Amoleo-Images is the other exception, but a narrower one than it first was:images.amoleo.comitself is the app (landing page, admin console, and the authenticated API) — matching this convention exactly, no exception needed there at all. Only its two CDN-bound read paths carry a prefix instead —public.images.amoleo.comandprivate.images.amoleo.com— because an R2 custom domain binds a whole hostname and can't path-split CDN reads from the authenticated API the way a single Express origin normally would, and can't path-split public reads from private ones either. SeeAmoleo-Images/docs/Architecture.md.Decided 3 Sept 2026, revised 5 Sept 2026:
api.<product>.amoleo.comis not a family-wide default, even though it looks like a naming pattern worth generalising. Images itself briefly usedapi-images.amoleo.comfor exactly this (3–4 Sept), then reversed it on 5 Sept once the CDN hostnames were prefixed instead (public.*/private.*) — freeing the bare hostname for the app removed the only reason Images had one. So there is currently no repo using this pattern; it remains available in principle, but the bar stays high. Every transactional repo is one Express origin serving client and API together on purpose (§1: "removes CORS, cookie-domain and first-paint-ordering problems rather than solving them"), and that stays the default. A repo would earn its ownapi.*host only for a concrete reason a same-origin server doesn't cover — a non-browser caller that needs a stable API URL independent of the app's own hosting (Connections' Electron shell, a desktop app syncing with Upnext) — decided and written up in that repo's own docs, not adopted by decree across the family. Accounts in particular should not move its issuer offaccounts.amoleo.comfor naming consistency alone: an OIDC issuer URL has its own downstream consequences (the well-known discovery document, token audience, anything a client hardcodes) that a naming pass shouldn't trigger as a side effect.Also decided 3 Sept 2026: no
private.<product>.amoleo.com(or, as of 5 Sept,public.<product>.amoleo.com) pattern as a family default either, for the same reason in reverse. Images needs Worker-fronted and R2-bound hostnames because a CDN edge cannot evaluate a live share-token expiry against raw bytes on a public bucket, and an R2 custom domain can't path-split from the app's own hostname either — a structural split, not a visibility split. A product whose "private" content is just auth-scoped rows behind the same API (Upnext's private lists, Worldbook's private entities) has no such constraint and gets no such hostname — seeAmoleo-Worldbook/docs/Architecture.md, "History," for why Worldbook in particular already settled this the other way.Security middleware is not optional on the transactional tier:
helmetandexpress-rate-limiton every Express app. On the static tier the equivalent is a_headersfile, which Pages reads and nobody else will remind you about.Analytics load only after consent, on either tier — see cookie-consent.md. A site with no analytics has no banner, deliberately.
In-repo
docs/for what is true of that repo; here for what spans repos. If you are writing the same paragraph in two repos, it belongs in this folder and one of these documents.
5. Pointing back here from a repo
Each repo's CLAUDE.md carries a short Part of the Amoleo family section
near the top: what the family repo is, where it is on disk relative to that repo
(../Amoleo-Family), its GitHub URL, and the specific list of things that live
there rather than locally — brand colours, the footer lockup, cookie consent,
screenshots, and this document.
Two things make the pointer worth the lines it takes:
- The sibling path only exists on this machine. A cloud session, a fresh clone or a CI run has the repo and nothing beside it, so the pointer gives the GitHub URL as well as the relative path. It says "read this", not "this is already open".
- It has to name what is there, not just link. "See the family repo" gets
skipped. "The accent colour, the footer, and the cookie banner are specified
in
../Amoleo-Family/docs/and checked by scripts there — don't change them locally" is a sentence that stops a wrong edit.
check-stack.mjs checks each repo has one, by looking for Amoleo-Family in
its CLAUDE.md. That is a weak check — it catches a repo with no pointer, not a
pointer gone stale — and it is honest about being one.
6. What the checker cannot see
check-stack reads files on disk. It can tell that a repo declared static has
grown a Dockerfile, that an app lost its container skill, that a repo has no
GitHub remote or no backlog tooling. It cannot tell you whether the OIDC
integration is correct, whether the user table is really keyed on sub, or
whether a secret is hard-coded three directories down.
So the rules in §2 and §4 are enforced by reading, which means they are enforced by this document being short enough to read. Keep it that way: when something here can be made mechanical, move it into the checker and cut the prose to a sentence.
Generated from docs/stack.md + tools/stack.json. Edit there and re-run npm run build in docs-site-src/ — never hand-edit a page.