Changelog¶
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Each sub-repo (Barrin's API, Barrin's Identity, Tamiyo Scroll, Tolaria
News, Docs, Ops) keeps its own CHANGELOG.md — full history, linked in
the nav to the left. The section below aggregates just the entries for
the most recently released version, across every sub-repo that changed
in it, as a quick "what shipped last" summary.
Latest changes — v1.0.0¶
Barrin's API¶
Added¶
- Initial scaffold of the Barrin's API backend (FastAPI, Python 3.14, PostgreSQL via async SQLAlchemy 2 + asyncpg, Alembic migrations, Pydantic v2 settings).
- Authentication (
/api/v1/auth): JWT (HS256) login/refresh/logout, self-registration with email verification (/auth/signup,/auth/signup/verify,/auth/signup/resend), admin-only direct account creation, Argon2id password hashing, and a 5-level hierarchical role system (anonymous→user→role_c→ml_developer→admin) enforced via arequire_role(min_role)dependency. - Tamiyo Scroll BFF (
/bff/tamiyo-scroll), a competitive MTG tracking API: user settings (read-only sharing), personal decks and versioned decklists (with Moxfield import), a metagame/opponent deck roster, a BO3 match log, card-test feedback, decklist coloring by test feedback, and aggregated archetype/matchup statistics. - Email delivery service with a console sender for local development and an SMTP sender for production.
scripts/create_admin.pyto bootstrap the first admin account, andscripts/workflow_ci.pyto run the full local CI pipeline (ruff, ty, pytest) outside of GitHub Actions.- Alembic migrations: users/roles/tokens, auth email verifications, and the Tamiyo Scroll tracker tables.
- Test suite (pytest + pytest-asyncio + httpx) with a 90% global / 100%
app/models/coverage floor. docs/content/back/barrins_api/auth_roles.md: documents the JWT authentication and hierarchical role-based access control system (role hierarchy, endpoint security matrix), replacing the ad hocX-Admin-Keyheader.docs/content/back/barrins_api/signup_email_verification.md: documents the self-registration and email verification flow (/auth/signup,/auth/signup/verify,/auth/signup/resend).- Nav entries (
docs/mkdocs.yml) and_links.mdsidecar links for the two pages above. GET /health: reports database connectivity (200/503), for external uptime monitoring —503covers both a failed query and a fully unreachable database (e.g. connection refused/rejected). Registered afterGET /ingeneral/router.pyso/docslists routes in that order.- External uptime monitoring (HetrixTools) is live against
barrins_apiprod + staging/health, plus certificate-expiry alerting — seedocs/content/ops/operations/index.md. - Real Moxfield deck import (
app/services/moxfield/): fetches a public deck from Moxfield's API, rate-limited to 1 request/second, replacing the earlier placeholder that only stored the given URL as text. - API routes reorganized into one
app/api/<domain>/package per domain (general/,tamiyo_scroll/), each owning its own router aggregator — replaced the previousts_router.py/bff/ts_router/andv1_router.py/v1/split, which had a file and a differently-scoped directory sharing the same name.
Changed¶
- Translated
README.mdfrom French to English.
Barrin's Identity¶
Nothing yet.
Tamiyo Scroll¶
Added¶
- Initial scaffold of the Tamiyo Scroll frontend (React 19, TypeScript, Vite, React Router, TanStack Query, Zod, TailwindCSS, shadcn/ui components).
- Authentication flow: login page, self-registration email verification
page, and a
ProtectedRouteguard backed by a session store consuming thebarrins_api/api/v1/authendpoints. - Metagame tab: personal decks list with Moxfield decklist import, a meta/opponent deck roster, and aggregated archetype/matchup statistics sections.
- Suivi BO3 tab: match journal, new-match form, and card-test feedback section, backed by the BO3 match log and card-test BFF endpoints.
- Decklist tab: current decklist view (colored by card-test feedback) and version history section.
- Read-only "viewing owner" selector (header) and
active-deck-contextfor sharing another user's data without allowing edits, per the BFF's read-only sharing settings. - App shell layout with tab navigation, and a centralized typed API
client (
src/api/client.ts) with Zod-validated request/response schemas (src/schemas/tamiyoScroll.ts). - Test suite (Vitest + Testing Library) covering the API client, card tests, active-deck context, match form, and card-tests section.
LoginPage.tsx: live password requirements checklist on signup, mirroringPASSWORD_PATTERNinapps/barrins_api/app/schemas/auth.py(12+ characters, upper/lowercase, digit, symbol) so users see which rules they still need to satisfy as they type. UX feedback only — the backend remains the sole source of truth on submit.PersonalDeckSelector.tsx: an archive (delete) button per deck in the list, wired to the already-existingDELETE /personal-decks/{id}endpoint (soft-delete viaarchived_at, never a hard delete) anduseArchivePersonalDeckhook — both existed backend-side but had no UI entry point. Guarded behind a confirmation dialog (ui/dialog.tsx) so a stray click can't archive a deck. Archiving the currently-active deck also clearsactive_personal_deck_id.AppShell.tsx: a "Welcome, {display_name}" greeting next to the Log out button, usinguseCurrentUser(GET /api/v1/auth/me, already implemented but unused in the UI). Falls back to the account email when no display name is set.
Changed¶
- Route
/app/suivi-bo3renamed to/app/bo3-tracker(App.tsx,AppShell.tsx's tab nav,LoginPage.tsx,VerifyEmailPage.tsx,README.md). Thepages/suivi-bo3/folder andSuiviBo3Tabcomponent name are unaffected — internal naming, not the route. - Post-login, post-signup, and post-email-verification now redirect to
/app/bo3-trackerinstead of/app/metagame, landing users on the match tracker first. - Translated
README.mdfrom French to English. - Translated remaining French UI text (labels, buttons, placeholders,
error messages) and code comments across the app —
index.css,active-deck-context.tsx,lib/mtg-format.ts,schemas/tamiyoScroll.ts,LoginPage.tsx,VerifyEmailPage.tsx, the decklist, metagame, and Suivi BO3 sections,AppShell.tsx,lib/store.ts,lib/queryClient.ts,api/client.ts,api/viewingOwner.ts, andhooks/useViewingOwner.ts— to English. - The "Share my data" checkbox and "View: {user}" selector extracted
into
SharingControls, disabled for v1.0.0 (not mature enough — the underlying backend enforcement is fully tested, but this UI had no component-level test).AppShell's deck selector no longer branches oncanEdit/viewing-mode, since there's no UI path left to enter it. CurrentDecklistSection.tsx,PersonalDecklistImportSection.tsx, andCardTestsSection.tsxnow render nothing (null) instead of a "Select or create a personal deck…" placeholder card when no personal deck is active (e.g. right after archiving one) — matchesVersionHistorySection.tsx's existing behavior for the same state.
Fixed¶
VerifyEmailPage.tsx: footer credit read "Account managed by barrins_api" — corrected tobarrins_identity, the actual identity service.PersonalDecklistImportSection.tsx:handleImport(Moxfield URL) andhandleSaveRaw(raw decklist text) never caught a failed mutation, so a backend error (e.g.400on an invalid/non-Moxfield URL) silently vanished instead of reaching the user — found via the A3 manual UAT step. Both now catchApiErrorand render the message inline, mirroringLoginPage's existing pattern.vite.config.ts: stubbedVITE_API_BASE_URLvia Vitest'stest.envsosrc/api/client.tsdoesn't build requests against"undefined"during tests. The variable was only ever supplied by a local, gitignored.envfile, so every CI run (including thefrontjob for otherwise-unrelated Dependabot bumps) failed 6client.test.tstests withTypeError: Invalid URL.
Tolaria News¶
Nothing yet.
Docs¶
Added¶
- CI-runnable local scripts (
docs/package.json):npm run lint,npm run spellcheck,npm run build, andnpm run ci, mirroring thedocsjob in.github/workflows/CI.ymlso the same checks can run from a terminal without waiting on CI. docs/cspell.json, a real cspell config the CLI can read (spelling exceptions previously only lived in.vscode/settings.json, which the standalonecspellCLI does not parse).- This changelog, following Keep a Changelog and Semantic Versioning.
docs/hooks/sync_readmes.py, anon_pre_buildmkdocs hook that copies eachapps/<app>/README.mdinto itsdocs/content/**/<app>/index.mdpage at build time, so app READMEs and their docs page never drift. A sibling_links.mdfile per page preserves the curated nav links (e.g. tobff/tamiyo_scroll.md,bootstrap.md,incidents/index.md) that used to live directly inindex.md; those sidecars are excluded from the built site viaexclude_docsinmkdocs.yml.- Root
.gitignore: ignores the mkdocs-generatedindex.mdpages, thedocs/site/build output, and Python__pycache__/*.pycfiles (produced by the new build hook). docs/hooks/sync_readmes.pyextended to also sync eachops/my-server/roles/<role>/README.mdintodocs/content/ops/roles/<role>/index.md(target directories created and cleaned up on demand, since — unlike the app pages — no_links.mdsidecar pre-creates them). New Ops → Roles nav section (docs/mkdocs.yml, placed after Deployment) anddocs/content/ops/roles/index.mdoverview page link the eight generated role pages;.gitignoreand the generated-marker comment in the hook itself were generalized to cover both README sources.on_shutdownhook indocs/hooks/sync_readmes.py: deletes the generatedindex.mdpages when the hook shuts down, sincemkdocs servekeeps rewriting them on every reload but never removes them on its own.docs/hooks/sync_changelogs.py: a secondon_pre_buildhook, mirroringsync_readmes.py's pattern, that copies each sub-repo's ownCHANGELOG.mdintodocs/content/changelog/<subrepo>.mdand buildsdocs/content/changelog/index.md(a tracked_intro.mdpartial plus a "Latest changes" section aggregated from whichever[X.Y.Z]section matches the latestvX.Y.Zgit tag). Replaces the single hand-maintaineddocs/content/CHANGELOG.mdthis entry itself used to live in.
Changed¶
docs/package.json: mergedspellcheckandspellcheck-appinto a singlespellcheckscript covering bothcontent/**/*.mdand../apps/**/*.md..github/workflows/CI.yml: excluded_links.mdsidecar files from themarkdownlintandcspellsteps (they intentionally start with a bullet list rather than a heading).docs/cspell.json: added the technical terms and proper nouns introduced while translating app READMEs and wiring the README sync hook (asyncpg,cffi,decklist,getpass,metagame,Moxfield,MTGJSON,mypy,oxlint,pytest,Resends,venv,winrate/winrates, among others).docs/cspell.json: removed the blanket*.ymlignore in favor of a!docs/**/*.{yml,yaml}exception (so files likedocs/mkdocs.ymlare spell-checked) and added**/*.tomltoignorePaths; added terms surfaced by the new auth/signup documentation (checkfirst,passlib,pyproject,Referer,STARTTLS,userrole,VARCHAR, among others).docs/cspell.json: addedsubdir, introduced by the new*_repo_subdirAnsible role variable documented below (ops section).
Fixed¶
mkdocs.ymlhaddocs_dir: contentpointing at a folder that did not exist; all documentation pages moved underdocs/content/to match it.mkdocs.ymlnav referencedback/barrins_api/implementation.md, which does not exist (the actual page isback/barrins_api/bff/tamiyo_scroll.md); also added the missing nav entries forfront/tamiyo_scroll/bootstrap.mdand the incidents pages, which were causingmkdocs build --strictto fail.
Ops¶
Added¶
.github/workflows/CI.yml: path-filtered CI pipeline (dorny/paths-filter) that runs checks only for the parts of the monorepo a change actually touches —back(lint, security, types, tests viauv run scripts/workflow_ci.pyforbarrins_api/barrins_identity),front(npm run lint/build/testfortamiyo_scroll/tolaria_news),ops(ansible-lintforops/my-server), anddocs(markdownlint, cspell,mkdocs build --strict) — on every push/PR tostagingandmain. Aci-requiredjob aggregates the per-job results into a single fail-closed status check, so branch protection has one check to depend on even though the individual jobs are conditionally skipped..github/dependabot.yml: weekly dependency update PRs forapps/barrins_api(uv),apps/tamiyo_scroll(npm),docs(npm), and.github/workflows(github-actions), all targetingstagingso updates go through the same CI gate as any other change before reachingmain. Dependabot only ever opens pull requests — it never pushes commits directly to a branch..github/workflows/deploy-docs.yml: manual (workflow_dispatch) MkDocs build-and-deploy workflow to GitHub Pages, intentionally kept out of the required CI checks. The hosting target (GitHub Pages + custom domaindocs.barrins-codex.org) is a placeholder pending confirmation. Superseded: this workflow was never actually committed to the repo, and the hosting target is now decided — self-hosted viadocs.yml/thedocs_siterole (see Added, below), not GitHub Pages (the account already hosts other, unrelated projects' Pages sites — see B2,docs/project/first_production_release/b2-docs-deploy/index.md).- Ansible VPS deployment (
ops/my-server/), moved in-repo from the previous separateSpigushe/myserverrepository (now deprecated) so infrastructure changes land alongside the application changes that require them (Constitution §26.1). Playbooks:initial.yml,setup.yml,barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml; roles:create_ssh_key,setup_base_user,setup_packages,register_ssl,backend_website,react_frontend,fastapi_backend.scripts/check_no_secrets_committed.shguards against ever staging a real secrets file. Documented under the new Constitution §38-mandateddocs/content/ops/tree (architecture/independence.md,architecture/decisions.mdfor the ADRs below,deployment/{backend,frontend,rollback}.md,security/secrets.md,operations/index.md— the last honestly documenting current gaps: no health endpoint, no monitoring, no tested backups). ops/my-server/postgresql_pgadmin.ymland thepgadminrole: a Docker-based pgAdmin4 deployment/administration playbook (isolated Docker network,pg_hba.conf/listen_addresseswiring, weekly auto-update timer,unattended-upgradesfor the host), porting thepgadminrole from myserver's unmergedpostgresql-pgadmin-playbookbranch. PostgreSQL itself is already installed bysetup_packagesat host bootstrap; this playbook only exposes it via pgAdmin. Documented atdocs/content/ops/deployment/database.md.ops/my-server/secrets/tamiyo_scroll/{staging,production}.env.example: documentation templates mirroringapps/tamiyo_scroll/.env.example, for parity withbarrins_api's per-appsecrets/layout. Unlikefastapi_backend_env_file,react_frontendhas noenv_filemechanism —tamiyo_scroll.ymldoes not read these;VITE_API_BASE_URLis already computed automatically by the playbook and isn't secret.ops/my-server/roles/postgres_backup, wired intopostgresql_pgadmin.yml: a systemd-timer-driven daily backup (pg_dump --format=customper non-template database, pluspg_dumpall --globals-onlyfor roles/grants) into/var/backups/postgresql/, 14-day retention, running as thepostgresOS user (umask 077makes every dump0600by construction). Documented atdocs/content/ops/deployment/backup.md. UAT-verified on staging: timer active, a manually-triggered run produced dumps with the correct permissions (0600, owned bypostgres) for every database on the instance, and a full restore drill (restore into a scratch database,SELECT count(*)compared against the live database) matched exactly.ops/my-server/secrets/postgresql_pgadmin/admin_password.txt.example,ops/my-server/secrets/github/token.txt.example: plaintext templates for two secretssecrets/README.mdalready documented but never actually shipped — a bare pgAdmin admin password file, and a shared GitHub PAT (see Changed, below).ops/my-server/roles/github_token: the GitHub PATpre_tasksblock (see Changed, below) extracted into its own role oncedocs.ymlbecame its fourth identical copy acrossbarrins_api.yml,tamiyo_scroll.yml, andtolaria_news.yml. Every playbook that clones a private repo now listsgithub_tokenfirst in itsroles:;fastapi_backend/react_frontend/docs_sitefall back to itsgithub_tokenfact via<role>_github_token | default(github_token)unless a playbook overrides it per-invocation.ops/my-server/docs.ymland thedocs_siterole: self-hosted deployment of the mkdocs documentation site (B2), samedeploy_env/branch/release-tag options asbarrins_api.yml/tamiyo_scroll.yml. Structurally mirrorsreact_frontend(clone at a ref, build, symlink static output, hand ownership towww-data, nginx vhost +register_ssl), but builds withuv/uvx(uvx --with mkdocs-material mkdocs build --strict, matchingfastapi_backend's toolchain) instead ofnvm/npm, and serves a plain static multi-page site (try_files $uri $uri/ =404;) rather than a client-side-routed SPA. Decided over GitHub Pages because the account already hosts other, unrelated projects' Pages sites. Documented atdocs/content/ops/deployment/docs_site.md.
Changed¶
.github/workflows/CI.yml: translated remaining French inline comments and step names to English.- Constitution §34 (Secrets Management), applied while moving
ops/my-server/in-repo and decided with the user rather than guessed (§16.2, recorded as an ADR indocs/content/ops/architecture/decisions.md): backend.envfiles are local-only and git-ignored (ops/my-server/secrets/**/*.env), never committed even encrypted.fastapi_backend'sfastapi_backend_env_filestep uses one if present on the operator's machine, skips gracefully otherwise. - Constitution §25/§27/§31 (Release Policy), same ADR process:
production deploys resolve the latest GitHub release tag by default
(
fastapi_backend_use_release_tag/react_frontend_use_release_tag, wired todeploy_env == 'production'in every playbook), or a pinned tag for rollback. Staging keeps deploying a branch, since it exists to preview code before release. .gitignoreandscripts/check_no_secrets_committed.shgeneralized: allow-listsecrets/**/*.exampleandREADME.mdinstead of listing each secret filename individually, so a new secret file (e.g. pgAdmin's admin password) is caught by default without a new gitignore line.- Constitution §26.1 (Infrastructure objective): added an explicit
"one application, one playbook" rule — a frontend playbook must
never embed a backend role invocation (or vice versa), and running
one app's playbook must never touch another's systemd service,
nginx vhost, or database. Decided with the user (§16.2) after
tolaria_news.yml's embedded-backend exception (see Fixed below) was judged to need fixing rather than being grandfathered. ops/my-server/ansible.cfg: normalizedkey=valuespacing under[defaults]—inventory,vault_password_file,timeout, andansible_ssh_userhad stray spaces around=that every other key in the file didn't.- Production email sending (ADR-3): decided to go through a
transactional email provider's SMTP relay for
identity@barrins-codex.org, rather than self-hosting a mail server or keeping the temporary personal Gmail relay long-term. No code change required —SMTPEmailSenderalready speaks generic SMTP. ops/my-server/barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml,docs.yml: the shared GitHub PAT is no longer an inlineansible-vault-encrypted blob duplicated across the playbooks — externalized to a single local, git-ignoredsecrets/github/token.txt(same pattern aspostgresql_pgadmin's admin password), read via thegithub_tokenrole (see Added, above) — originally a fail-fastpre_tasksblock copy-pasted into each playbook, extracted into a role oncedocs.ymlmade it a fourth copy. Renewing the token (every 30 days) is now overwriting one local file, not re-encrypting and editing three tracked files.
Fixed¶
.github/workflows/CI.yml: thebackjob never provisioned a Postgres service or a realSECRET_KEY, so any PR touchingapps/barrins_apiwas doomed to fail —pytesterrors out whiletests/conftest.pyimportsapp.config.settings(the placeholderSECRET_KEYis rejected), and even past that the session-scopedtest_enginefixture needs a reachable database. This went unnoticed because no PR had touchedapps/barrins_apisince the CI pipeline was wired up. Added apostgres:17service container (localhost:5432, health-checked viapg_isready), job-levelDATABASE_URL/TEST_DATABASE_URLenv vars pointing at it, and a step generating an ephemeralSECRET_KEYviaopenssl rand -hex 32beforeworkflow_ci.pyruns.ops/my-serverplaybooks/roles/READMEs:become: 'no'/'yes'(andgather_facts/update/force/recurse/daemon_reload/enabled/update_cacheusing the same quoted-string pattern) are YAML strings, not booleans — schema/lint tools correctly flagged them as "Incorrect type. Expected boolean." Replaced every instance with realtrue/false..github/workflows/CI.yml: theopsjob ranansible-lint ops/myserver— the original scaffold's placeholder path (marked# TO UPDATE) that nobody updated once the real Ansible deployment landed atops/my-server/— so the job failed outright (ops/myserver: File or directory not found). Corrected the path. The newdocs/content/ops/**tree also never passed thedocsjob's actual checks: cspell didn't knowfastapi,pgadmin,certbot,journalctl,frontends,spigushe,uvicorn,HSTS,vhosts,dpage,certonly,creatordate,nohostname,inlines,ciphertext,FQCN, orkeypair(added todocs/cspell.json), anddeployment/backend.mdhad a code-block line over the 80-charMD013limit.ops/my-server: fixing theansible-lintpath above revealed the playbooks/roles themselves failed the same job hard — 114 failures + 33 warnings across 38 files, none of it caught before since this was the first timeansible-lintactually reachedops/my-server(see the path bug above). Brought it to a clean pass at ansible-lint'sproductionprofile (opsCI only requiresmin):fqcn(73×): every builtin module action FQCN-prefixed (ansible.builtin.copy, notcopy).syntax-check[unknown-module](2×):openssh_keypairandauthorized_keymoved out ofansible.builtin— switched tocommunity.crypto.openssh_keypair/ansible.posix.authorized_keyand addedops/my-server/requirements.ymldeclaring both collections (ansible-galaxy collection install -r requirements.yml, also wired into theopsCI job before linting).role-name(7×): renamed every hyphenated role directory to snake_case (fastapi-backend→fastapi_backend,react-frontend→react_frontend,backend-website→backend_website,create-ssh-key→create_ssh_key,register-ssl→register_ssl,setup-base-user→setup_base_user,setup-packages→setup_packages) and updated every reference across playbooks and docs.var-naming[no-role-prefix](56× once the roles above had valid names — the rule doesn't check unnamed roles): every role-input var and internal computed-config dict renamed to carry the full role name as prefix — abbreviations likefb_repobecamefastapi_backend_repo; the bare single-letter config dicts (r, andpgadmin's ownr) became<role>_config(fastapi_backend_config,pgadmin_config, etc.).risky-file-permissions(8×): explicitmode:added to everytemplate/copytask that lacked one.yaml[octal-values](4×):mode: 0755/0600quoted ("0755"/"0600").no-changed-when(4×): explicitchanged_whenadded tocommand/shelltasks that always reported "changed".package-latest(1×): thepipfallback install now pinsstate: presentinstead oflatest, consistent with installing from arequirements.txtin the first place.name(9×): missing play/task names added; the one task name embedding a Jinja expression mid-string moved it to the end.jinja[spacing](33× warnings):{{var}}→{{ var }}throughout, including.j2templates (not part of the lint gate, fixed for consistency while touching the surrounding code). Verified withansible-playbook --syntax-checkon all six top-level playbooks (ansible-lintitself needs the POSIXgrpmodule and doesn't run natively on Windows — validated from a WSL/Linux venv). New Constitution subsection (docs/content/CLAUDE.md§26.4, "Ansible coding standards") distills these rules for future playbook/role work.ops/my-server/roles/fastapi_backend,react_frontend:fastapi_backend_repo/react_frontend_repoinbarrins_api.yml,tamiyo_scroll.yml,tolaria_news.ymlpointed atbarrins-project/barrins_api,barrins-project/tamiyo_scroll,barrins-project/tolaria_news— repos that don't exist; the apps actually live underapps/<name>/in this monorepo (Spigushe/barrins-project). The first deploy would have failed at thegit clonestep. Both roles gained a*_repo_subdirvar: the full repo is still cloned toapp_root/site_root, but dependency detection/install, the deployed.env, the build command, and the systemdWorkingDirectorynow resolve against<root>/<repo_subdir>; the three playbooks were repointed atSpigushe/barrins-projectwith the matchingapps/<name>subdir. Also surfaced and fixed a related latent bug:react_frontend'sdistsymlink task only fired whenbuild_dir != 'dist', which would have silently served nothing once a subdir is introduced (build output lands at<site_root>/<subdir>/dist, not<site_root>/dist) — the condition now compares full resolved paths instead.ops/my-server/roles/register_ssl/tasks/main.yml: the role's own README documents templating/etc/nginx/snippets/ssl-params.confas step 1 — shared by every HTTPS-serving role viainclude snippets/ssl-params.conf;(backend_website,react_frontend,pgadmin) — but the task that actually templates it was dropped when the Ansible deployment moved in-repo;tasks/main.ymlwent straight from the HTTP vhost to reload to certbot. Any HTTPS vhost reload on a host missing that snippet failed nginx's config test outright (open() "/etc/nginx/snippets/ssl-params.conf" failed (2: No such file or directory)), surfaced when deployingbarrins_api.yml -e deploy_env=stagingto a fresh domain. Restored the missing task, ordered first as the README already described.- Two WSL-specific gotchas surfaced while diagnosing the above from a
/mnt/c(DrvFs) checkout, not repo bugs but worth recording for any operator deploying from WSL: DrvFs mounts report their directories as world-writable by default, so Ansible silently ignores a same-directoryansible.cfg(inventory/ansible_ssh_usernever applied, inventory host pattern left unmatched); and DrvFs's default file permissions can leave the owner-execute bit set even after narrowingfmask, which makes Ansible mistake.vault-password-file.txtfor a vault password script rather than a plain password file. Both resolved by settingmetadata,umask=22,fmask=111under[automount]in/etc/wsl.conf(fmask=111clears execute for owner/group/other alike, vs. a narrowerfmask=11which left owner's execute bit set) followed bywsl --shutdown. ops/my-server/tolaria_news.yml: dropped the embedded copy of thebarrins_apibackend role block (previously documented as a known exception to "one playbook per app" — see Constitution §26.1 above). It's frontend-only now, pointingVITE_API_BASE_URLat whateverbarrins_api.ymlalready has running, the same patterntamiyo_scroll.ymlalready used. Updated the SSH/Alembic path reminders (~/projects/<domain>/apps/barrins_api, not the app root) and every doc referencing the straytolaria.ymlfilename (the file has always beentolaria_news.yml):README.md,architecture/independence.md,deployment/frontend.md,deployment/rollback.md.ops/my-server/roles/fastapi_backend/tasks/main.yml: no task ever ranalembic upgrade head— the role installed dependencies, deployed.env, and restarted the service, leaving any pending schema migration unapplied against the newly deployed code (Constitution §31.1/§37.1 both list migrations as a required deployment step). Added an "Apply database migrations" task (uv run alembic upgrade head,chdirat the resolved work dir), gated on the samefastapi_backend_pyproject.stat.existscheck as theuv synctask.ops/my-server/roles/fastapi_backend/tasks/main.yml: the migrations task above initially ran right after dependency installation, before the.envdeploy step — soalembic upgrade headconnected using whatever.env(if any) was already sitting on the server from a previous deploy, not the one this run just copied. Surfaced while deployingbarrins_api.yml -e deploy_env=staging: migrations failed withpassword authentication failed for user "REPLACE_USER"even after the localsecrets/barrins_api/staging.envwas fixed, because the corrected file hadn't been copied to the server yet at the point migrations ran. Reordered so "Deploy the local .env file" (and its "no local .env found" fallback) runs before "Apply database migrations".ops/my-server/barrins_api.yml,tamiyo_scroll.yml,tolaria_news.yml:env_branchdefaulted staging deploys to adevelopbranch that doesn't exist in this repo (the actual branch isstaging) — any-e deploy_env=stagingrun would have failed at thegit clone/checkout step. Also populated the three playbooks'github_tokenvault block, still aREPLACE_WITH_...placeholder ciphertext since the ops migration in-repo, with the realansible-vault encrypt_stringoutput.ops/my-server/README.md: the venv setup instructions created a barevenv/directory, which nothing but a stale, since-superseded.gitignoreline covers — every role actually usesuv's.venvconvention (caught by the repo-wide**/.venv/pattern). Renamed to.venv, and added--forceto theansible-galaxy collection installstep so it actually reinstalls collections pinned inrequirements.ymlinstead of silently skipping already-installed ones.ops/my-server/roles/fastapi_backend/README.md: documented theuv python install 3.14step the role already performs beforeuv sync(idempotent, a no-op when already installed) — the README previously only mentioneduv syncitself.ops/my-server/roles/react_frontend/tasks/main.yml: the "Ensure nvm is installed" task setNVM_DIR=/opt/nvm(shared across apps) but never created that directory first; nvm'sinstall.shonly auto-creates$NVM_DIRwhen it matches its own default ($HOME/.nvm), so it exited with "You have $NVM_DIR set... but that directory does not exist" on a fresh host. Surfaced while deployingtamiyo_scroll.yml -e deploy_env=stagingto a fresh server. Added a task creating/opt/nvmbefore the install step.ops/my-server/roles/react_frontend/tasks/main.yml: the "Clone/update application repository" task runs as root (the play'sbecome: truedefault), but the role's last task handssite_roottowww-datarecursively — including the.git/directory — so on the next run root no longer owns the repo and git refuses it outright (detected dubious ownership in repository at ..., Git's CVE-2022-24765 protection). Surfaced on a redeploy oftamiyo_scroll.yml -e deploy_env=stagingafter the initial deploy had already flipped ownership towww-data. Added a task settingsafe.directoryin root's global gitconfig (as root) before the clone/update step, so every future run is immune to the ownership check regardless of who last owned the checkout. First written as anansible.builtin.command: git config --global --add safe.directory ...task, which passed locally but failed PR #17'sopsCI job underansible-lint'scommand-instead-of-modulerule; switched tocommunity.general.git_config(add_mode: add, which maps togit config --addand is idempotent — it skips when the value is already present), addingcommunity.generaltoops/my-server/requirements.yml.ops/my-server/roles/react_frontend/tasks/main.yml: with the ownership check above resolved, the same "Clone/update application repository" task then failed withLocal modifications exist in the destination ... (force=no)on a second deploy — the build step (npm install/ build command) can leave the working tree dirty (e.g. a regenerated lockfile), and Ansible'sgitmodule refuses to update over local modifications unless told to. Since this checkout exists solely to be rebuilt from source control on every deploy, addedforce: trueso it always resets cleanly to the target ref. Verified with a fulltamiyo_scroll.yml -e deploy_env=stagingredeploy, which completed successfully end to end..gitignore: thesecrets/**allow-list only worked forsecrets/barrins_api/because that directory predates the rule — for any brand-new subdirectory undersecrets/(surfaced while addingsecrets/postgresql_pgadmin/andsecrets/github/), git excluded the whole directory before evaluating the*.examplenegation, a parent-directory-excluded gitignore gotcha. Added!ops/my-server/secrets/*/so new secret subdirectories are traversed correctly regardless of what they're named.ops/my-server/roles/register_ssl/vars/main.yml:register_ssl_contact_namedefaulted to<username>@<domain>— a mailbox that never existed on any of the 4 domains this role certifies. Let's Encrypt doesn't verify deliverability at registration time, socertbotnever complained; every certificate issued so far was registered with a bogus contact. Made the var required (no default, matchingregister_ssl_server_name) and passed a real, monitored address from every playbook invoking the role.ops/my-server/postgresql_pgadmin.yml: bothpgadmin_admin_email: "{{ pgadmin_admin_email }}"andpgadmin_admin_password: "{{ pgadmin_admin_password }}"passed a role parameter whose value referenced a play-level var of the exact same name — Ansible resolved the right-hand side against the role parameter being defined, an infinite self-reference ("Recursive loop detected in template"). Renamed the play-level vars (admin_contact_email,pgadmin_admin_password_value) so they no longer collide with thepgadminrole's own input var names.ops/my-server/roles/pgadmin: the role unconditionally overwrotepostgresql.conf'slisten_addressesto"localhost,<gateway>"— on a host where it was already'*'(needed bybarrins_apiitself to reach Postgres over the public interface, per itsDATABASE_URL), this produced a duplicate directive and broke that connectivity, since Postgres uses whichever line appears last in the file. Surfaced live while re-testingbarrins_api.ymlright afterpostgresql_pgadmin.yml's first run. The task now reads the active value first and only narrows it when nothing already covers the gateway IP. Also dropped--add-host=host.docker.internal:host-gatewayfrom the pgAdmin container — unnecessary (it already reaches Postgres directly via the isolated network's gateway) and actively misleading, since on Linuxhost-gatewayresolves to the default bridge's gateway, not this custom network's.ops/my-server/roles/docs_site/tasks/main.yml: the "Build the docs site" task carried abecome: falsecopy-pasted fromfastapi_backend'suv-install pattern — but unlikefastapi_backend(which clones and builds entirely as the unprivileged user),docs_siteclones and builds as root likereact_frontend, only handing ownership towww-dataat the very end. Building as the unprivileged user against a still root-owned checkout failed outright:PermissionErrorwritingdocs/content/back/barrins_api/index.md, regenerated bydocs/hooks/sync_readmes.py's mkdocs pre-build hook. Surfaced deployingdocs.yml -e deploy_env=staging. Dropped the straybecome: falseso the build step runs as root, consistent withreact_frontend's equivalent build task.ops/my-server/roles/github_token/tasks/main.yml: theopsCI job failed onvar-naming[no-role-prefix]—ansible-lintwants every fact a role sets prefixed with the role's own name, but this role's entire purpose is to set a shared, unprefixedgithub_tokenfact thatfastapi_backend/react_frontend/docs_site(and every playbook invoking them) already fall back to by that exact bare name. Prefixing it would break that contract. Suppressed with a targeted# noqa: var-naming[no-role-prefix]and a comment explaining why, rather than renaming..github/workflows/CI.yml: thechangesjob'soutputs:map exposedback/front/opsbut notdocs, even though the same job'spaths-filterstep already computed it —needs.changes.outputs.docswas therefore always empty, so thedocsjob (if: needs.changes.outputs.docs == 'true') was skipped on every PR regardless of what actually changed, not just this one. Surfaced by a PR touching onlydocs/project/**still showingci / docsskipped. Added the missingdocs: ${{ steps.filter.outputs.docs }}line.