# Compatibility policy

TaxPilot AI and the CMS are separate products, in separate repositories, shipped
on separate cadences to separate infrastructure. This is how they stay able to
talk to each other, and what happens when they stop.

---

## What is versioned

Three numbers, and only one of them decides anything.

| | Example | Decides anything? |
| --- | --- | --- |
| CMS application version | `1.1.4` | No — diagnostics only |
| TaxPilot AI release version | `0.1.0` | Only against the CMS's declared floor |
| **Agent API contract** | **`1.0`** | **Yes** |

The contract is what is negotiated. The application versions are reported so an
operator can tell which builds are talking to each other, and nothing gates on
them.

**Why not the application version.** If this side pinned against the CMS's
release number, every CMS patch — a dashboard colour, a PDF margin — would be a
version somebody has to teach TaxPilot AI about. That means a table mapping one
product's releases onto the other's, maintained by hand, wrong within a month.
The contract changes only when the thing that actually matters changes.

---

## What the contract numbers mean

`MAJOR.MINOR`, declared in `App\Support\AgentApiContract::VERSION` on the CMS
side and required in `app/api/compatibility.py` on this one.

**MAJOR — a breaking change.** An endpoint removed or renamed. A field removed.
A field whose meaning changed. A change to how requests are signed. An agent
built for one MAJOR refuses another, in **both** directions.

**MINOR — an additive change.** A new endpoint. A new field in a response. A new
optional parameter. Older agents keep working unchanged; a newer agent may
declare a floor.

So a release requiring `1.2`:

| CMS speaks | Result |
| --- | --- |
| `1.1` | refused — needs something the CMS does not have |
| `1.2` | works |
| `1.5` | works — the extra is additive |
| `2.0` | refused — something it depends on may have changed meaning |
| `0.9` | refused — endpoints may not exist |

### Which side to change

- Adding an endpoint or an optional field → **MINOR**
- Adding a required request field → **MAJOR** (old agents do not send it)
- Removing or renaming anything → **MAJOR**
- Changing what a field means while keeping its name → **MAJOR**, and the one
  most easily missed: nothing breaks at the type level, and every agent silently
  misreads it

The number lives in code, not configuration. It describes what a build does, not
how a deployment is set up — an operator who could edit it could tell an agent
the CMS speaks a contract it does not.

---

## The handshake

`GET /api/agent/v1/whoami`, which needs no permission by design. It is called at
boot, on every readiness probe, and every fifteen minutes by the daemon.

```json
{
  "name": "TaxPilot AI",
  "permissions": ["clients.read", "proposals.submit"],
  "cms": {
    "version": "1.1.4",
    "api_contract": "1.0",
    "minimum_agent_version": "0.1.0"
  }
}
```

**Re-checked, not checked once.** The CMS updates itself over the air — that is
proven, in production — so the installation this deployment is attached to can
change contract on a Tuesday afternoon with nobody touching this side. A verdict
reached only at start-up would go stale at exactly the moment it mattered.

---

## What happens on a mismatch

**At boot** it is fatal. `python -m app check` fails, the daemon refuses to
start, and the exit code is 78 — "you configured this wrong", not "it broke".

Fatal rather than degraded, deliberately. An incompatible contract means the
requests this release knows how to make no longer mean what it thinks they mean,
and those requests file documents against a tax firm's client records. Starting
anyway and discovering it one document at a time is far worse than not starting.

**At runtime** every call to the CMS is refused by a gate in `CmsClient` —
`ADR-0005` makes that client the only channel out, so one check covers
everything. `whoami` is exempt, because it is the handshake: blocking it would
make the incompatibility permanent for the life of the process, and a CMS rolled
back to a working version would never be noticed.

**In health** `/health/ready` reports `compatibility` as FAILING, which takes
the deployment out of rotation. An orchestrator that went on routing to it would
be routing to a service whose every CMS call is being refused.

The gate refuses on **knowledge, not ignorance**. A client that has not
handshaken yet is not known to be incompatible, and blocks nothing.

---

## A CMS that says nothing

A CMS built before the handshake existed — which is every CMS deployed today,
including production — reports no `cms` block at all.

**That is treated as contract `1.0`, and reported as degraded rather than
refused.**

This is not leniency for its own sake. Nothing about the API had changed when
those builds shipped, so their contract *is* 1.0; silence is not an unknown, it
is a known. And the strict reading would mean this platform could not talk to
any currently deployed CMS until that CMS was updated — a compatibility check
causing the outage it exists to prevent.

The rule corrects itself. When a future release requires `2.0`, an assumed `1.0`
fails the ordinary comparison with no special case to remember. Silence stops
being acceptable exactly when it stops being true.

An **empty** `api_contract` is treated the same way, for the same reason: one
bad config value should degrade a deployment, not stop every agent from
starting. A **malformed** one (`"1.2.3"`, `"v1.0"`) is refused — something was
claimed and cannot be understood, which is different from nothing being claimed.

---

## The CMS's floor on agents

`minimum_agent_version` is **declared by the CMS and enforced by the agent**,
which refuses to start below it.

Declared rather than enforced on purpose. Enforcing it would put another moving
part on the authentication path — which every request passes through, and which
is the last place for logic that can wrongly lock out a working installation. An
agent too old to check itself is a deployment problem, not an attack: it holds a
key somebody issued it.

An unreadable floor does not stop a start. The real checks — permissions, the
contract, whether endpoints exist — still apply.

---

## Where a release records what it needs

`app/api/compatibility.py` is the source of truth:

```python
REQUIRED_CONTRACT = "1.0"     # the floor this release needs
SUPPORTED_MAJOR = 1           # refuse anything else, both directions
MINIMUM_CMS_VERSION = "1.1.0" # recorded in the manifest; diagnostics
```

`python -m app.release build` copies `MINIMUM_CMS_VERSION` into the release
manifest by default. The code is what gets enforced at boot, so a manifest
disagreeing with it would be the thing that was wrong — `--requires-cms`
overrides it and is rarely the right thing to do.

**Raise `REQUIRED_CONTRACT` only when the code actually starts depending on
something a later MINOR added.** A floor above what is really needed locks out
working installations for nothing.

---

## Releasing a breaking change

The order matters, because there is a window where both are deployed.

1. **Ship the CMS first**, with the new MAJOR. Every existing agent immediately
   refuses to start and reports why. This is intended: they cannot safely use
   the new contract.
2. **Then install the matching TaxPilot AI release.** It comes up, handshakes,
   and resumes.

Between the two, documents queue in WhatsApp and nothing is filed. Nothing is
lost — a proposal already submitted lives in the CMS (ADR-0008), and the
approval queue is unaffected.

If that window is unacceptable, the change is not a MAJOR change: make it
additive, ship it as a MINOR, and remove the old shape one release later once
nothing reads it. That is the same expand/contract discipline the database
migrations follow, for the same reason.

---

## Current state

| | |
| --- | --- |
| CMS contract | `1.0` |
| Required by this release | `1.0` |
| CMS floor on agents | `0.1.0` |
| Production CMS | reports nothing — assumed `1.0`, degraded |

Contract `1.0` is the shape shipped with the Approval Queue: `whoami`, metadata,
client search, proposals, and the promoted `documents` path.

### Bank statement intake did not move the contract

It added no endpoint and no required field. `payload.source`,
`payload.identifier` and `evidence.read_attempted` all live inside objects the
CMS already accepted as free-form — which is why this is not even a MINOR.

That does mean the two sides can be upgraded independently, and the degradations
are worth knowing because neither of them looks like a fault:

| Upgraded | Not upgraded | What happens |
| --- | --- | --- |
| Agent | CMS | Forwards are proposed and filed correctly, but the reviewer sees no *Where this came from* panel; a clean statement scores **high** risk rather than medium, because an older CMS reads "not read" as "could not be read"; and **duplicates are not detected** — the same statement forwarded twice reaches the queue twice |
| CMS | Agent | Nothing changes. An older agent never sets `source`, so no proposal is treated as a forward and every existing path behaves exactly as before |

The second row is what decides the rollout order: **upgrade the CMS first.** The
other way round leaves a window in which duplicate bank statements can be filed,
and duplicate detection is not something a reviewer can supply by being careful.
