ADR-0011: The application template, in detail¶
Status: Accepted; partly superseded by ADR-0013 and ADR-0015 Date: 2026-09-29 Deciders: Alex Nodeland
Context¶
ADR-0004 decided on a Copier template that generates applications on artifactr, reflexr or both, wired to the stack's ports (ADR-0005), and RFC-0001 sketched what it generates. Building it settled what they left open, and found three things that differ from what was planned:
- Copier takes a repository, not a directory in one. RFC-0001's
copier copy gh:alexnodeland/stackr/templatebecomes a clone ofhttps://github.com/alexnodeland/stackr/template.git, which doesn't exist. A template in a subdirectory is declared by_subdirectoryin acopier.ymlat the repository's root. - Local Supabase signs access tokens with ES256. The Supabase CLI pinned in
versions.env(2.118.0) signs users' tokens with an asymmetric key and publishes it at/auth/v1/.well-known/jwks.json; its legacy JWT secret (HS256) signs only the API keys. Hosted projects that haven't moved to signing keys still sign with the secret. - Supabase's
publicschema is served to anyone with its public key. Supabase's default privileges grant theanonandauthenticatedroles every privilege on tables thepostgresrole creates inpublic, and its Data API servespublic. Local Supabase's ports listen on every interface (ADR-0009), and its keys are well known.
Other questions were new: how the two libraries share one application, how feedback reaches Langfuse when each library mirrors one workspace at a time, how traces reach Langfuse when both the Collector and Langfuse's client can send them, and how a smoke test can run an agent through the gateway without a provider key.
Decision¶
The template¶
copier.ymlat the repository's root, the files intemplate/. Applications are generated withuvx copier copy gh:alexnodeland/stackr my-app(the latest release, once there are tags) or from a clone, and updated withuvx copier update. There are no Copier tasks, so no--trust.- Questions: the name, slug and description; the libraries (
artifactr,reflexrorboth); whether to include evals; the Python version (3.12 to 3.14); the published port (8800 by default, since 8000 is commonly taken); and a revision for each library. - The libraries are pinned by git revision in the application's
[tool.uv.sources], since none is on PyPI. The defaults are each repository'smainwhen the template was last updated. The application declares evalr's source itself when it uses reflexr's[evals]extra: a dependency's own[tool.uv.sources]doesn't apply to its dependents. - One application, one database, one telemetry setup. Each library's surfaces are mounted under its name in every variant:
/artifactr/v1and/artifactr/mcp/,/reflexr/v1and/reflexr/mcp/. A variant, or a latercopier update, never moves a URL, and both libraries mount side by side without clashing. - The examples are small and complete: a
noteartifact type, its agent and aratingof turns (artifactr);ticket.openedandticket.triagedevents, atriagerule and its agent, and atriage-reviewof runs (reflexr). The triage agent answers in text, so a mocked model reply completes its run.
The ports¶
- Identity: Supabase's tokens, verified against its published keys, with a shared secret as well when one is set.
AUTH_JWKS_URLverifies asymmetric tokens (ES256, RS256, EdDSA and the rest) with the key theirkidnames;AUTH_JWT_SECRET, when set, verifies HS256 tokens. Each algorithm is verified only with its kind of key. Tokens must carryexp,suband theAUTH_AUDIENCE(authenticated), andAUTH_ISSUERwhen it is set. The user issub; the tenant isapp_metadata.tenant_id(AUTH_TENANT_CLAIM), which only Supabase's service role can set. Anything else is 401: the libraries' routers answer 401 whenresolve_actorrefuses, and the MCP servers sit behind an ASGI guard that answers 401 before a request reaches them. A WebSocket, which browsers open without headers, may carry its token in theaccess_tokenquery parameter. This amends ADR-0005's identity port, whose settings wereAUTH_ISSUER,AUTH_JWKS_URLandAUTH_AUDIENCE, withAUTH_JWT_SECRETandAUTH_TENANT_CLAIM. - Database:
DATABASE_URL, and the libraries' tables in a schema of the application's own.DATABASE_SCHEMA(the package's name by default) is every connection'ssearch_pathand is created at startup, before the libraries' migrations run. Their tables and version tables are prefixed, so one schema holds both. Without a URL, workspaces live in memory; SQLite URLs use the libraries' SQLite engines. - Telemetry:
configure_telemetry, whenOTEL_EXPORTER_OTLP_ENDPOINTis set, with Langfuse whenLANGFUSE_PUBLIC_KEYis set too, so the application and its tests run without the stack. Traces reach Langfuse only through the Collector, as ADR-0007 routes every trace: the Langfuse client's span filter exports nothing, and the client still sets each turn's and run's session, user and tags on the spans and sends scores. (Turning its tracing off instead would stop its scores too.) FastAPI and HTTP clients are instrumented and the database driver is not, since the libraries poll their storage and each poll would be a trace of its own. With both libraries, artifactr'sconfigure_telemetrysets up the one tracer and meter provider both use. - LLM gateway:
litellm_model("default"), with the libraries'LiteLLMGatewaycapability. Each request carries its tenant's key (LITELLM_TENANT_KEYS, a JSON object, orLITELLM_API_KEYfor a tenant without one) and asks for thepii-maskandprompt-injectionguardrails (LITELLM_GUARDRAILS).LITELLM_MOCK_RESPONSEadds LiteLLM'smock_responseto every request, for smoke tests; the gateway honours it only for keys whose team allows mocked responses (ADR-0010). - Evaluation data: feedback mirrored to Langfuse scores, a mirror per workspace. The libraries'
FeedbackMirrorfollows one workspace; the application starts one for each workspace the first time a request uses it, through the routers'authorizehook, and creates the feedback types' score configs at startup. Neither stops the application when Langfuse is down.
Around the application¶
- Evals:
evals/holds starter experiments with evalr, whose evaluators' verdicts are the application's own feedback types: the notes agent's replies, and the triage rule replayed against the triage agent with reflexr'sreplay_task. They run offline with a scripted model and an in-memory tracker, or in Langfuse with the gateway's model. - Quality gates, as in the family: uv, ruff, pyright in strict mode with no inline suppressions, pytest with warnings as errors and 100% branch coverage, pre-commit with Conventional Commits, and CI that runs them and builds the image. Tests need no network: tokens are signed in the test, and the model is scripted.
- The
appprofile is the application's own Compose file. Its service joins thestackrnetwork and local Supabase's, where the stack's addresses are fixed;.envholds the addresses this machine uses, formake serve, andSTACK_DATABASE_URLnames another database for the profile. The image is built in two stages, the first with git for the libraries' sources.
Validation¶
make validaterenders every variant (scripts/check-template) and checks that nothing is left unrendered, the Python passes the generated project's own ruff settings, the YAML passes yamllint, the shell scripts shellcheck, and the Compose file is valid.- CI's template job generates each of the six variants and runs its
make check, and the largest variant again on Python 3.14. make smoke-appgenerates an application with both libraries and evals, runs it in its app profile beside the whole stack, signs a user in with local Supabase Auth, and sends a message and a ticket. Each starts an agent whose model request goes through the gateway on astackr-smokekey with a mocked reply. The smoke test checks that a request without a token is refused, that each agent's trace is in Tempo with the gateway's spans in it and in Langfuse, and that the application's turn and run counters are in Prometheus. It runs in CI with local Supabase only, the adapter whose Auth issues the tokens.
Options considered¶
Verifying access tokens¶
| Option | Local Supabase's tokens (ES256) | Hosted projects on the legacy secret (HS256) | Settings |
|---|---|---|---|
| Published keys, and a shared secret when set (chosen) | Yes | Yes | AUTH_JWKS_URL, AUTH_JWT_SECRET |
| A shared secret only (HS256) | No | Yes | AUTH_JWT_SECRET |
| Published keys only | Yes | No | AUTH_JWKS_URL |
Where the libraries' tables live¶
| Option | Served by Supabase's Data API | Works on plain PostgreSQL and hosted Supabase | Settings |
|---|---|---|---|
| A schema of the application's own (chosen) | No | Yes | DATABASE_URL, DATABASE_SCHEMA |
The public schema |
Yes, to the anonymous role, unless row-level security is added to the libraries' tables | Yes | DATABASE_URL |
| A database of the application's own | No | Needs a role and database created by an administrator | DATABASE_URL |
How traces reach Langfuse¶
| Option | Duplicates | Trace attributes and scores |
|---|---|---|
| Through the Collector only; the client exports nothing (chosen) | None | Kept |
| The client exports too | Every span twice | Kept |
| The client's tracing off | None | Scores stop too |
Where the app profile lives¶
| Option | Generated applications | stackr |
|---|---|---|
| The application's own Compose file, on the stack's networks (chosen) | Run beside any stack that has the networks | Unchanged |
A service in stackr's compose.yaml, built from a path |
Depend on a checkout of stackr | Knows about one application |
Consequences¶
- Easier: a new application starts signed in with Supabase, on its PostgreSQL out of the public schema's reach, traced end to end through the gateway, and scored in Langfuse, and passes its own CI.
- Easier: the smoke test proves the template, the libraries and the stack together, on every pull request.
- Harder: the template tracks the libraries' APIs and their
mainbranches. When a library moves, bump its revision incopier.yml; the template job shows whether the generated applications still pass. - Harder: the database driver isn't traced, so a turn's trace shows the libraries' commit spans but not their queries.
- Revisit: the libraries' storage polls, which keep the driver's instrumentation off; a mirror that follows every workspace, or keeps a cursor; published library releases, which would replace git revisions; and a smoke test on plain PostgreSQL, with tokens signed by the test.
Action items¶
- The template, its checks in
make validate, and CI's template job (RFC-0001 phase 5). -
make smoke-app, run by CI after the smoke test of everything on local Supabase.
Amendment (2026-09-29): a script bumps the libraries' revisions¶
The maintainer decided to keep the libraries on GitHub rather than publish them to PyPI for now (artifactr#23), so the template keeps pinning each by git revision, and published releases stay a reason to revisit. Bumping a pin by hand meant copying three commit SHAs into copier.yml and remembering make docs-reference, and the pins drifted: when the script below was added, all three were behind their libraries' main. So:
scripts/bump-libraries(make bump-libraries) moves the pins. It resolves each library'smainwithgit ls-remote, rewrites the*_revdefaults incopier.yml, and regenerates the reference pages that show them.copier.ymlis the only place the pins are written:template/pyproject.toml.jinjareads them, and the pages are generated from them. It prints each change as old → new, with a URL comparing the two commits, for the pull request's description.LIBRARY=REVarguments move only the libraries they name, to a tag, a branch or a commit; a commit is checked by fetching it alone from the library's repository.- evalr follows reflexr. Building the script found that the template's evalr pin can't follow evalr's
main. The template installs reflexr's[evals]extra, and uv resolves reflexr's requirement on evalr with reflexr's own[tool.uv.sources], which pins evalr to a commit; an application pinning evalr anywhere else has two URLs for one package, and uv refuses to lock it. The decision above, that an application declares evalr's source itself, stands, and the source must name the commit reflexr pins. So the script pins evalr to the commit the new reflexr'spyproject.tomlpins, says so when evalr'smainis elsewhere, and refuses an evalr revision that differs. evalr moves when reflexr moves its pin. --checkreports without writing, and exits 1 when a pin is behind. Neithermake validatenor CI runs it: every pull request would fail as soon as a library merged something, for a reason unrelated to the change.- A person runs it, and a pull request carries the bump. No scheduled workflow opens bump pull requests: a workflow's token can open one only when the repository allows GitHub Actions to create pull requests, and that setting stays off. The pull request's Template and Smoke jobs, which generate applications on the new pins, are the test. When a library's
mainbreaks the template, the same pull request fixes the template, or holds that library at its last good commit withLIBRARY=REV.
Amendment (2026-09-29): the reactor stops gracefully¶
- The reactor stops first, and never in the middle of a transaction.
Automation.lifespanruns reflexr'sreactor.serve(stop=, grace=)(reflexr#69) and, at shutdown, sets the event and awaits it before the feedback mirrors, the MCP server and the database close. Cancelling the reactor's task, as the template did, could land in a transaction and leave SQLite locked, which it caught and logged (#17). - The shutdown fits the stop period. The server lets open requests finish for
DRAIN(2 seconds,app.py), then the reactor gives running actionsSTOP_GRACE(5 seconds,automation.py); both, with the rest of the shutdown, end within the app profile'sstop_grace_period, Docker's 10 seconds, which its compose file states.