This log records significant architectural, technical, and design decisions made throughout the lifecycle of AgentKit.
- Date: 2026-09-28
- Status: Accepted
- Context: AgentKit is designed as a production-grade showcase of core agentic engineering. Many frameworks (LangChain, LlamaIndex, CrewAI) introduce heavy abstractions, opaque control flows, and unnecessary dependencies.
- Decision: Build the ReAct reasoning loop, state machine, tool registry, and memory interfaces directly using Python standard libraries and async primitives without third-party agent frameworks.
- Consequences:
- Full control over loop logic, step limits, latency, error recovery, and security checks.
- Minimal dependency footprint and clean, debuggable call stacks.
- Requires maintaining our own schema translation and provider integrations.
- Date: 2026-09-28
- Status: Accepted
- Context: The agent must support multiple model providers (Google Gemini, OpenAI) interchangeably without modifying core agent logic.
- Decision: Define a domain-level
LLMClientprotocol/ABC inagentkit/llm/base.pywith neutral data models (Message,ToolCall,LLMResponse,Usage). Concrete adapters (GeminiClient,OpenAIClient,FakeLLMClient) translate between these neutral types and vendor SDKs. - Consequences:
- Seamless model swapping via configuration (
LLM_PROVIDER=geminioropenai). - Easy testing using
FakeLLMClientwith deterministic scripted responses. - Provider-specific quirks (e.g. Gemini FunctionDeclaration vs OpenAI function tool format) are isolated within adapter files.
- Seamless model swapping via configuration (
- Date: 2026-09-28
- Status: Accepted
- Context: Agent workloads have two conflicting access patterns: fast, short-lived conversational state with automatic expiration, and durable, queryable historical audit logs for compliance and tracing.
- Decision:
- Redis: Stores ephemeral conversation messages per
session_idwith a sliding TTL (default 1 hour) and maintains high-throughput sliding window rate-limiting counters. - PostgreSQL: Stores durable run metadata (
runstable) and granular step-by-step telemetry (stepstable) managed via async SQLAlchemy 2.0 and Alembic.
- Redis: Stores ephemeral conversation messages per
- Consequences:
- Sub-millisecond session state retrieval and automatic cleanup of abandoned sessions.
- Full structured telemetry preserved for debugging, auditing, and analytics without polluting fast memory.
- Date: 2026-09-28
- Status: Accepted
- Context: Defining tools manually in JSON Schema is error-prone and causes drift between code and documentation.
- Decision: Provide a
@tooldecorator that inspects native Python type hints and docstrings to automatically generate standard JSON Schemas and dynamic Pydantic argument validation models. - Consequences:
- Tools are idiomatic, typed Python functions.
- Argument validation is strictly enforced before tool execution.
- Structured validation errors can be fed back to the LLM for self-correction without crashing the runtime.
- Date: 2026-10-01
- Status: Accepted
- Context: The suite originally exercised
PostgresMemoryStore,PostgresTraceSinkandRedisMemoryStoreexclusively against in-memory SQLite andfakeredis. Both stand-ins behave differently from their targets in ways that matter: SQLite rendersuuid/json/timestamptzasCHAR/TEXT, ignores foreign keys unlessPRAGMA foreign_keys=ON, andfakeredisis a pure-Python reimplementation that never exercises the hiredis codec or real TTL expiry. CI declared PostgreSQL and Redis service containers that no test connected to. A concurrency test written against live Redis then showed the sliding-window rate limiter admitting 19 of 20 simultaneous requests against a limit of 5, becauseZCARDwas read andZADDwritten in separate round trips with anawaitbetween them. - Decision:
- Keep the hermetic tier as the default (
pytest, SQLite +fakeredis+FakeLLMClient) so the suite stays fast and dependency-free. - Add an opt-in
real_infratier (tests/real/, enabled byAGENTKIT_REAL_INFRA=1) that runs the same components against live PostgreSQL and Redis, driving Alembic through the real dialect and running the real FastAPI lifespan. - Enforce the sliding-window limit with a single Lua script so trimming, counting and insertion are one indivisible operation, with a non-atomic fallback only when a deployment forbids scripting.
- Give fakeredis a Lua runtime (
lupa) so the hermetic tier exercises the same code path as production. - Split CI into a hermetic matrix job and a dedicated live-infrastructure job so the service containers are actually exercised.
- Keep the hermetic tier as the default (
- Consequences:
- Dialect-specific and concurrency behaviour is covered rather than assumed; a rate-limit bypass of this class cannot regress unnoticed.
pytestgains an explicit opt-in for external services, documented in the README and.env.example.- The live tier truncates the
runs/stepstables and deletes onlysession:*/ratelimit:*keys, so it requires a dedicated test database.
- Date: 2026-10-01
- Status: Accepted; superseded in part by ADR-008 (function-response role) and ADR-013 (signature encoding). Two claims below were inaccurate when written and are corrected inline — see the notes on the serialization and model bullets.
- Context: Newer Gemini reasoning models (e.g. Gemini 2.x and 3.x) employ internal reasoning/thinking steps before invoking tools. The Google GenAI API emits an opaque
thought_signatureon candidatePartobjects containingfunction_call. When returning subsequent conversation turns withfunction_responseparts, the API strictly enforces that the prior assistant turn echoes back the exactthought_signature. Omitting this signature produces400 INVALID_ARGUMENT — Function call is missing a thought_signature in functionCall parts. Additionally, older model identifiers likegemini-2.5-flashreturned404 NOT_FOUNDfor new users, andRole.TOOLmessages in the Gemini adapter previously usedrole="user"instead of the officialrole="tool"and mapped tool calls by arbitrary UUID IDs rather than declared function names. - Decision:
- Update
ToolCallneutral model to include an optionalthought_signature: bytes | None = Nonefield. - In
GeminiClient, extract and preservethought_signaturefrom candidate parts and forward it in subsequent assistant tool calls usingtypes.Part(function_call=..., thought_signature=...). - In
GeminiClient, resolveRole.TOOLmessage identifiers to declared function names via assistant history, and usetypes.Content(role="tool", parts=[...])adhering to official Google GenAI SDK standards. - Persist messages with an explicit encoding for binary fields. Correction: this ADR originally claimed that
m.model_dump(mode="json")base64-encodesbytestransparently. It does not: Pydantic's defaultser_json_bytes="utf8"decodes them as UTF-8 and raises on any non-UTF-8 signature, which aborted the whole save. Superseded by ADR-013, which tags and base64-encodes the signature at the persistence boundary and decodes it in aToolCallvalidator. - Standardize the active default Gemini model across application settings, factory, documentation,
.env.example, anddocker-compose.yml. Correction: this ADR originally namedgemini-2.0-flash; the active default isgemini-3.1-flash-lite(Settings.LLM_MODEL,create_llm_client,GeminiClient).
- Update
- Consequences:
- Full compatibility with active and future reasoning-capable Gemini models.
- Clean multi-turn tool calling without 400 or 404 errors.
- Zero disruption to OpenAI or FakeLLM adapters since
thought_signaturedefaults toNone.
- Date: 2026-10-01
- Status: Accepted
- Context: SPEC Section 13 mandates that
docker compose upmust start a working system, run migrations, and seed the demo database (productsandorders). The previous migration service only ranalembic upgrade head, requiring manual or separate execution of demo seeds, whilescripts/seed_demo_db.sqllacked conflict clauses and would crash with primary key collisions if re-executed on subsequent compose restarts. - Decision:
- Add
ON CONFLICT (id) DO NOTHING;toINSERT INTO productsandINSERT INTO ordersinscripts/seed_demo_db.sqlfor strict ANSI-compliant idempotency. - Implement a dedicated
scripts/seed.pyexecutable that readsscripts/seed_demo_db.sqland applies it asynchronously using the configuredDATABASE_URL. - Update
Dockerfileto copyscripts/into the production runtime container. - Update
docker-compose.ymlmigrations command to executealembic upgrade head && python scripts/seed.pyprior to theapiservice starting. - Delegate
examples/demo.pyseeding toscripts.seed.seed_databaseto eliminate code duplication.
- Add
- Consequences:
docker compose upboots directly into a fully seeded, turnkey environment ready for analytical queries viasql_readonly.- Database seeding is safely re-entrant across container restarts without data duplication or integrity errors.
- Date: 2026-10-01
- Status: Accepted
- Context: The official Google Gemini GenAI SDK requires function execution responses in multi-turn chat history to be represented with
role="user"wrappingtypes.Part.from_function_response(...). Settingrole="tool"triggers an upstream400 INVALID_ARGUMENTerror because the Gemini API expects function outputs to be submitted as user turns containing function responses. Furthermore, Gemini API access tiers and quotas vary across model tiers;gemini-3.1-flash-liteprovides ultra-low latency, robust tool calling, and high throughput without encountering regional or tier rate limitations. - Decision:
- Update
GeminiClient.chat()to wrap function responses withrole="user"usingtypes.Part.from_function_response(name=tool_name, response={"result": msg.content}). - Standardize the default active Gemini model to
gemini-3.1-flash-liteacrossSettings,GeminiClient,create_llm_client,.env.example,docker-compose.yml, and documentation. - Wire
RetryingLLMClientwith configurableLLM_MAX_RETRIES(default 3) and exponential backoff intocreate_llm_client_from_settings().
- Update
- Consequences:
- Eliminates 400 Invalid Argument errors on multi-turn ReAct tool execution with Gemini.
- Consistent model behavior across local, containerized, and CI environments.
- Automatic resilience against transient 429 quota exhaustion and 5xx network spikes.
- Date: 2026-10-01
- Status: Accepted
- Context: A whole-project audit found several paths where a misconfiguration or a transient fault was silently converted into a working-looking success.
Settings.API_KEYSdefaulted toak_test_key_12345, a key published in the repository, so any deployment without a.envcame up openly accessible.get_llm_clientcaught every exception from the provider factory and returnedFakeLLMClient, so a missingGEMINI_API_KEYproduced HTTP 200 with scripted text instead of a 503. Theapp.state.agentoverride was indistinguishable from the memoized first assembly that AUD-01 removed.RetryingLLMClientwas wired by AUD-04 but with only a retry count: the backoff delays were fixed defaults rather than configurable settings.TieredMemoryStoreforwarded only the run methods, so duck-typedget_run_tracedetection in the trace endpoint fell through to a trace sink that may hold no rows. - Decision:
- Make
API_KEYSrequired with no default;Settingsvalidation rejects an empty or whitespace-only value. - Remove the
FakeLLMClientfallback from the request path. Provider construction failure raisesServiceUnavailableError(503).LLM_PROVIDER=fakeremains an explicit opt-in resolved by the factory. - Keep retry wrapping in
create_llm_client_from_settings, now configurable throughLLM_MAX_RETRIES,LLM_RETRY_BASE_DELAY_SandLLM_RETRY_MAX_DELAY_S. An explicitapp.state.llm_clientoverride still wins and is not wrapped, keeping tests deterministic. - Assemble the
Agentper request from resolved dependencies; an explicitapp.state.agent_overrideremains the only override mechanism. - Forward
get_run_traceandlist_runsthroughTieredMemoryStoreto the underlying run store, returning empty lists when the run store does not support them.
- Make
- Consequences:
- A misconfigured deployment fails loudly at startup or on the first request instead of serving fake or unauthenticated traffic.
- No request is served by a dependency graph assembled for an earlier request.
- Transient provider failures (429/5xx/network) are retried with configurable exponential backoff and jitter; authentication and other 4xx failures are not.
- The test suite seeds a fixed
API_KEYSbefore importingagentkit.api.main, whose module-levelcreate_app()would otherwise fail validation.
- Date: 2026-10-01
- Status: Accepted
- Context:
http_fetchresolved the hostname, checked the resolved addresses, then lethttpxresolve the hostname again when connecting. That time-of-check/time-of-use gap lets a short-TTL DNS answer hand back a public address during validation and a loopback or169.254.169.254address during connection. DNS resolution was synchronoussocket.getaddrinfocalled directly from async code, stalling the event loop, and the full response body was buffered before being truncated tomax_chars, so a large payload could exhaust memory. Separately,sql_readonlyappliedfetchmany(row_limit)client-side only, which still makes the database plan and execute the full scan, and its keyword scan rejected any query containing a write keyword even inside a string literal, while its bare#check rejected the legitimate PostgreSQL JSON operators#>and#-. - Decision:
- Resolve DNS via
asyncio.to_threadand reuse the resolved addresses for both validation and the connection decision. - After connecting, read
server_addrfrom the response's network stream and reject the request if the peer is not among the validated addresses. - Stream the body with an explicit byte budget instead of buffering then slicing.
- Apply the row limit in SQL by wrapping an unbounded statement, leaving a caller-supplied top-level
LIMITuntouched, and pushstatement_timeoutto PostgreSQL where the dialect supports it. - Mask string literals and quoted identifiers before the keyword scan, and treat
#as a comment marker only when it does not begin a JSON path operator.
- Resolve DNS via
- Consequences:
- DNS rebinding to a private or metadata address is refused after the connection is established, not merely before it.
- No blocking DNS on the event loop, and response memory is bounded regardless of
max_chars. - Legitimate read-only queries that merely mention a write keyword as data, or use JSON path operators, are accepted; write statements remain blocked.
- Date: 2026-10-01
- Status: Accepted
- Context: Two fallbacks reintroduced the exact races their primary paths had been written to eliminate, and several error boundaries leaked raw SDK exceptions. The rate limiter's non-scripting fallback trimmed and counted in one pipeline then inserted in a second, so concurrent requests between the two observed the same pre-insert cardinality and were admitted together. In both LLM adapters the
tryblock wrapped only the SDK call, so response parsing escaped as raw provider exceptions: readingraw_response.textraises on a blocked prompt, an emptychoices/candidateslist raisedIndexError, and malformed tool-call JSON was rewritten to{"raw": ...}, which later failed argument validation with a misleading "missing required field" message.RetryingLLMClient.__getattr__recursed infinitely when_clientwas absent, and/healthechoedstr(exc)from SQLAlchemy and Redis on an unauthenticated endpoint. - Decision:
- Implement the non-scripting rate limit fallback with
WATCH/MULTI/EXECoptimistic locking, denying the request if contention never settles within the retry budget. - Wrap response parsing in both adapters inside the error boundary, re-raising domain errors unchanged and mapping anything else to
LLMProviderError. Report malformed tool-call JSON as such instead of substituting a placeholder object. - Use full UUIDs for generated
tool_call_idvalues; a truncated 32-bit id risks collisions within a long session. - Guard
RetryingLLMClient.__getattr__against re-entering itself when_clientwas never assigned. - Return fixed client-facing strings from
/healthand log the underlying driver error instead.
- Implement the non-scripting rate limit fallback with
- Consequences:
- The fallback enforces the documented limit under concurrency, erring toward denial where it cannot decide.
- Every provider fault is matchable on
LLMProviderError, so HTTP status mapping stays correct for malformed as well as failed responses. - The unauthenticated health endpoint no longer discloses internal hostnames, ports or database names.
- Date: 2026-10-25
- Status: Accepted
- Context:
sql_readonlyconnected withSettings.DATABASE_URL, the application's own superuser role, so the entire "read-only" guarantee rested on a keyword regex. That regex could be blinded by PostgreSQL quoting forms it did not understand: a dollar-quoted body ($$'$$) or anE'\''escape string hid quotes from the literal masker, and single-statement built-ins that read server files, execute dynamic SQL or mutate state (pg_read_file,query_to_xml,setval,pg_terminate_backend,lo_import,dblink_exec) never reached the keyword denylist at all. Separately,LIMIT ALLand any oversizedLIMITwere treated as "already bounded" and passed through untouched, and the cap was only enforced byfetchmanyon the client. - Decision:
- The tool connects with a dedicated, non-superuser role from
DATABASE_READONLY_URL, provisioned byscripts/init_readonly_role.pywithCONNECT,USAGEandSELECTonly plusdefault_transaction_read_only = on. The application itself keeps usingDATABASE_URL. - A missing read-only URL fails closed (
ToolSecurityError) rather than falling back to the application role; each session additionally runsSET TRANSACTION READ ONLYand, for the configured engine, refuses a superuser connection before executing the statement. - Statement validation remains as defence in depth: the masker now understands dollar quotes and
E'...'escapes, and a function-call denylist covers the side-effecting and file-reading built-ins. - A trailing
LIMIT/FETCH FIRSTbound is clamped to the configured maximum (LIMIT ALL,LIMIT NULLand oversized counts included), smaller caller bounds are preserved, and the statement is wrapped only when a row-locking clause forbids appendingLIMIT.
- The tool connects with a dedicated, non-superuser role from
- Consequences:
- Writing through the tool requires two independent failures, one of them a database privilege error; a validation gap alone is no longer enough.
- Deployments must provision the role (
python scripts/init_readonly_role.py) or the SQL tool returns a structured failure. Compose does this in the migrations service. - An injected engine (demo, tests) is explicitly caller-owned and still gets the read-only transaction, but not the superuser check.
- Date: 2026-10-25
- Status: Accepted
- Context: Session persistence had three independent failure modes.
RedisMemoryStore.save_messagesserialized withmodel_dump(mode="json"), and Pydantic's defaultser_json_bytes="utf8"raisesUnicodeDecodeErroron any non-UTF-8 Geminithought_signature— so one signature aborted the write of the entire conversation.get_messagesturned any deserialization error into[], which made a corrupt history indistinguishable from a new conversation and let the next run overwrite it with the truncated one. Andtruncate_messageswas never wired to a setting (histories grew without bound), while the naive tail cut it implemented could start the history on atoolmessage whose assistanttool_callsturn had been dropped, a shape providers reject. - Decision:
- Binary signatures are persisted with an explicit tagged base64 object (
{"base64": "..."}), encoded in the store and decoded by aToolCallfield validator. Bare strings are still decoded as the legacy raw UTF-8 form so old histories remain readable. - A corrupt or unreadable history raises
SessionHistoryError(503) instead of returning[].Agent.runlogs it with the session id, marks the run failed and never callssave_messages. SESSION_MAX_MESSAGES(default 40) bounds the retained history and is wired throughget_memory_store. Truncation advances the cut point past leading tool results, so the kept history is always provider-valid.
- Binary signatures are persisted with an explicit tagged base64 object (
- Consequences:
- A conversation can no longer be silently replaced by a context-free one after a decode error or a Redis blip; the caller gets a 503 it can retry.
- Sessions are bounded in Redis, and the bound is enforced at the only writer.
- A stored history written by an older version stays readable, at the cost of an explicit legacy branch in the validator.
- Date: 2026-10-25
- Status: Accepted
- Context: The adapter emitted one
Content(role="user")per tool result. Gemini validates that a turn answering a function-call turn carries the same number offunction_responseparts as the call turn hadfunction_callparts, so every parallel tool call failed with400 INVALID_ARGUMENT. The provider'sFunctionCall.idwas discarded in favour of a fresh UUID andFunctionResponse.idwas never set, so two parallel calls to the same tool could not be correlated at all. Separately, response parsing never inspectedPart.thought, so internal reasoning text was concatenated into the answer, the persisted assistant turn and the API'sfinal_answer; theraw_response.textfallback reintroduced it even after parts were filtered. - Decision:
- Consecutive
toolmessages are grouped into a singleContent(role="user")with onefunction_responsepart per call, each carrying the id of the call it answers. - The provider's
FunctionCall.idis preserved end to end (a fresh UUID remains the fallback when the provider omits it), and is forwarded on the outboundFunctionCall. - Parts with
thought=Trueare excluded from the answer text but still yield their function call and thought signature. Theraw_response.textfallback is used only when the response carried no inspectable parts.
- Consecutive
- Consequences:
- Parallel tool calls work against Gemini, and identical parallel calls can be matched to their results in call order.
- Internal reasoning never reaches the client through
final_answeror stored history.
- Date: 2026-10-25
- Status: Accepted
- Context: Neither adapter inspected the provider's own verdict. Gemini reports a blocked prompt through
prompt_feedback.block_reasonand a stopped generation throughcandidate.finish_reason(SAFETY,RECITATION,PROHIBITED_CONTENT,BLOCKLIST,SPII); OpenAI reportsfinish_reason == "content_filter"or a populatedmessage.refusal. All of these arrive as an ordinary 200 response with no content and no tool calls, and the agent only branched on "are there tool calls?", so each was returned asSUCCEEDEDwith an emptyfinal_answer. - Decision:
LLMResponsecarries an optionalrefusal_reason, populated by each adapter from the provider's block/refusal signals.RunStatusgainsrefused. The agent maps a refusal to that status with the provider's reason, and maps an empty completion (no text, no tool calls) tofailedwith an explicit "empty provider response" reason.- A
SUCCEEDEDrun always has a non-emptyfinal_answer; the assistant turn is only appended to history when there is text.
- Consequences:
- Operator dashboards and clients can distinguish "the model declined" from "the system broke" and from a genuine empty answer.
- The refusal reason is client-visible in
failure_reason, which is safe: it is the provider's category, not prompt content.
- Date: 2026-10-25
- Status: Accepted
- Context:
get_llm_clientcalledcreate_llm_client_from_settingson every request. EachAsyncOpenAI/genai.Clientowns its own HTTP transport and connection pool, and nothing closed them: the lifespan only disposed of Redis and the database engine. Long-running processes leaked sockets and file descriptors and never pooled provider connections. Closing is adapter-specific —genai.Client.close()does not close the async transport used byclient.aio, which needsclient.aio.aclose(). - Decision:
LLMClientgains a concreteaclose()that defaults to a no-op; both provider adapters override it (Gemini prefersaio.aclose(), OpenAI awaitsclose()) andRetryingLLMClientdelegates to the client it wraps.get_llm_clientbuilds the provider once and stores it onapp.state.managed_llm_client; the lifespan closes it before releasing Redis and the database engine.- An explicit
app.state.llm_clientoverride still wins and stays caller-owned: tests and embedders that inject a client are responsible for its lifetime.
- Consequences:
- N requests use one provider client and one connection pool, and shutdown releases it.
- A construction failure is still deferred to the first request and surfaced as a 503, so a missing API key does not stop the process (or the test suite) from starting.
- The retry wrapper remains the outermost layer, so it still wraps the shared client and forwards
aclose.
- Date: 2026-10-25
- Status: Accepted
- Context: The duplicate-tool detector remembered only the immediately preceding call signature, so an alternating pattern (
A,B,A,B, …) reset its counter on every step. The guard never fired and the run burnedmax_steps— each step costing tokens, latency and money — before the step limit stopped it. Separately,verify_api_keyusedapi_key not in settings.api_keys_list: a short-circuiting membership test whose timing reveals how much of a key matched, and which compared the raw header against stripped configured keys, so a padded key behaved differently from an unpadded one while still being logged and rate-limited raw. - Decision:
- The detector keeps a
duplicate_tool_call_window(default 20) of recent(tool, canonical-args)signatures and aborts when one signature reachesmax_consecutive_duplicate_tool_callsoccurrences inside that window. The field keeps its historic name; its description now states the sliding-window semantics. - API keys are stripped on both sides and compared with
hmac.compare_digestagainst every configured key without short-circuiting. The normalised key is what the dependency returns, so rate limiting and logs use the same value.
- The detector keeps a
- Consequences:
- Alternating loops are caught in a handful of steps instead of at the step limit, and the window bound is explicit and configurable.
- A very small window deliberately weakens detection; that is a documented, testable trade-off rather than an accident.
- Key comparison cost depends only on the number of configured keys, not on how many characters matched.
- Date: 2026-10-26
- Status: Accepted
- Context: The compose hardening in AUDIT-18 made Redis start with
redis-server --requirepassand requiredREDIS_PASSWORD, but onlydocker-compose.ymlinterpolation consumed it. Nothing in Python read the variable, so two paths silently kept using an unauthenticated URL:agentkit/api/main.pybuilt its client fromsettings.REDIS_URL, andtests/conftest.pyresolvedREDIS_TEST_URL→REDIS_URL→ settings. Against the hardened stack the live tier failed at fixture setup withHELLO must be called with the client already authenticated(18 errors), and a deployment that set onlyREDIS_PASSWORDleft the API unable to connect. Duplicating the secret insideREDIS_URLworks but puts a credential into a string that ends up in logs, process listings and crash dumps. - Decision:
apply_redis_password(url, password)inagentkit/config.pyis the single rule: when a password is configured and the URL isredis:///rediss://with no userinfo of its own, the password is percent-encoded and embedded; an explicit username or password, a non-Redis URL and a URL without a host are returned unchanged.SettingsgainsREDIS_PASSWORDand completesREDIS_URLin amodel_validator, so every consumer ofsettings.REDIS_URLauthenticates without knowing about the separate variable.tests/conftest.py::redis_test_url()applies the same helper, resolving the password fromos.environfirst and then fromSettings—.envvalues reach the model but notos.environ, and an explicitREDIS_TEST_URLstill wins so CI keeps its own URL.
- Consequences:
- One credential resolution for the API and the live suite; a password-only configuration works, and the secret can stay out of the URL.
- A URL that already carries credentials is never rewritten, so pointing at a passwordless local Redis or a differently-authenticated instance behaves exactly as before.
scripts/check_deploy_credentials.pystill requires a password to be present inREDIS_URL; keeping both set is the documented production shape, and the URL-level check stays the guard against an unauthenticated datastore.
- Date: 2026-10-26
- Status: Accepted
- Context: Compose synthesized
DATABASE_URLandDATABASE_READONLY_URLby interpolating${POSTGRES_PASSWORD}and${POSTGRES_READONLY_PASSWORD}into a URL string. The documented generator isopenssl rand -base64 24, whose output contains/,+and=; interpolation cannot escape them.urllib.parse.urlsplitand every RFC 3986 consumer end the authority at the first/, so the remainder of the password becomes the path and the connection targets a database named after the rest of the secret. SQLAlchemy'smake_urlis lenient about/but still truncates at@, which a password manager may emit. Docs, tests and the credential guard all needed the same rule, and duplicating it three times is how the URL forms drift apart. - Decision:
- Credentials are configured as components:
POSTGRES_HOST,POSTGRES_PORT,POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DBand thePOSTGRES_READONLY_USER/POSTGRES_READONLY_PASSWORDpair for the tool role.REDIS_PASSWORDalready worked this way (ADR-018). build_postgres_url()renders the URL through SQLAlchemy'sURL.create(), which percent-encodes each component, andresolve_postgres_url()is the single precedence rule: an explicitDATABASE_URL/DATABASE_READONLY_URLwins verbatim, otherwise the components build the URL, otherwise the documented development default applies.Settingsapplies that rule in itsmodel_validator, andscripts/check_deploy_credentials.pycalls the same two functions, so the guard audits exactly the URLs the application will use.docker-compose.ymlpasses components only. The datastore host and port are fixed to the compose service names, so a host-side.envpointing atlocalhostcannot leak into a container.REDIS_URLis likewise credential-less andREDIS_PASSWORDsupplies the secret.
- Credentials are configured as components:
- Consequences:
- Any password a generator produces works unchanged, including
/,+,=,@and#; nothing in the system has to avoid valid characters. - A deployment that pins a full URL is unaffected, and the compose stack no longer depends on shell-style nested interpolation, which is easier to reason about and to render with
docker compose config. - The
.env.exampleand README no longer teach a hand-built URL containing a password, so the unsafe pattern is not the first thing a new deployment copies. - This supersedes the last consequence of ADR-018: the credential guard no longer demands a password inside
REDIS_URL, because a credential-less URL plusREDIS_PASSWORDis the documented, tested form.
- Any password a generator produces works unchanged, including
- Date: 2026-10-26
- Status: Accepted
- Context:
scripts/init_readonly_role.pycreated the role or, when it already existed, only changed its password:ALTER ROLE %I WITH LOGIN PASSWORD %L. Everything the role had accumulated stayed in place. A role that was aSUPERUSER, heldCREATEDB, ownedINSERT/CREATEgrants, or was a member ofpg_read_server_filestherefore kept far more than theSELECT-only contract the docs promise. Membership is the sharpest case:SELECT "pg_read_file"('/etc/passwd')passes the statement lexer (it is a legitimate read of a privileged built-in, and the masker blanks quoted identifiers) and only the role's privileges stand between the agent and the host filesystem._enforce_read_only_sessionrejected superusers and nothing else. - Decision:
- Provisioning treats an existing role as untrusted input.
restriction_statements()revokesSUPERUSER,CREATEDB,CREATEROLE,REPLICATIONandBYPASSRLS, then revokes all privileges on the database, thepublicschema, all tables, sequences and functions;_revoke_memberships()drops every direct membership; only then areCONNECT,USAGEandSELECTgranted back. - The script refuses a role that owns the target database, because an owner can grant itself anything, and it verifies the outcome with
_assert_restricted(), raisingReadonlyRoleConfigErrorinstead of reporting a role as provisioned when it is not. - The tool checks the same properties per session, in one round trip:
rolsuper,rolbypassrls, and membership inpg_read_server_files,pg_write_server_files,pg_execute_server_program,pg_signal_backend,pg_monitororpg_maintain, resolved throughpg_has_roleso indirect membership counts. Any hit is aToolSecurityErrorbefore the query runs. - The statement lexer re-exposes plain quoted identifiers to the function denylist (and only there), because PostgreSQL resolves
"pg_read_file"to the same function aspg_read_file. Keyword scans keep masking identifiers, soSELECT "delete" FROM tstays valid.
- Provisioning treats an existing role as untrusted input.
- Consequences:
- Re-running the provisioner is safe and now converges a pre-existing role to read-only instead of inheriting its history.
- A deployment that cannot restrict its role fails loudly at provisioning time, and one that skips provisioning still fails closed at query time.
REVOKE ALL … ON ALL FUNCTIONSalso drops functionEXECUTEgrants the role may have relied on; the tool only needsSELECT, and a function call in a projection still works throughPUBLICexecute grants.