Metadata-Version: 2.4
Name: axon-contracts
Version: 0.9.0a1
Summary: Axon controller <-> agent contracts: protobuf messages, MQTT topics, gRPC endpoints. Single source of truth for the Go agent and the Python API.
License: AGPL-3.0-only
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: protobuf<5,>=4.25
Provides-Extra: dev
Requires-Dist: grpcio-tools==1.62.3; extra == "dev"
Requires-Dist: flake8>=6.0; extra == "dev"
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# axon-contracts

Single source of truth for Axon wire contracts: **protobuf messages**, **MQTT
topics**, the **gRPC EE-data endpoint**, and the public **Pulse sensor HTTPS
ingest schema**. The Go submodule also contains small transport-independent
runtime packages shared by the switch agent and Pulse.

## Layout

```
proto/                     canonical .proto source of truth (19 messages, 1 gRPC service)
gen/go/                    Go module: github.com/Taurine-Technology/axon-contracts/gen/go
  pb/                        generated Go bindings (package pb)
  topics/                    hand-written MQTT topic builders (package topics)
  diagnostics/               bounded DNS/HTTP/path/speed/responsiveness engine
  buildinfo|compress|geo|logging/
                              small shared runtime packages
schemas/
  sensor_ingest.schema.json  authoritative Pulse ingest protocol v1 payload
axon_contracts/            Python package (pip: axon-contracts)
  proto/                     generated *_pb2.py / *_pb2_grpc.py
  mqtt/topics.py             MQTT topic builders (parity with gen/go/topics)
  constants.py               shared constants + gRPC EE-data endpoint contract
tools/                     pinned protoc + plugins (provisioned by `make tools`)
tests/                     cross-language topic parity + proto import guards
```

## Codegen

```sh
make tools        # provision pinned protoc + protoc-gen-go (gitignored)
make proto        # regenerate Go (gen/go/pb) + Python (axon_contracts/proto)
make proto-check  # CI guard: regen and fail if committed output drifted
```

Pinned versions live in `tools/versions.env`. The Go bindings use protoc 35.1 +
protoc-gen-go 1.36.11 (byte-identical to the agent's committed headers). The Python
bindings are generated with `grpcio-tools==1.62.3` so the gencode validates under
**protobuf 4.25.x** — axon-ee is capped there by TensorFlow and must consume these
bindings unchanged. `tests/test_proto_imports.py` enforces this.

## Consuming

**Go (agent):**
```sh
go get github.com/Taurine-Technology/axon-contracts/gen/go@gen/go/v0.3.0
```
```go
import (
    "github.com/Taurine-Technology/axon-contracts/gen/go/pb"
    "github.com/Taurine-Technology/axon-contracts/gen/go/topics"
)
```

**Python (API/controller):**
```sh
pip install "axon-contracts @ git+https://github.com/Taurine-Technology/axon-contracts.git@v0.3.0"
```
```python
from axon_contracts.proto import mirror_flow_stats_messages_pb2
from axon_contracts.mqtt.topics import data_pattern, MIRROR_FLOW_STATS
```

## v0.8.0-alpha.7 — Per-device application rate limits, VLAN targeting

**Per-device rate limits** (`proto/policy_messages.proto`). `RateLimitRule`
gains `limit_mode` (`RateLimitMode`: `PER_DEVICE` gives every matching device
its own bucket pair, `SHARED` keeps one bucket for all devices; unset reads as
`SHARED`), `up_rate_kbps` (0 = same as `rate_kbps`) and `burst_ms` (0 = agent
default 50 ms). On agents with the eBPF app limiter these replace the shared
OVS meter; the classified flow's OVS entry sets a `pkt_mark` slot instead of
`meter:N`. `PolicyRuleStatus.enforcement` (`RateLimitEnforcement`) reports how
each agent enforces the rule: `OVS_METER_SHARED` (older agents, inferred from a
non-zero `meter_id`, and the fallback where the limiter cannot load),
`EBPF_PER_DEVICE`, `EBPF_SHARED` or `NOT_ENFORCED`.
`ClassifierFlowStatsEntry.action_type` stays `METER` for marked flows with
`meter_id` 0, so blocked / rate-limited accounting per client is unchanged.

**VLAN targeting** (merged from `keegan/vlan-policy-targeting`):
`BlockRule.target_vlan_id` (12; 11 is
`client_networks` from alpha.6) / `RateLimitRule.target_vlan_id` (14), the
`vlan_block_rule` / `vlan_rate_limit_rule` `PolicyUpdate` arms and the
`PolicySync.vlan_*_rules` lists, which old agents ignore as a whole.

**Capabilities** (`AgentVersionResponse.capabilities`, `constants.py`):
`policy.vlan-targeting.v1`, `policy.per-device-ratelimit.v1`.

Mark-word ownership on the agent's tc egress hook: bits 0–15 app-limit slot,
28–31 tag `0xA`; bits 24–26 stay the application-priority tins (alpha.5);
16–23 are left alone (tailscale). Python `0.8.0a7`; Go tag
`gen/go/v0.8.0-alpha.7` when cut.

## v0.8.0-alpha.6 — Policy client scope (`client_networks`)

**What it is.** Blocking rules and block lists can be restricted to the LAN
clients inside a set of CIDRs, for an exit-node agent that carries many
downstream sites' traffic (one site = one set of client CIDRs).
`repeated string client_networks` is added to `BlockRule` (field 11),
`IPBlockRule` (9), `URLBlockRule` (9) and `BlockListSync` (10, covering both
its `ip_entries` and `domain_entries`). At most 32 IPv4 or IPv6 CIDRs; the
flow's LAN client (the LAN-side endpoint after the agent's orientation, the
same endpoint `QoSAppPriorityRule.client_networks` matches) must fall inside
one. It is not "src or dst" like `target_network`. Empty = every client; OR
within the list, AND with every other populated constraint on the rule. On
`IPBlockRule` the `ip_address` / `cidr_range` (and `direction`) still name the
remote address being blocked. `BlockListEntryUpdate` carries no scope: its
entries inherit the list's scope as last set by `BlockListSync`.
`RateLimitRule` deliberately gets no `client_networks` yet (meters are shared
per rate; per-client-network limits need per-rule meters first).

**Capability.** `DeviceProfile.policy` (field 26, `DeviceProfilePolicy`):
`client_networks` (1) says the agent honours the field on all four messages
and keys application block rules on `rule_id` (so several rules may name the
same application, e.g. one per client scope; older agents key on `app_name`);
`max_client_networks` (2) is the largest list it accepts, 0 = unknown. The
profile hash now covers `policy` too.

Additive, but NOT safe to send blind: an older agent ignores `client_networks`
and would apply a scoped rule to EVERY client. A controller MUST NOT send a
non-empty list to an agent whose profile does not report
`policy.client_networks = true` (an absent `policy` reads as false). An older
controller never sets the field (read as every client) and ignores `policy`.
Python `0.8.0a6`; Go tag `gen/go/v0.8.0-alpha.6` in lockstep with
`v0.8.0-alpha.6` when cut.

## v0.8.0-alpha.5 — Smart Uplink application priority

**What it is.** Application priority gives selected traffic preference when a
shaped uplink is busy, by choosing the CAKE tin each packet is queued in. It is
tin selection only: not a bandwidth reservation, not a rate cap, not DSCP
rewriting, and it never raises the configured link or per-host limits. Rules
are evaluated on the device against each flow's own nDPI verdict; inbound DSCP
is never a source of priority and on-wire DSCP is never modified. Design:
axon-ee#522 (`docs/superpowers/specs/2026-09-29-application-priority-design.md`
in axon-ee).

**Tin mode.** `QoSTinMode` (`UNSPECIFIED` = `BESTEFFORT`, `DIFFSERV4`) is
requested per direction in `QoSDirectionConfig.tin_mode` (field 10); CAKE and
CAKE_MQ only (a TBF_FQ_CODEL direction answers `STATUS_UNSUPPORTED`), unknown
values fail validation. The agent renders `diffserv4` only after its marker is
attached and verified on the interface; otherwise the direction stays
besteffort and `QoSDirectionApplyResult.applied_tin_mode` /
`app_priority_reason` (fields 6–7) say so, while `status` keeps reporting
shaping alone.

**Mark bits and tin order.** `QoSPriorityLevel` `LOW` / `NORMAL` / `HIGH` /
`REALTIME` (1–4) are CAKE's diffserv4 tins Bulk / Best Effort / Video / Voice.
The agent's marker writes them into `skb->mark` bits 24–26 (`fwmark
0x07000000`, value = level) on every packet, clearing those bits first;
unmatched, unknown and stale flows get `NORMAL`. A diffserv4 direction publishes
four `QoSTinStats` with `tin_index` in CAKE display order (0 `bulk`, 1
`besteffort`, 2 `video`, 3 `voice`); `memory_used_bytes` stays a
direction-level value, never summed across tins.

**Rules and sync.** The controller publishes `QoSAppPriorityTable` on control
topic `qos/priority/sync` (QoS 2, raw protobuf, never retained): ALWAYS a full
snapshot of up to 256 ordered `QoSAppPriorityRule`s plus the site timezone and
a `table_hash` (sha256 of rules + timezone) for no-op detection; an empty table
clears every priority. A rule matches on nDPI `apps` OR `categories` (or
`any_application`, required true exactly when both lists are empty), AND every
populated client constraint (`client_networks`, `client_macs`, `client_vlans`;
OR within each list), AND its optional `QoSSchedule` (site-local; a half-open time
window whose overnight form belongs to the start day, or, with both times empty
and days listed, the whole of each listed day); the first match wins, no match is
`NORMAL`. The agent answers once per revision with
`QoSAppPriorityTableResponse` on `qos/priority/sync/response` (QoS 2).
Validation is whole-table: any invalid rule refuses the revision
(`STATUS_FAILED`, up to 64 `rejected_rule_ids` with paired reasons) and the
previous table stays; there is no `STATUS_PARTIAL`. Revisions are monotonic and
independent of policy and host-table revisions; an exact replay repeats the
stored answer, an older revision (or the stored one with a different payload)
is `STATUS_STALE`, and every response carries `applied_revision`.

**Accepted is not effective.** `STATUS_OK` means validated and durably stored,
nothing more. Effective state is reported separately: `QoSStatsBatch.app_priority`
(field 9, `QoSAppPriorityStats`) carries per interface the stored and
classifier-loaded revisions, `effective` (marker verified, diffserv4 rendered,
classifier revision == stored) with a `state_reason`, and marked packets/bytes
indexed by level; `QoSCapabilityReport` fields 16–22 carry support, reason,
the device limits (rules, tracked flows, table bytes) and the stored /
classifier revisions.

**Bounds.** Table payload at most 262,144 bytes, 256 rules and 4,096 selectors
in total; per-rule limits are in the proto; the timezone must load on the
device. A response encodes to at most 24 KiB.

Additive: an older agent ignores `tin_mode` and keeps rendering besteffort,
never reports `app_priority_supported` (so the controller never syncs a table
to it or asks it for diffserv4), and never subscribes to `qos/priority/sync`.
An older controller never sets `tin_mode` (read as besteffort) and ignores the
new response, stats and capability fields. Go constants `QOSPrioritySync` /
`QOSPrioritySyncResponse` and Python `QOS_PRIORITY_SYNC` /
`QOS_PRIORITY_SYNC_RESPONSE` carry the topics. Python `0.8.0a5`; Go tag
`gen/go/v0.8.0-alpha.5` in lockstep with `v0.8.0-alpha.5` when cut.

## v0.8.0-alpha.4 — Smart Uplink without a shaper

**`QoSBackend.QOS_BACKEND_NONE`** (`proto/qos_messages.proto`). A direction
bound to an interface with this backend installs no qdisc: the agent owns the
port for the per-host limiter (`QoSHostTable` plans) and the live view only.
`rate_kbit`, `memlimit_bytes`, `host_isolation` and `overhead_profile` are
ignored for it (controllers send 0 / defaults). Without CAKE the port has no
per-host fairness or AQM; only the subscriber caps apply. Agents before 0.6.2
reject a policy carrying it at validation (`STATUS_FAILED`) and keep their
previous policy; agents from 0.6.2 answer `STATUS_UNSUPPORTED` when their
per-host limiter is disabled or unsupported. Python
`0.8.0a4`; Go tag `gen/go/v0.8.0-alpha.4` when cut.

## v0.8.0-alpha.3 — Smart Uplink host limits

**Per-host limiter.** `proto/qos_messages.proto` adds a hard per-subscriber cap
in front of the Smart Uplink shaper: a token bucket per LAN host (destination
MAC on the download interface, source MAC on the upload interface, optionally
qualified by the outer VLAN), enforced by the agent at the tc egress hook
before CAKE. The controller publishes the table as `QoSHostTable` on control
topic `qos/hosts/sync` (QoS 2, raw protobuf): a revisioned snapshot of up to
5,000 `QoSHostLimit` entries per message, chunked when larger and applied only
once every chunk has arrived, or a single-message delta of upserts and
removes, plus table-wide default caps for unknown hosts. The agent answers
once per revision with `QoSHostTableResponse` on `qos/hosts/sync/response`
(QoS 2): status, entries live, up to 64 rejected entries echoed with their
`plan_ref`, the interfaces the limiter is attached to, and the agent's applied
revision. Revision, chunking, delta and validation rules are documented in the
proto; the ones both ends must agree on are summarised below.

**Sync rules.** A delta names the applied revision it was computed against in
`QoSHostTable.base_revision` (field 13) and is applied only on exactly that
base; otherwise nothing changes, the agent answers `STATUS_STALE`, and the
controller re-bases with a snapshot (snapshots ignore `base_revision`). Every
`QoSHostTableResponse` carries `applied_revision` (field 10; 0 before any table
is applied), so a controller restored from a backup moves its sequence past the
agent's after a single `STATUS_STALE` instead of probing revision by revision.
An exact replay of the applied revision is answered with its original outcome
(`STATUS_OK`, or `STATUS_PARTIAL` with the same `rejected` and `reason`), never
a bare `STATUS_OK` for a revision that had rejects. After VLAN normalisation, a
key that appears more than once in a revision (an upsert and a remove of it
included) has every occurrence rejected; a chunk index redelivered with
different content fails the revision. Caps are 0 or 64–100,000,000 kbit/s and
bursts 0 or 5–1,000 ms; an out-of-range table-wide setting fails the snapshot,
and every bucket, the shared unknown-host buckets included, is floored at
128 KiB.

**Bounds.** Enforced by the receiver in both directions, with lengths counted
in Unicode code points. A table message has at most 64 chunks, 5,000 entries
(upsert plus remove) and a 128-character `correlation_id`, or its whole
revision is `STATUS_FAILED`. A response has at most 64 rejected echoes (known
fields only, `mac` cut to 8 bytes, `plan_ref` to 64 characters), a
512-character `reason`, 16 `interfaces` of 15 characters and a 128-character
`correlation_id`, so it always encodes to under 24 KiB; controllers drop an
oversized response. A 5,000-entry chunk is under 200 KiB with short
`plan_ref`s and under 1.5 MiB with every field at its maximum.

**Capability, stats and live view.** `QoSCapabilityReport` gains
`host_limiter_supported` / `host_limiter_reason` / `host_limiter_max_hosts`
(fields 13–15); `QoSStatsBatch` gains a bounded per-direction
`QoSHostLimiterStats` (field 8, absent when no limiter is attached);
`QoSLiveHostRate` gains `policed_bytes` / `policed_packets` (fields 5–6), and
`QoSLiveInterfaceSample.per_host_drops_available` is now true on an interface
with the limiter attached. Live rows carry no VLAN, so `policed_*` is aggregated
per MAC across VLANs. CAKE's own queue drops remain unattributed per host.

Additive: older agents never report the limiter as supported (so the
controller never syncs a table to them) and never set the new fields; older
controllers never publish on `qos/hosts/sync`. Go constants `QOSHostsSync` /
`QOSHostsSyncResponse` and Python `QOS_HOSTS_SYNC` / `QOS_HOSTS_SYNC_RESPONSE`
carry the topics. Go tag `gen/go/v0.8.0-alpha.3` in lockstep with
`v0.8.0-alpha.3`.

## v0.7.0 — Smart Uplink live view + passive TCP RTT

**Smart Uplink live view (post-shaper per-host rates).** `proto/qos_messages.proto` adds the live-view session control and data messages:
`QoSLiveViewRequest` / `QoSLiveViewResponse` (control topics `qos/live/request`
and `qos/live/response`, QoS 1) and `QoSLiveBatch` (data topic `qos-live`, QoS 0,
zstd-compressed). While a session is active the agent publishes, per shaped
interface and per LAN host, the bytes/packets that left the interface after the
qdisc dequeued them, plus the qdisc's own sent/drop deltas; per-host drops are
explicitly reported as not available. Additive: an older controller never opens
a session and an older agent answers nothing (the controller times out). Go tag
`gen/go/v0.7.0` in lockstep with `v0.7.0`.

**Passive TCP RTT on mirror flow stats.** `proto/mirror_flow_stats_messages.proto` adds `FlowRttLeg` and the `rtt_src` /
`rtt_dst` sub-messages (fields 32/33) on `MirrorFlowStatsEntry`. The agent sits
mid-path, so a TCP RTT splits into a leg towards `src_ip` and a leg towards
`dst_ip`; the capture layer does not know which endpoint is the LAN client, so
the controller maps those onto client ("Subscriber RTT") and server ("Upstream
RTT") legs with its own LAN-network orientation. `samples`/`sum_us` are
cumulative like the byte counters, `min_us`/`max_us` are life-of-flow.
Additive: older readers ignore the new fields, and an absent sub-message means
the leg was never measured — never "zero RTT". Go tag `gen/go/v0.7.0` in
lockstep with `v0.7.0`.

## v0.6.0 — DeviceProfile

`proto/device_profile_messages.proto` adds `DeviceProfile` (agent hardware,
kernel, queueing and capture capabilities; status topic `device/profile`,
QoS 1) and `DeviceProfileRequest` (control topic `device/profile/request`,
QoS 1). Additive: an older controller ignores the topic and an older agent
never publishes it. Go tag `gen/go/v0.6.0` in lockstep with `v0.6.0`.

## Versioning

- **Python wheel** → published to the `axon-dist` PEP 503 index on a `vX.Y.Z` tag
  (see `.github/workflows/release.yml`).
- **Go module versioning:** the module lives in the `gen/go` subdirectory, so its
  semver tags MUST be prefixed: `gen/go/vX.Y.Z` (Go submodule tag convention).
  Keep the Go tag and the `vX.Y.Z` wheel tag in lockstep.

The shared diagnostics and Pulse ingest schema prepare **v0.4.0** (Go tag
**gen/go/v0.4.0**). Publish the root and Go-submodule tags in lockstep after
this release commit merges; agent and Pulse consumers must land only after the
Go tag is available.

## Licensing

The `gen/go` module is Apache-2.0 under `gen/go/LICENSE`; this explicit
submodule license permits it to be linked into the closed-source switch agent
and Pulse without importing GPL code. The repository root and Python package
remain AGPL-3.0-only.

## Scope

Owns proto + MQTT topics + the gRPC EE-data endpoint contract, Pulse wire
schemas, and dependency-light Go code genuinely shared by more than one Axon
binary. Agent-internal protos (for example `agent_ipc`) stay in the agent repo.
Human-facing REST APIs, distribution URLs, and broker bootstrap URLs remain out
of scope.

The enrollment/bootstrap payload itself stays out of scope (it is a hand-rolled
JSON dict built by axon-ee and read by the agent's `internal/config/bootstrap.go`).
The only shared bootstrap artifacts here are inert **string literals** in
`axon_contracts.constants` — key names, default tag, and status values — so the
Python controller and Go agent agree on spelling without a schema. The tailnet
adoption constants (`TAILSCALE_DEFAULT_AGENT_TAG`, `BOOTSTRAP_FIELD_TAILSCALE_*`,
`TAILSCALE_STATUS_*`) follow the existing `ENROLLMENT_TOKEN_PREFIX` /
`BOOTSTRAP_CONFIG_PATH` precedent.

### RTT sample distribution v1

`FlowRttLeg.histogram_version=1` carries 21 non-overlapping cumulative counts
of accepted TCP timestamp samples. Inclusive upper bounds (µs) are
100, 250, 500, 1000, 2000, 4000, 8000, 12000, 20000, 30000, 50000, 80000,
120000, 200000, 400000, 800000, 1600000, 3200000, 6400000, 10000000, +∞.
Their sum equals `samples`; handshake measurements are excluded. Consumers
ignore unknown histogram versions while retaining the legacy RTT fields.
For version 1, malformed bin counts or totals invalidate only the histogram;
validate and retain the legacy RTT fields independently. Percentiles identify a bin range, not an exact value.

`rtt_sampling_method` reports each flow's actual path: userspace (1), userspace
followed by active kernel bypass with sustained sampling (2), or active bypass
without sustained sampling (3). Zero means unreported. Configured bypass does
not establish runtime support: `DeviceProfileCapture` distinguishes successful
filter attachment, active bypass, and active kernel RTT. Count-only and off
modes continue userspace sampling. The optional kernel state has its own cap;
a flow may report method 3 even when the device advertises sampler support.
