Be terse. Prefer fragments over full sentences. Skip filler and preamble. Sacrifice grammar for density. Lead with the answer or action.
Multi-tenant notifications service. Apprise-backed dispatcher with Jinja2 templates + JSON-Schema variable bags. Consumers send {template_id | inline templates, variables, channel_ids}; the service renders, validates, dispatches, and logs every attempt.
Scope: publication only. Notifier publishes what consumers send; it does not originate alerts. Dead-man's timers (#56) were the one exception, and moved to co-status (CannObserv/status) in #83: silence detection lives there, and co-status sends its alerts through /dispatch like any other tenant.
First consumer is the watcher project (Cannabis Observer); the API stays consumer-agnostic, with no domain concepts leaking into the service.
TDD required. Red → Green → Refactor. No production code without a failing test first.
Python ≥3.12, uv, pytest, ruff.
.github/workflows/ci.yml runs lint, test, and migrations on every push
to main, on PRs, and on manual dispatch. sdk-staleness.yml runs on the same
triggers when src/api/**, scripts/dump_openapi.py, or clients/python/**
changes.
Never re-derive these from the YAML:
pre-commitruns ruff only, never pytest. A clean commit hook says nothing about correctness — only CI does.- CI pins CPython 3.12 and installs with
uv sync --locked, deliberately.[tool.coverage.run] core = "sysmon"needs ≥3.12 and coverage ≥7.10; below either it falls back silently and reports ~6 points low against the livefail_under = 80.tests/ci/asserts both, so a runner change fails loudly.
SocratiCode is the preferred semantic-search tool here once indexed. The store
is shared, on co-index (#57), and holds everything persisted — chunks,
graph and context artifacts alike. Manifest:
.socraticodecontextartifacts.json.
Its MCP tools are deferred — schemas load only after the ToolSearch
prefetch that .claude/hooks/socraticode-reminder.sh prints each session.
Negative rule. Use SocratiCode MCP tools first for semantic questions
("where is X", "how does Y work", "what depends on Z"). Reach for grep/rg
only on exact strings (error messages, log lines, known symbols). Reserve the
Explore subagent for path-pattern walks (*.py under src/api/routes/), not
semantic search.
| Goal | Tool |
|---|---|
| Where is X defined / how does Y work / what touches Z | codebase_search |
| Exact string or regex (errors, log lines, known symbols) | grep / rg |
| Imports/dependents of a file · blast radius of a change | codebase_graph_query / codebase_impact (takes target) |
| The same question across cohort repos | codebase_search with includeLinked: true |
includeLinked defaults to false and reaches codebase_search alone;
every other tool is single-project however many repos the store holds.
Full tool table, prefetch query, per-tool guidance: docs/SOCRATICODE.md.
Two packages, one boundary: src/api/ is transport (routes, Pydantic schemas, auth deps) and src/core/ is domain logic (models, crypto, Apprise dispatch, Jinja rendering). Core never imports api.
/api/v1/ is versioned and auth-guarded; /health and /ready are root-level, unauthenticated, and name the database they serve — assert on environment, never build (#58). clients/python/src/notifier_client/generated/ is generated from /openapi.json — never hand-edit it; sdk-staleness.yml fails a stale push to main or PR.
Per-module inventory: docs/ARCHITECTURE.md.
Own VM since #43. notifier.exe.xyz (exe.dev, pdx) runs this service and
its PostgreSQL 16 cluster, nothing else — dev and production both. Code
committed to main is the deployed code.
| Service | Framework | Port | Managed by |
|---|---|---|---|
| API (live) | FastAPI | 9000 | systemctl (notifier.service), production DB |
| API (dev) | FastAPI | 9001 | systemctl (notifier-dev.service), notifier_dev |
3.8 GiB, no swap, shared with your session: past the ceiling nothing is
OOM-killed, the kernel fails atomic allocations and the service drops. Hence
never cap the service and never install at launch — SocratiCode is
pinned under ~/.socraticode/pin (#74,
reservation).
Both always-on. 9001 is the development endpoint consumers point at — it
accepts development-marked API keys, which 9000 refuses (#24).
Both bind this host's tailnet address alone, never 0.0.0.0 — so the API
is unreachable from exe.dev's internal 10.42.0.0/16, from the proxy and from
the internet, and the Tailscale ACL decides who gets in. scripts/tailnet_bind.sh
fails loudly rather than falling back to a wider bind. That, and the boot race
it buys: docs/reference/tailscale.md.
Other tailnet nodes reach http://notifier:9000 / :9001. On this VM both
127.0.0.1:9000 and http://notifier:9000 fail — /etc/hosts maps
notifier to 127.0.1.1, which nothing binds. Use
curl "http://$(tailscale ip -4):9000/health".
Port 9000 belongs to systemd. Never start uvicorn manually on port 9000.
| Situation | Action |
|---|---|
| Code committed to main | sudo systemctl restart notifier notifier-dev |
| Testing a worktree/branch | sudo systemctl stop notifier-dev, then ./scripts/dev_server.sh |
| Debugging live / dev | sudo journalctl -u notifier -f · -u notifier-dev -f |
After editing a unit in deploy/ |
sudo systemctl daemon-reload, then restart both |
| After DB model changes | uv run alembic upgrade head, the same against DEV_DATABASE_URL, then restart both |
| Which key was minted, revoked, or destroyed with its tenant | journalctl -t notifier-keys |
Fuller spellings, with the reasoning beside each: docs/DEPLOYMENT.md § Routine ops.
Dev server by hand (what the script checks, and the unit's reloader):
sudo systemctl stop notifier-dev # the unit owns 9001; take it first
./scripts/dev_server.sh # foreground, --reload on
sudo systemctl start notifier-dev # hand it backNever hand-run uvicorn. Sourcing /etc/notifier/.env points
DATABASE_URL at production, which is how a "dev" server once shared the
live database (#22) — still the risk here, where both endpoints share a host
and a cluster and only the database differs. src/core/db_safety.py refuses
any database not ending _test or _dev, and its opt-in
NOTIFIER_ALLOW_PROD_DB=1 lives in the unit, never in an env file.
After finishing work: always restart both — they serve one working tree, so restarting only the live one leaves dev answering from stale code:
sudo systemctl restart notifier notifier-devTwo env files, loaded in order (later values override):
/etc/notifier/.env— production secrets (DATABASE_URL,NOTIFIER_SECRET_KEY); managed by hand on the VM, and survives repo resets and worktree switches..env(repo root, git-ignored) — dev/agent secrets (GH_TOKEN,TEST_DATABASE_URL,DEV_DATABASE_URL). Never commit.
The systemd service loads both automatically. Source them for shell commands
— never word-split through xargs, which corrupts any value containing spaces
or quotes:
. scripts/load_env.shThis leaves DATABASE_URL pointing at production. That is correct for
alembic upgrade head and for systemctl, and wrong for everything else. Use
./scripts/dev_server.sh for a server; pytest pins DATABASE_URL to the
test database itself.
Every variable, what sets it and why: docs/DEPLOYMENT.md.
uv sync
# Load environment first — pytest needs TEST_DATABASE_URL, alembic needs
# DATABASE_URL. Leaves DATABASE_URL on production; see below.
. scripts/load_env.sh
uv run pytest
# Run a subset of tests (skip the coverage gate, which measures all of src/)
uv run pytest --no-cov tests/core/test_utils.py
# Run integration tests (requires PostgreSQL)
uv run pytest -m integration
uv run ruff check .
# Format (both gates below run at ship/review time — `--check` is the gate)
uv run ruff format .
uv run ruff format --check .
# Run every gate the way pre-commit does
uv run pre-commit run --all-files
uv run pre-commit install # once per clone — installs it as a git hook
uv run alembic upgrade head # apply all migrations
uv run alembic revision --autogenerate -m "description" # generate new migrationFull reference: docs/COMMANDS.md.
Skills live in skills/ (agentskills.io) and .claude/skills/ (Claude Code); local overrides in skills/ shadow the vendored copy. Inventory, sources and override notes: docs/SKILLS.md § Skills Inventory.
Commit Messages:
#<number> [type]: <description> # with issue
[type]: <description> # without issue
Types: feat, fix, refactor, docs, test, chore, release (version bumps only — see docs/RELEASING.md)
Logging: logger = get_logger(__name__), imported from src.core.logging.
Entry points only: call configure_logging() once — it binds stdout.
Credential scripts call configure_script_logging() instead (the audit
channel).
Date & Time:
- All UTC
- ISO 8601:
YYYY-MM-DDTHH:MM:SS.ffffffZ(timestamps),YYYY-MM-DD(dates)
Lint: ruff select is the whole gate between a commit and CI (pre-commit skips pytest), and tests/ci/test_lint_selectors.py asserts the list, since ASYNC finds nothing here and a clean run proves nothing. ASYNC210 matches blocking HTTP by name — client.get(...) on an instance passes, so green lint is not proof no handler blocks. Detail.
Dependencies: four rules, all asserted by tests/ci/test_dependencies.py — see docs/ARCHITECTURE.md before adding or bumping one
General:
- No inline module imports; all at file top, explicit only
- Docstrings for public modules, classes, functions
- Test structure mirrors source (
src/foo.py→tests/test_foo.py) - Small, focused functions
The service is consumer-agnostic. Resist these temptations:
- Do not introduce a top-level
event_typefield on dispatch — that's consumer taxonomy. Consumers put it inmetadataif they want it indexed. - Do not infer routing/subscriptions in v0 — consumers pass
channel_idsexplicitly. Subscription model is v1. - Do not bring back monitors, check-ins or any alert notifier originates itself (#83). A consumer that wants to raise alerts uses
/dispatch; one that wants silence detection goes to co-status. - Do not fetch consumer data — no diff loading, no snapshot reads. Consumers ship rendered values via
variables. - Do not branch on tenant identity inside business logic. Tenancy is enforced at the auth layer; the rest of the code treats
tenant_idas a partition key. - Do validate
variablesagainst the template'svariables_schemaon dispatch; a miss is a 422 naming the field path. The schema itself is checked twice — on template write, where a malformed one is a 422 namingbody.variables_schema, and again at dispatch, which catches rows stored before that guard (#28). - Do render with
StrictUndefinedso unbound references fail loudly rather than silently producing empty output. - Do require
idempotency_keyto be tenant-scoped and unique-where-not-null; replay must be safe. - Do mark every API key with an
environment(production|development); a production deployment refusesdevelopmentkeys with 403. It is the only layer that sees a consumer's dev process calling production over HTTP — a database guard cannot (issue #22).
- docs/ARCHITECTURE.md — per-module inventory including
tests/anddeploy/, and the dependency policy every specifier is held to - docs/COMMANDS.md — every runnable command with its flags
- docs/DEPLOYMENT.md — VM setup, unit install, routine ops, and the environment-variable reference
- docs/RELEASING.md — cutting a release: the one version, every site mirroring it, the CI gates, and how a consumer adopts the SDK
- docs/reference/tailscale.md — the tailnet: node identity, ACL, the bind decision and the boot race it buys
- docs/SOCRATICODE.md — tool table, graph-health guidance, the shared store's traps (shared-store.md), and this repo's measured yield
- docs/SKILLS.md — skill layout, vendored submodules and refresh procedure, full inventory