---
name: convention-secrets
type: convention
license: CC BY-NC-SA 4.0
description: >-
  How credentials and secrets are named, stored, and defaulted across Aled's
  projects — env vars only with .env.local gitignored and .env.example
  committed, service-namespaced names, fail-loud on a missing variable, the
  safety-critical default that a variable selecting a sandbox versus a
  real-consequence target defaults to the safe one, and never pasting a live
  credential into a tracker or a chat. Read before wiring credentials into any
  project, adding an environment variable, or writing code that chooses between
  a practice and a live target, so the shape is set forward once rather than
  re-derived per project. The ticket-shaping side — refining credential-gated
  build work into a human setup leaf plus a build leaf — lives in
  convention-ticket; this convention covers the secrets themselves. Sits beside
  convention-vercel and convention-mux; a first draft, grown as credential
  setups are repeated.
---

# Secrets and credentials

How credentials and secrets are handled across Aled's projects — named, stored, injected, and defaulted. Each project had re-derived this independently (the Anthropic key in Vercel env for Luna, per-project Mux credentials in `convention-mux`, `GITHUB_TOKEN` for the agents work), so the shape was implicit and inconsistent. This sets it forward as one standard rather than inheriting whatever a given repo happens to do — the crypto-era bot.Trader repo in particular is a source to borrow from, not a golden approach.

The ticket-shaping side of credential work — that credential-gated build work is refined into a `human` setup leaf plus a build leaf joined by a blocked-by relation, never a single leaf with the prerequisite in prose — lives in `convention-ticket` (*Obligations belong in structure, not prose*). This convention is about the secrets themselves once that work is under way.

## The standard

* **Env vars only.** Credentials are read from environment variables — never hardcoded in source, never committed to the repo.
* **`.env.local` local, `.env.example` committed.** `.env.local` (gitignored) holds the real values for local work; `.env.example` is committed carrying every variable name with a placeholder value, so the required shape is discoverable without exposing a secret.
* **Namespaced names.** Variable names are namespaced by venue or service, in uppercase snake case — `OANDA_API_TOKEN`, `OANDA_ACCOUNT_ID`, `OANDA_ENVIRONMENT` — so their origin is obvious and two services never collide on a bare name.
* **Fail loud on a missing variable.** Consuming code that finds a required variable unset stops and names which one is missing. No silent degradation, no empty-string fallback, no guessed default that lets the process limp on misconfigured.
* **Never expose a live credential.** A real credential is never pasted into Linear, a ticket comment, a document, or a chat session. Where a value must be handed over, it goes through the environment or a secret store, not the tracker.

## The safety-critical default

Where an environment variable selects between a **sandbox** and a **real-consequence** target, it defaults to the safe one, and the dangerous one requires an explicit, deliberate opt-in. No code path reaches a real-consequence target by omission or by a misread of an unset variable.

The type case is bot.Trader: `OANDA_ENVIRONMENT` defaults to `practice`, and reaching a live account requires the variable to be set explicitly to the live value. The principle is not confined to trading — it holds for any variable that chooses between a safe and a consequential target (a test versus a production database, a dry-run versus a live send). Where such a switch exists, the safe side is the default, the consequential side is opt-in, and the intent is stated at the point the variable is read.

## Command blocks that report state, never the secret

Some credential work cannot be done by the agent and is handed to a person as a copy-pasteable command block to run and paste the output back — the host case in `convention-host-access` is the type example. Such a block **never prints a secret's value**. It reports only:

* **presence** — `SET` or `UNSET` for each variable, never the value.
* **last-4 only** where an identifier has to be confirmed — the last four characters of an account id or a key fingerprint, enough to tell two credentials apart, never enough to reconstruct one.
* **the expected-good result stated per command**, so the person can tell whether it worked from the report alone, without the value ever being echoed.

The pasted output lands in a chat or a ticket — exactly the surface a live credential must never reach (above). Reporting `SET`/`UNSET` and last-4 keeps the diagnostic value while keeping the secret off that surface. Its shape:

```
# reports presence, never the value
[ -n "$OANDA_API_TOKEN" ] && echo "OANDA_API_TOKEN: SET" || echo "OANDA_API_TOKEN: UNSET"
echo "OANDA_ACCOUNT_ID last-4: ${OANDA_ACCOUNT_ID: -4}"
```

(Worked pattern: the 2026-08-13 bot.Trader host deploy completed this way — the block printed `SET`/`UNSET` and the last-4 of the account id, never a token — APP-862.)

### The default-expansion anti-pattern — never build the reported string from `:-`, `:=`, or a bare `$VAR`

The safe form above tests presence and echoes a **fixed literal** (`SET` / `UNSET`). Do **not** build the reported string out of a shell default-expansion of the secret itself. The specific trap, which has already leaked a live credential:

```
# ANTI-PATTERN — never do this. It prints the secret on the success path.
echo "${OANDA_API_TOKEN:+SET}${OANDA_API_TOKEN:-UNSET}"
```

It reads as a matched, symmetrical conditional and is not one. `${VAR:+SET}` substitutes the literal `SET` when `VAR` is set — correct. But `${VAR:-UNSET}` substitutes the **variable's own value** whenever `VAR` is set, and `UNSET` only when it is empty or unset. So when the variable *is* set — the success path, exactly when it matters — the line prints `SET` immediately followed by the secret's value. Three properties make it slip past a reviewer, and each defeats the check independently:

* **It looks symmetrical** — `:+` and `:-` side by side read as a paired if / else.
* **It fails only on the success path** — the value leaks precisely when the credential is present, i.e. on every real run.
* **The obvious test exercises the safe path** — running it against an *unset* variable prints `UNSET` and looks correct, so a quick test confirms the wrong thing.

The same trap is present with `${VAR:=…}` (assign-and-substitute) and with a bare `$VAR` anywhere in the echoed string. Only the fixed-literal form — `[ -n "$VAR" ] && echo "VAR: SET" || echo "VAR: UNSET"` — is safe; the reported string must be built from literals and last-4 only, never from an expansion that can yield the value.

**Why naming the safe outcome was not enough.** The author who leaked was *deliberately following* the "report `SET` / `UNSET`, never the value" rule and derived this shell from it in good faith. A rule that specifies an *outcome* without also giving a canonical *form* inherits the failure rate of every author's re-derivation — so the safe form is given above as copy-pasteable text **and** the anti-pattern is named here, in the file an author reads *before* writing a block, rather than left to be re-derived per session. (Worked case: 2026-08-15 — this idiom printed a live OANDA token into an Owner-verify chat transcript during an APP-942 session; the credential had to be rotated across the host env and GitHub Actions secrets — APP-959.)

**Dry-read before send.** Any command block handed to a person is read once, specifically for value-printing, before it goes out: scan every `echo` / `printf` and every parameter expansion in the reported string, and confirm none can substitute a secret's value on any path. This is a distinct check from writing the block correctly — it is the one that catches a block that was written wrong.

## Relationship to the other conventions

`convention-vercel` covers where env vars live for a Vercel deployment and the naming used there; `convention-mux` holds the per-project Mux credential sequence; `convention-github` covers agent tokens and repo secrets. Those are the per-surface applications; this convention is the standard they share. Where a surface convention and this one ever disagree on a detail, raise it — the intent is one forward standard, not a per-surface drift.

## Tone

Everything written follows `cos.tov`: calm, precise, sentence case, British spelling, no exclamation marks, outcome before adjective.

## Quick checklist before wiring a credential

- [ ] Read from an env var, never hardcoded or committed?
- [ ] `.env.local` gitignored, `.env.example` updated with the variable name and a placeholder?
- [ ] Name namespaced by venue or service, in uppercase snake case?
- [ ] Consuming code fails loud on a missing variable, naming which one?
- [ ] Does any variable select between a safe and a consequential target? If so, safe is the default and the consequential side is explicit opt-in?
- [ ] No live credential pasted into Linear, a comment, a doc, or a chat?
- [ ] Any command block handed to a person to run reports `SET`/`UNSET` and last-4 only, with the expected result per command — never echoes a secret's value onto a chat or ticket?
- [ ] Does that block build its reported string only from fixed literals (`SET`/`UNSET`, last-4) — never from `${VAR:-…}`, `${VAR:=…}`, `${VAR:+…}` paired with a `:-` half, or a bare `$VAR` that would substitute the secret's value on the success path? And has it been dry-read once, specifically for value-printing, before sending?
- [ ] Credential-gated build work split into a `human` setup leaf + build leaf per `convention-ticket`, not a prose prerequisite?
