AGENTS.md · git:20261028.8342310 · 2026-10-28 · sha256 ca5e32fedfdd5a29
AGENTS.md git:20261028.8342310A
Immutable. This exact content never changes and is served at /api/v1/blob/ca5e32fedfdd5a29.
# Long Run Hybrid Coach repository rules
This is a Codex-native product. The **product** must not call an LLM API or require an
OpenAI API key -- not `garmin_coach_loop/`, not the CLI, not the gateway, not `evals/`,
not `tests/`. The model doing the coaching is the client's, and it stays the client's.
One exception exists, and it is a separate deployable rather than a loophole: the public
demo under `entrypoints/demo/`, which serves one synthetic athlete to anonymous visitors
and needs a model of its own because there is no client on the other end of it. It runs as
its own Railway service with its own secret, no provider credentials and no volume; it
reuses this package's contracts and projectors instead of reimplementing them; and nothing
in `garmin_coach_loop/` may import it or acquire its dependency. It is not an entry to
anybody's plan -- no store, no owner and no write path is reachable from it -- so invariant
10 below does not bind it, and what it must never become is a second way to coach a real
athlete. `tests/test_demo_boundary.py` holds every clause of this paragraph that a test
can hold.
## Dependencies
The product had no Python dependencies at all until 2026-09-17, and the reason that
stopped being right is worth stating rather than the rule alone. MCP is not this
repository's protocol. Hand-writing it meant owning every revision of somebody else's
wire format, and the revision that made that untenable -- 2026-07-28 -- is not another
value in a version list but a different message shape, so the alternative to a dependency
was a second protocol implementation maintained here forever (issue #352).
So there is exactly one dependency, pinned exactly, in `requirements.txt`:
- **`mcp`**, the official Model Context Protocol Python SDK. It owns the protocol and
wire layer behind `/mcp` -- revisions, the 2025 `initialize` handshake, the 2026-07-28
per-request envelope and `server/discover`, JSON-RPC framing, and serving both eras from
one endpoint. It carries no LLM client and no provider credential, so the rule above is
untouched: the model doing the coaching is still the client's.
Adding a second one is a decision of the same size, not a follow-on from this one. The
question to answer first is whether the thing being imported is a standard this product
merely speaks, or product behaviour it should own. Coaching, validation, the store, the
delivery boundary and identity are the second kind and stay stdlib-only.
**Nothing installs from `requirements.txt`.** It is the intent -- one name, one version,
and the reason. `requirements.lock` is what every build installs: the distributions that
pin resolves to, each with the sha256 of the artifacts allowed to satisfy it, generated by
the command recorded in its own header. The `Dockerfile` and every workflow job install it
with `--require-hashes`, so a substituted byte fails the build rather than entering the
process that holds every connected athlete's credential, and there is deliberately no
second unhashed path to fall back to.
**Upgrading the SDK is a decision with a written trigger**, not a version comparison: a
protocol revision this product should serve, or a security or compatibility fix. Not
latest. The rule, the five-step ceremony and the owner who notices are in
[docs/ops/upgrade-the-mcp-sdk.md](docs/ops/upgrade-the-mcp-sdk.md); `change_gates.py`
classifies a moved pin as a transport change, and `scripts/mcp_protocol_envelope.py`
prints what the candidate SDK would change on the wire before it is rolled.
`python3 -m unittest discover -s tests -p 'test_*.py'` and
`python3 scripts/check_repo_safety.py` now need the lock installed; CI installs it, and so
does the deployed image. `/readyz` reports the version that was actually resolved
(`mcp_sdk_version`), because a pin says what the build asked for and only the running
container says what is answering.
## Repository invariants
1. Keep the product runnable without importing PersonalOS. PersonalOS may be a
migration reference, never a runtime dependency.
2. Keep credentials, raw provider payloads, GPS tracks, FIT activities, private
contexts, plans, approvals, receipts, and provider state outside the
repository. Only anonymous fixtures may be committed.
3. Treat missing, stale, partial, and failed reads as unknown. Never convert
them to zero or evidence of recovery.
4. The model owns coaching judgment. Deterministic code owns data acquisition,
reconciliation with identity-backed actuals, validation, persistence,
approval binding, idempotency, provider writes, read-back, and delivery state.
5. Deterministic validation must not become a shadow coach. Block only when an
action is structurally invalid, contradicts verified state or identity,
invents unsupported precision, crosses an authorization or delivery boundary,
or conflicts with an explicit positive safety signal. Missing, stale, partial,
or failed optional evidence may lower confidence but must not by itself force
rest or human review, or block an otherwise valid coaching action.
6. Before adding a blocking validator, document the exact invariant and concrete
harm, why a warning, model judgment, or narrower capability boundary is
insufficient, which valid workflows remain possible, and the false-positive
cost. Add both a harmful-case regression and a false-positive control. Prefer
provenance, warnings, bounded writes, and targeted checks over blanket denial.
7. Publishing requires approval bound to the exact proposed delivery. Only
product-owned workouts may be written or updated.
8. Report only delivery evidence the product can observe. The contract is the
owner of valid delivery states; an earlier state never proves a later hop.
9. Do not diagnose. Pain, illness, chest pain, dizziness, or unusual symptoms
require a lower-risk human decision.
10. Within one product, every entry must express every coaching act that product
accepts. Independent products may declare different evidence and capabilities,
while sharing training contracts, validation, approval and delivery code.
Each product owns its identity registry, records and current PlanState; client
names never select the product. No cross-product data or plan synchronization
is implicit. A migration or new athlete interaction requires an owner decision.
11. Invariant 5 binds the validator; this binds the prompt. A Skill or hosted
instruction must not become a shadow coach either. It owns only the
product-specific orchestration a competent model cannot infer: what the
source of truth is, which boundary needs an explicit confirmation, and what
the product may claim to have observed. Field semantics belong to
`contracts/`, training judgment to `hybrid_training.md` served beside it as
its own prompt, structural and authorization rules to the validator, and the command surface
to README. An observation is never mapped to an assumed cause and a fixed
adjustment: `none_found` means no matching evidence was observed, never that
the athlete's week was too full.
12. A new capability changes evidence, context, schema, or tool descriptions.
Changing a coaching instruction instead is the exception and carries the
burden: name the concrete, reproducible eval failure it fixes, and why a
better field description, context shape, or tool contract cannot fix it. A
single dogfood incident is not that failure. No fixed threshold, progression
percentage, or decision tree enters an instruction unless it is a structural
or safety invariant. When deleting an instruction leaves the coaching evals
and the safety boundary unchanged, keep it deleted.
13. Everything the model reads is one finite budget: tool descriptions, input
schemas, the orchestration prompt, the Skill, and every context field. A
client is handed all of it before the first turn and carries it through the
conversation, so an addition is paid for by every later turn. Growth is
therefore accounted for, not assumed: adding surface means saying what it
buys, and adding it to the orchestration prompt means naming the paragraph it
replaces.
14. A tool is one act with one set of defaults. Do not merge operations whose
side effects, destructiveness, or defaults differ — a single tool whose
unstated field means "not stated" on one path and "as prescribed" on another
invents data on behalf of the athlete, and one whose annotations must
describe both paths can only describe them dishonestly. Equally, do not split
one act into a sequence the model has to rediscover. Fewer tools is not the
goal; each tool being truthfully describable in its own sentence is.
15. Prefer changing what the model reads over adding to what it must choose
between. A better field description, a clearer context shape, or a tighter
input contract is cheaper than a new tool and cannot be called at the wrong
time. When a new surface is genuinely the answer, an eval case that fails
before it and passes after is what shows it was.
16. A comment reading `archived issue #NN` names an issue in the archived
private repository this product migrated from; the number does not resolve
here and is provenance for finished work, never a link to follow. Cite this
repository's own tracker as plain `issue #NN`. Before adding a new citation,
open the number and confirm its topic actually matches what you are writing
next to — the archived repository used the same low numbers this one has
since reissued for unrelated work, so a remembered number is not enough.
## Product boundaries and prioritization
A real bug is not automatically the next bug. Keep **existence**, **severity**, and
**priority** separate so a review does not turn into an edge-case generator.
- Start every finding with the normal user-visible scenario: who does what, what
they see, how often that path is expected, and what goes wrong. An internal
inconsistency without a material user consequence is not a current blocker.
- Product boundaries are decided once and then treated as constraints. A later
edge case may test the boundary, but does not reopen it or invent a new product
model unless normal use demonstrates that the boundary is wrong.
- For coaching, keep two primary truths: **the Coach prescription (PlanState)**
and **actual execution**. A provider calendar is a delivery projection, not a
third coaching source of truth. Do not build continuous bidirectional calendar
reconciliation or conflict-resolution machinery for manual provider edits.
Product-owned future delivery should follow the latest confirmed PlanState;
past calendar entries are history. What the athlete actually did remains
evidence even when it did not match a planned session.
- Classify work before changing code:
- **Blocker:** normal flow is broken/materially misleading; meaningful user data
can be lost or corrupted; there is a material authorization, privacy,
security, or safety problem; or public release cannot proceed safely.
- **Current:** common or material user friction / coaching-quality loss that
belongs to the current product lane.
- **Scheduled:** a real but uncommon, recoverable, or out-of-band failure. File
it with a trigger and priority, then finish the current higher-value work.
- **Deferred:** hypothetical/narrow race or low-impact cleanup without observed
product harm. Preserve the evidence and the condition that would reopen it;
do not build architecture for it now.
Security/privacy issues may remain blockers even when the exploit path is rare;
rarity alone is not a reason to ignore high-impact boundaries.
- When review discovers a scheduled/deferred issue, record it without cascading
into adjacent fixes. Finish the current issue/PR first. A new finding may
interrupt only when it independently meets the blocker/current threshold.
- Do not let edge cases create new state machines, scores, warnings, tools,
confirmation steps, or reconciliation systems unless the normal product flow
needs them. Prefer the smallest repair at the layer that owns the fact.
- Every issue or PR intended to interrupt the roadmap must state **before →
after** in user terms and name its priority. Reports must list **current
blockers/current work separately from scheduled/deferred findings**; never mix
them into one undifferentiated bug list.
## A step the athlete has to take is the owner's decision
Anything that costs the athlete an action -- a client tool approval, an OAuth prompt, a
confirmation turn, a page to read, a reauthorization -- is a product decision, not an
implementation detail. Before landing a change that adds one, or that changes what a
client asks for, show the owner the concrete before → after in user terms (which client,
which screen, how many approvals for the same task) and the tradeoff, and wait for an
answer. Naming the change security work, compliance work, or a platform review
requirement does not waive this: when a requirement and the approved experience conflict,
report the conflict in the same before → after form and let the owner choose. Removal is
the same decision in the other direction -- a disclosure or confirmation the owner asked
for does not come out quietly either.
Issue #408 and issue #409 are what this rule is made of. A truthful annotation change
added a client approval to every preview, and the release that removed a warning page
removed the only place an athlete was told which client had been authorized. Both were
defensible in isolation and neither was the owner's call to skip.
## Version numbers
`PRODUCT_VERSION` (`garmin_coach_loop/gateway.py`), `server.json` and the Codex
plugin manifest carry one number, and a test holds the three equal. What the parts
mean was settled by the owner on 2026-09-09, against this repository's own prior
habit of bumping the minor for every release that changed anything:
- **Minor is a product-level release the owner declares**, not something a change
earns. 1.3 to 1.4 was one. Reserving it is the point: a minor spent on an
ordinary release is a minor unavailable for the next real one.
- **Patch carries everything else**, including a changed tool description, a
renamed response field, and a moved `instructions_sha256`. This product does not
maintain backward compatibility before it is stable, so a rename is not by itself
a reason to reach for a larger number.
- **The number does not decide whether a submission is a new reviewed surface.**
The changed surface does. A patch that moves `tool_catalogue_sha256`,
`instructions_sha256` or `skill_sha256` still creates one, and still cannot roll
under a pending review's snapshot (issue #182). Read the digests, not the version.
## Development and release gates
The repository has one inexpensive local feedback path and two correctness boundaries:
```bash
# default local feedback: committed changes against origin/main, plus staged,
# unstaged and untracked work in this checkout
python3 scripts/test_selection.py
# the reviewed model-facing surface, compared against a base ref through both
# protocol eras -- tool names, descriptions, schemas, annotations, instructions,
# prompts, and the catalogue digest
python3 scripts/mcp_contract_equivalence.py --base origin/main
# see the exact manual gates before touching a live client
python3 scripts/change_gates.py --base origin/main
# what the installed MCP SDK puts on the wire, against the recorded envelope --
# negotiated revisions, initialize capabilities, server/discover, error semantics
python3 scripts/mcp_protocol_envelope.py
# the confidence boundary kept for every pull request and every main push
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/check_repo_safety.py
```
`test_selection.py` runs only the directly affected tests when it has a mapping. An
unknown executable change falls back to the full suite; documentation-only changes run
no product tests. This is a developer feedback optimization, not evidence for merge or
release. Pull requests and `main` continue to run the full suite, repository safety, and
the clean-tree check in CI. CI concurrency cancels an older run for the same pull request
or branch when a newer commit supersedes it.
`change_gates.py` is the mechanical decision point for expensive manual work:
| Change surface | Additional gate | Why |
| --- | --- | --- |
| OAuth, gateway, provider delivery, delivery-boundary, or local MCP client code | Corresponding live smoke | The provider/auth hop is not proven by unit tests alone. |
| Tool catalogue, input/output schema, annotation, or served prompt/instructions | Real client acceptance, Scan Tools, and a new plugin version before resubmission | These are model-facing or reviewed MCP bytes. |
| The wire module, or a moved dependency pin (`requirements.txt`, `requirements.lock`) | The dual-era acceptance run, and no resubmission | The SDK owns which protocol era answers; a refused 2026-07-28 client falls back to 2025 silently. It cannot move this repository's reviewed bytes, so it must not trigger a review. |
| Canonical Skill only | Client acceptance for Skill-consuming entries; no Scan Tools for the current MCP-only OpenAI submission | The Skill is packaged separately from the MCP snapshot. |
| Submission packet, registry entry, or plugin manifest | A new plugin version before resubmission; no Scan Tools on its own | They are the bytes a reviewer or the registry receives, not the served tool catalogue. |
| Internal code, tests, docs, release notes, or CI-only changes | No live ceremony | They do not change a live provider or reviewed client surface. |
| A package file no list names | Reported as unclassified: live smoke and client acceptance until it is named | Silence is not evidence that a new module is internal. |
| The public demo's model, service, session, boundary, config, orchestration prompt, or fixture (`entrypoints/demo/`), or its deploy artifacts (`Dockerfile.demo`, `railway.demo.toml`) | The acceptance run against the deployed service | It is the only check in this repository that reaches the real Responses API; unit tests there run against a fake model (issue #478). |
The tool-catalogue row is decided from the digest rather than from the diff: `change_gates.py`
builds `tool_catalogue_sha256()` at `--base` and at this checkout and asks for Scan Tools when
the two differ, whichever file moved them, keeping the line markers in `mcp_transport.py` as
the fallback for a base it cannot build.
The table is exhaustive by construction for `garmin_coach_loop/`: `change_gates.py`
names every `.py` and `.md` file in the package as live, model-facing, diff-gated or
internal, and `tests/test_process_gates.py` fails when a file is in none of them. So a
new module cannot inherit "no gate" by being new. Only pull requests cancel a
superseded CI run; a `main` run always completes, because the promotion gate asks
whether one exact SHA has a successful `main` push run and a cancelled run cannot
answer that.
Every deployment still needs the production `/readyz` read-back. The `production` branch
is only a release pointer: its CI job does not repeat the full suite. Before Railway can
deploy, `scripts/verify_production_promotion.py` requires that the exact `production` SHA
is the current `main` head, has a successful `main` push run of the full CI workflow, and
builds a valid release identity. Railway's **Wait for CI** waits for this lightweight
production job; `/readyz` then proves the staged private deployment identity and running
release match after startup. A failed or stale main run blocks promotion rather than
falling back to a partial test result.
Do not infer a client acceptance, provider smoke, Scan Tools result, submission, or
deployment receipt from a green local test, a green PR, or a release bundle. Use the
change-gate output and the evidence boundary each gate names.
## Multi-agent runs
Task state lives in the GitHub issue. Never in conversation memory, and never in a
tracked state file: a file sits inside one worktree, so a parallel worker cannot read
another's copy until a merge, and two workers editing it conflict. Issue comments are
append-only, live outside every worktree, and any agent can read them with
`gh issue view <n> --comments`.
- **A worker reports by commenting on the issue.** What it delivered, which files it
touched, which verification it ran with the actual output, and what it could not
prove. A worker that reports only in its own terminal has not reported.
- **The coordinator owns the issue body.** It reads the delivered artifacts, not the
worker's account of them, and folds the outcome into an execution status block in the
body. Workers never edit the body.
- **One coordinator at a time**, named in that status block. It is whichever session
currently holds the role, not a fixed agent.
Two tasks may run in parallel only when both hold:
1. Neither depends on the other's output.
2. Their file ranges do not intersect. Every task brief states its file range before
dispatch; an unstated range means the task is not parallelizable yet.
A dependency chain runs sequentially. Splitting it across worktrees buys nothing and
costs a merge.
**The coordinator hands off after each task.** Update the issue body, then end the
session; the next task starts from a fresh session that reads the issue. A coordinator
that stays alive across a whole run carries every prior task in its context and pays for
it on every turn.
**A dispatched worker inherits the host CLI's model and reasoning effort** unless the
dispatch names them. Name them, and trust the launch receipt's effective values rather
than the arguments sent.
**Closing a task is three separate actions, and none triggers another:** release the
worker, remove its worktree, update the issue. Merging the pull request does none of
them. Decide a worktree is finished from its pull request state, not from
`git branch --merged`: this repository squash-merges, so a merged branch tip is never an
ancestor of `main`.
## Working from one session to the next
### Start from stored state, never from conversation memory
Before deciding anything about the plan:
```bash
python3 -m garmin_coach_loop.cli doctor-store
python3 -m garmin_coach_loop.cli status
```
A plan reconstructed from what an earlier conversation said is a *second* plan.
The store holds the only current one, and reading it is cheap.
Once this machine names a hosted coach (`GARMIN_COACH_LOOP_GATEWAY_URL`), the
canonical plan is the hosted one and the local store is history. Read it the way
every other client does:
```bash
python3 -m garmin_coach_loop.cli hosted-session
```
`doctor-store`/`status` still answer for whatever local store is being worked on;
they just stop being the answer to "what is the athlete's current plan".
### Dogfooding runs on main, and writes in one place
The real store lives at `~/.local/share/garmin-coach-loop`. A git worktree
isolates code, **not** state — so a work-in-progress branch pointed at the
default store writes real state from unreviewed code.
- Dogfood from the main checkout, on `main`.
- Develop in a worktree.
- Anything needing its own state sets `GARMIN_COACH_LOOP_HOME`.
- **One writer per athlete within each product.** Every client connected to that
product reaches its hosted owner store. Independent products keep independent
plans and records, as described in
[docs/ops/independent-coach-products.md](docs/ops/independent-coach-products.md);
their calendar delivery is not automatically coordinated. A writable local store
beside the same hosted product creates a second current plan. With
a hosted coach configured, local writes refuse unless `--offline` says a
different store is meant, and a migrated store is sealed
(`hosted-handoff.json`) so no code path writes it at all. Moving an existing
local store across once: [docs/ops/migrate-local-store-to-hosted.md](docs/ops/migrate-local-store-to-hosted.md).
- Every refresh passes `--health-db`, or exports `GARMIN_COACH_LOOP_HEALTH_DB`
once:
```bash
python3 -m garmin_coach_loop.cli refresh-context \
--health-db ~/Side_project/personal_os/health/data/health.db
```
Without it `strength_execution` is `null` in every context. The loop still
runs and says so in `unknowns`, but the coach loses the only record of what
was actually lifted — sets, weights, and the set that got cut short — and is
left judging strength from duration and average heart rate. The path is the
athlete's, not the repository's: PersonalOS stays a data source, never an
import (invariant 1).
**This flag remains a local-path flag after the plan moves hosted.** `health.db`
is a file on this machine, so the gateway cannot read it for anybody — and the
product does not assume anybody else has one. Hosted recovery evidence comes from
the athlete in the conversation: numbers read off the watch face, an export they
paste, whatever they have. Pass those values as
`startCoachSession.recovery_signals` with a source label saying where they came
from; do not go reading `health.db` on their behalf, and never send the path,
database, credential, raw payload, or a figure the model inferred rather than the
athlete stating what their device showed. Sleep score, sleep duration, last night's
HRV and resting heart rate are also saved as dated `reported_recovery` records;
later contexts read the last 28 days where `recovery_signals` does not already
answer the day. Older records remain stored until account-data deletion. The
other uploaded recovery figures stay in the current CoachContext and must be
supplied again when needed on a later turn. See
[docs/data-sources.md](docs/data-sources.md#consequence-for-the-coach) for correction
and deletion limits. Hosted
`strength_execution` still comes from `recordStrengthExecution`, which is the
athlete stating the sets rather than the database holding them. The history half
of the same gap is issue #101 and is still open.
This makes schema changes urgent rather than optional: `doctor-store`
revalidates the entire commit history, so once newer code writes a field, older
code cannot open the store **at all** — not the new version, the whole thing.
Land the schema change on `main` before, or immediately after, writing state
that depends on it. A writer-contract version guard (`WRITER_CONTRACT_VERSION`
in `garmin_coach_loop/store.py`) now catches a mismatch before any write,
snapshotting the store first when this checkout is the newer side; `doctor-store`
reports the version and `restore-store` is the recovery path.
### A delivery that did not finish fences the store, including recovery
`delivery-attempt.json` is a journal of what a delivery may already have done to
Intervals. While any operation in it is unreconciled, `apply-decision` is refused —
and so are `snapshot-store`, a confirmed `restore-store`, and `adopt-owner-store
--mode copy`, because each of those would hand out a copy of state that
deliberately omits the reservation. Reach for the retry, not for `restore-store`:
re-running the *same* approved delivery set converges it without a duplicate event.
`clear-delivery-attempt --confirm` is the manual reconciliation, and it prints
exactly which operations it abandoned. A reservation this code cannot parse blocks
`doctor-store` rather than reading as absent.
### One `/mcp`, two protocol eras, and neither is this repository's to implement
The wire behind `/mcp` is the official MCP Python SDK's (`requirements.txt`, the product's
only dependency). It serves the 2025 `initialize` handshake and the 2026-07-28
per-request envelope with `server/discover` from the same endpoint, and which era a given
client uses is the client's choice, not a setting anywhere here.
What that means when working in this repository:
- **Do not add a protocol revision to a list.** There is no list; the accepted set is the
SDK's own registry (`mcp_sdk_transport.HTTP_PROTOCOL_VERSIONS`). A revision this server
should speak and does not is an SDK upgrade, which moves one pinned line.
- **That pinned line is not yours to move casually.** An upgrade needs a written reason --
a revision this product should serve, or a security or compatibility fix, never "newer
exists" -- and then the ceremony in
[docs/ops/upgrade-the-mcp-sdk.md](docs/ops/upgrade-the-mcp-sdk.md): regenerate
`requirements.lock` (hashed; nothing installs from `requirements.txt`), run
`scripts/mcp_protocol_envelope.py` to see what the candidate changes on the wire, run the
dual-era acceptance, deploy, then read `mcp_sdk_version` back off `/readyz`.
`change_gates.py` reports a moved pin as `protocol_acceptance: true` and deliberately not
as a catalogue change: an SDK cannot move bytes this repository owns.
- **The reviewed surface is still `mcp_transport.py`** -- tools, schemas, annotations,
prompts -- and `tool_catalogue_sha256` still hashes it. The SDK carries those bytes; it
does not own them.
- **Prove a transport change against a real client of each era**, because a 2026 client
that is refused falls back to 2025 silently and the conversation still works:
[docs/ops/accept-both-protocol-eras.md](docs/ops/accept-both-protocol-eras.md).
- **`scripts/mcp_contract_equivalence.py --base origin/main`** compares the whole
model-facing surface between a base ref and this checkout, through both eras. A tool
count is not that comparison, and neither is the digest alone: the digest says two
catalogues differ, not which annotation flipped, and says nothing about `instructions`.
### Revoking at Intervals signs every entry out, not the one being tested
Intervals authorization is granted per application per athlete, not per connection. So
revoking it at intervals.icu — the obvious way to test the revocation path from one
client — invalidates **every** token issued under that grant at once: claude.ai, ChatGPT,
a local MCP client, whatever else is connected. Re-consenting from the client under test
mints a new token for that client only; every other connection stays dead until it
reconnects on its own.
What those other connections see is a plain `401` challenge and, in the client, "the
connection was invalidated". No reason travels to the client, on purpose — it is written
to the security log instead ([docs/ops/security-events.md](docs/ops/security-events.md)),
which is the only place that says whether a connection was forgotten, refused for the
wrong audience, or presented under a rotated key. The gateway drops the fingerprint on
the first `401` the provider returns (`_forget_connection` in
`garmin_coach_loop/gateway.py`), which is what turns one revocation into a sign-out
everywhere.
So before revoking, say so in the tracking issue, and afterwards reconnect every entry
rather than the one under test. **Re-authorizing without revoking is safe** and needs none
of this: earlier fingerprints are kept deliberately (`record_token_fingerprint` in
`garmin_coach_loop/identity.py`), so two clients hold two tokens against one store.
### Verify against the live account, not against the plan
The plan records what the product *intended* to deliver. Whether Intervals holds
it is a separate fact, and only the provider can answer it. When a delivery
matters, read the calendar back rather than trusting `delivery_state` alone.
The same split applies to the hosted gateway itself: a merged release-lane PR, a
green CI run, or someone saying a deploy "should" have gone through are all
still the plan, not the account. `/readyz` on the live domain is the calendar
read-back for deployment — see
[`docs/ops/verify-production-status.md`](docs/ops/verify-production-status.md)
before either trusting or doubting a deploy status secondhand.
## Verification
Run:
```bash
python3 -m pip install --require-hashes -r requirements.lock
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/check_repo_safety.py
```
They run on Python 3.11 with `requirements.lock` installed -- one pinned package and what
it resolves to, hashes verified; see **Dependencies** above for why there is one at all.
They remain the merge and main confidence boundary; the production promotion job
intentionally proves reuse of that boundary for the same commit instead of executing it a
second time.