Skip to content

ADR-0018: Secrets and config access

Status: Accepted Date: 2026-04-21

ADR-0013 bar item 3 requires: “Secrets never touch code, logs, or stored context. API keys, tokens, and credentials are loaded from environment or platform bindings only. Error messages are redacted before they reach any log, any event, any stored memory, or any LLM prompt. Every new component that touches a secret gets a test asserting the secret is absent from its error paths.”

Today the codebase has zero consumers of configuration. That is changing immediately: the next planned step (LLM adapter) needs an Anthropic API key, the logger reads a level, and every subsequent component will need at least one of the two.

Two questions, in order of impact:

  1. What’s the smallest access surface that satisfies bar 3? A wrong answer here means either (a) components reach into env directly with env.SOME_KEY and no guard, losing the “typed and bounded” property, or (b) we build a 500-line config system for a system with one secret, which is exactly the bar-12 warning (“no feature that blocks on an open question ships”).

  2. Is there an abstraction worth introducing now vs. deferring? Provenance tracking (is this value a secret or a public var?), caching, hot-reload, schema-validated complex config — all tempting, all speculative for a component surface of one today.

ADR-0014 commits the platform to Cloudflare Workers. Both secrets and public env vars arrive on the Worker’s env argument as string-valued properties. There is no runtime distinction between the two — discipline lives in wrangler.toml and in developer practice.

Ship @agent-platform/config as a small set of typed accessors, nothing more.

Public API:

  • readSecret(env, key) — required string; throws ConfigError (severity fatal) if missing or empty. Error message names the key but never contains the value.
  • readRequiredEnvVar(env, key) — same contract as readSecret for non-secret required values.
  • readOptionalEnvVar(env, key, fallback) — infallible; returns the value or the fallback.
  • hasSecret(env, key) — boolean probe for “is the credential available?”
  • redactSecret(value) — returns '[REDACTED]'; for explicit placeholder use in log payloads whose key does not match the logger’s default redaction list.
  • Env type = Readonly<Record<string, string | undefined>>. Matches both Cloudflare env and Node process.env.

Deliberately omitted:

  • No caching. env is passed per-invocation; caching it in module scope on Workers is a correctness hazard.
  • No provenance tracking (is this a secret or a var?). The distinction lives in wrangler.toml and in which accessor the code calls.
  • No complex schema validation. Structured config goes through @agent-platform/schemas, not here.
  • No readOptionalSecret. An optional secret is a design smell — features are either enabled (readSecret succeeds) or disabled (hasSecret branches before entering the feature path).
  • No hot-reload or rotation. On Workers the rotation path is wrangler secret put + redeploy. There is nothing to subscribe to.
  • Bar 3 is enforceable at call sites. readSecret is the one path in; its error paths are tested against the specific anti-pattern of leaking the value. The grep for review is: “does this component call env.KEY directly?” A hit is a violation.
  • Every readSecret call site can be traced. Because it’s a named function rather than an idiom, search finds every consumer immediately. That list is what an auditor looks at to answer “where do we touch secrets?”
  • Error messages never contain values. Tests verify this specifically. The most valuable test is “error does NOT leak the env object by reference in context” — a guard against a future maintainer “helpfully” adding env to the error for debugging. That one pattern would reveal every secret on every missing-secret error.
  • The surface is small enough to audit in one read. The whole module is under 200 lines with aggressive commenting. Reviewers can understand the security guarantees without familiarity with a larger framework.
  • When real pressure appears, the ADR gets revised. Caching, provenance, schema validation, rotation — each of these is a legitimate future concern. None of them is speculative-enough that we should pay the cost before we have a consumer. When the LLM adapter ships and we discover we need something this module does not have, we supersede this ADR rather than quietly adding to the package.
  • New workspace package: packages/config/. Depends on @agent-platform/errors. No runtime dependencies.
  • 24 new tests. Workspace total: 163.
  • Future components calling env.FOO directly is a bar-3 violation. Reviewers cite this ADR.
  • No package; components read env.FOO directly. The status quo. Rejected: bar 3 requires a test surface for secret-touching components, and “every component does ad-hoc checks” is not a surface — it’s N surfaces with N inconsistencies. Also no typed error path, so every component invents its own “missing secret” reporting.
  • A full config system (environments, precedence, schema-validated config objects, hot-reload). ~500+ lines, two or more ADRs, probably two new packages (loader + types). Rejected because it would encode assumptions about components that do not yet exist. Every assumption that turns out wrong is an expensive retrofit. Build this when the second real use case justifies the abstraction.
  • Provenance-tracking from day one. The shape would be Secret<T> vs Public<T> wrappers, with .reveal() to get the string. Clean in isolation; adds real reader friction for a system with one secret. Rejected as premature — can be layered on later if we find components accidentally logging things they shouldn’t.
  • Pick an existing library (envalid, ts-dotenv, etc.). These are Node-centric, assume process.env, often don’t work well on Workers without shims, and add a dependency with its own release cycle to audit. What we need is ~50 lines of code; a dependency’s audit surface dwarfs the code it replaces.
  • Pull everything from wrangler.toml at build time. Cloudflare’s Secrets Store integration and vars-in-toml each solve different problems; our accessor is deliberately about runtime access to whatever the platform provides. Build-time injection (for public values) and runtime binding (for secrets) is the Workers standard split — we respect it rather than reinvent.
  • readOptionalSecret(env, key) returning string | undefined. Rejected as flagged in the Decision section: an optional secret is a design smell that the hasSecret + explicit-branch pattern expresses more clearly. Nothing stops a future ADR from adding the function if a case arises that justifies it.