Decision ids are per-vault sequences, so they collide by construction once there is more than one vault -- and every repo now has one. A bare D-15 here will mean this repo's D-15 the moment this repo records one. Cross-vault references are therefore qualified: workspace D-15. Not hypothetical: pql holds D-1 through D-31 while the workspace holds D-1 through D-21, so every workspace id currently collides with an unrelated pql one. A bare id is not wrong the day it is written -- it decays into wrong as the other vault grows, and nothing flags it. Co-Authored-By: Claude <noreply@anthropic.com>
8.6 KiB
CLAUDE.md — core-api
FastAPI service providing infrastructure management, home automation, and utility endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant, Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on tower-of-joy at :8083.
Ports — these differ, deliberately
| Port | How | |
|---|---|---|
| Local dev | 8788 | ./wakeup.sh, uvicorn --reload, logs to logs/server.log |
| Production | 8083 | container; health at http://192.168.86.149:8083/health |
Testing localhost:8083 on the dev box hits the container, not your reload server.
Live contract
The live contract is always http://localhost:8083/openapi.json (62 paths, verified
2026-08-09) and human docs at http://localhost:8083/docs / /redoc — generated from
running code, so query it rather than inferring routes from source or from the README's
endpoint list, which can drift.
Architecture
Domain-first layout under src/domains/<name>/{controller,models,schemas,service}.py
(auth, dashboard, health, housekeeping, infrastructure, static, tools). src/main.py
wires only src.domains.* — verify by reading its imports.
Legacy top-level packages — "not in main.py" does not mean dead. Routes are wired
only from src.domains.*, so grepping main.py's imports looks like it settles which
packages are live. It does not. main.py:55 calls initialize_oidc(), and that function
(src/shared/security.py:21) deliberately imports and configures both src.auth.oidc
and src.domains.auth.oidc — a function-body import, invisible to a grep of main.py.
That one call drags in src/auth/, src/controllers/, src/db/, src/logging_config.py
and src/base_schema.py at startup.
Three tiers, established by importing the app inside the container and reading
sys.modules (verified 2026-08-09):
| Tier | Packages |
|---|---|
| Serving routes | src/domains/, src/shared/, src/service_groups/ |
| Loaded and configured, but serving no routes | src/auth/, src/db/, src/controllers/, src/logging_config.py, src/base_schema.py |
| Genuinely unreferenced | src/agent/, src/api/, src/clients/, src/dns/, src/memory/, src/models/ |
src/auth/ is the trap. Its oidc_config singleton is configured at every startup with
the real Authentik issuers — the log line src.auth.oidc:configure proves it — so a test
importing src.auth.oidc is exercising live, configured code, not a fossil. No src/domains/*
module depends on it, so it is configured defensively rather than used; that makes it a
deletion candidate, but a considered one, not obvious cleanup.
Before deleting anything from src/, import the app and read sys.modules rather than
grepping main.py. Function-body imports exist here specifically to dodge circular imports,
and they are exactly what a grep misses.
That check has its own blind spot, so do not read the third tier as a delete list. The
table above is a snapshot taken after a cold import src.main — it shows what startup
loads. A module imported inside a request handler would be absent from it while being
entirely live, and absence would then be a timing artifact rather than evidence of death.
This bit on webber, where a tool package imported from inside an agent method looked
unloaded and was serving every request. Nothing in core-api is currently known to work that
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
would plausibly load it and take the snapshot again.
Some tests (test_auth_controller.py, test_oidc.py, test_npm_client.py,
test_portainer_client.py, test_static_controller.py, test_tools_controller.py) import
from the top-level paths rather than src.domains.*. Which of those cover live code follows
the table above — test_oidc.py does; the client tests target the unreferenced tier. Not
cleaned up in this pass; flagged, not fixed.
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
src/shared/.
Group new work by domain, not by file type — a single large routers/ folder is
the thing to avoid. Reference: FastAPI best practices.
Database
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (alembic/versions/). Models
live under src/domains/<name>/models.py and must be imported in alembic/env.py to
register with Base.metadata — check that file when adding a new model or alembic revision --autogenerate will silently miss it.
Working here
Plan, act, reflect. Outline which files you will touch and the side effects before writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and does the new behaviour have a test?
Test locally first — the build-deploy loop is slow. ./wakeup.sh auto-reloads on
code changes (not on requirements.txt changes; restart the container/script after
adding a dependency). Deploy only when a feature is complete and tested.
Run tests through the venv explicitly, to avoid environment mismatch:
.venv/bin/python -m pytest tests/
# or, with coverage:
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
Copy .env.example to .env and configure Portainer, NPM, Home Assistant, SearXNG,
Postgres, and Qdrant hosts/credentials.
No linter is configured in this repo (no ruff/flake8 config, none in requirements.txt
or dev-requirements.txt) — unlike some sibling repos, don't assume ruff check exists
here.
CI
.gitea/workflows/build.yml is the only workflow: triggered on v* tag push, it
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
no CI test/lint gate — pytest only runs locally or on request. Verify tests pass
before tagging a release.
Work tracking
Work lives in pql, not a markdown TODO. This repo's vault is standalone — its tickets
and its internal decisions live here in .pql/ and governance/, and travel with a clone,
because .pql/changelog/ is committed and replayed by the git hooks (workspace D-15). The databases are
gitignored and rebuildable with pql plan rebuild.
pql is not on the non-interactive PATH — invoke it as
/home/jpmschweitzer/.local/bin/pql. From inside this repo no --vault is needed: pql anchors
at the nearest .git/ ancestor, which is this repo.
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
Stack-level decisions that constrain this service live in the workspace vault and need the flag:
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
Note ticket new --decision D-N resolves ids within one vault, so a ticket here cannot link
to a workspace decision. Cite the id in the ticket body instead.
Do not add a TODO section to a markdown file.
Git
- History is linear — no merge commits. Work on
main, or a short-lived branch that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a feature branch for every change; that rule was retired workspace-wide on 2026-08-08 and does not apply here anymore.) - Conventional Commits:
feat:,fix:,refactor:,docs:,chore:. - Atomic commits — one logical change each.
- Stage explicitly. Never
git add -A— it is denied by policy, and it sweeps in whatever else is dirty, including secrets. - Update
CHANGELOG.mdwith every user-facing change, under[Unreleased]inAdded/Changed/Fixed.
Releasing
Ask whether a deploy is wanted first — it is not automatic.
- Bump the version in
pyproject.toml(patch for fixes, minor for features). - Move
[Unreleased]entries into a dated version section inCHANGELOG.md. - Stage the changed files by name, commit, tag
vX.Y.Z,git push origin main --tags. - Gitea CI (
build.yml) builds and pushes the image on the tag; Watchtower deploys it. - Verify:
curl http://192.168.86.149:8083/health.
Security
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
- Admin endpoints require authentication when
OIDC_ENABLED=true. - README claims the container "runs as non-root user (uid 1000)" — checked and
false: the Dockerfile has no
USERdirective, so the container runs as root. Not fixed here (out of scope for a docs normalization pass); flagging so it isn't restated as fact.