crates/gunnar-ui-server/proto/gunnar/v1/entitlement.proto
syntax = "proto3";
// ─────────────────────────────────────────────────────────────────────────────
// `Entitlement`: the paid state per namespace, written by monetize, read by
// `authorise_service` (the entitlement surface). **Compiled only with the
// `metered` feature** (`gunnar-proto/metered`, forwarded from
// `gunnar-server/metered`): the community appliance does not DECLINE to
// enforce plans, it does not CONTAIN the code, and the symbol table of the
// packed binary is what proves it (`OptionAudit`, `tests/feature_split.rs`).
//
// This file is the contract monetize's gunnar plugin codes against
// (`monetize/products/gunnar/proto/expected-gunnar-entitlement.proto`, the
// `Entitlement` half, verbatim).
//
// The signature: Ed25519 by monetize-server's key over the canonical JSON of
// the Rust `monetize_product::EntitlementFact` minus `signature` — keys sorted
// bytewise, no whitespace, `tenant` (= namespace) as a plain string, `state` as
// the variant name (`"Paid"`), `paid_until_unix_ms` as a number or `null`,
// `caps` an object. `monetize_embed::signing::fact_message` is the one writer
// of that form and `monetize_embed::signing::verify_fact` the one verifier; the
// metered gunnar links `monetize-embed` and verifies with the public key it is
// configured with (`--monetize-pubkey`). Field numbers 1-6 of `EntitlementFact`
// below are those fields in that order. gunnar writes no second canonical form.
// ─────────────────────────────────────────────────────────────────────────────
package gunnar.v1;
// The ladder. Mirrors `monetize_product::State`.
enum EntitlementState {
ENTITLEMENT_STATE_UNSPECIFIED = 0;
ENTITLEMENT_STATE_FREE = 1;
ENTITLEMENT_STATE_PAID = 2;
ENTITLEMENT_STATE_GRACE = 3;
ENTITLEMENT_STATE_SUSPENDED = 4;
ENTITLEMENT_STATE_RETENTION = 5;
}
// One signed fact about one namespace. Field for field
// `monetize_product::EntitlementFact` (namespace = tenant).
message EntitlementFact {
string namespace = 1;
string plan = 2;
EntitlementState state = 3;
optional uint64 paid_until_unix_ms = 4;
// Per-meter caps gunnar enforces: `pack_bytes` → pack quota,
// `cache_bytes` → `explode_budget_bytes`. Keys are the meter names
// `Usage.Read` reports. Absent = server default.
map<string, uint64> caps = 5;
// `operator` | `payment:<vendor>:<reference>` — appended verbatim to the
// attestation log.
string source = 6;
// Ed25519 (64 bytes) by monetize-server's key, see the header.
//
// **This is the V1 signature**: it covers fields 1-6 ONLY. It does not cover
// field 8, and must never be made to — it is what a gunnar built before
// 2026-09-17 computes from the seven fields it knows, and every fact signed
// before that date carries one. A fact that arrives with fields 8 and 9 is
// therefore accepted UNCHANGED by such a box: the stamp is two fields it has
// never heard of, and proto3 drops an unknown field on the way into the
// struct.
bytes signature = 7;
// **When monetize issued this verdict** — the field that makes a fact good
// ONCE (`monetize_product::EntitlementFact::issued_unix_ms`).
//
// Without it a fact is replayable for ever: capture a `Paid` fact, wait for
// the tenant to lapse, push the captured bytes back, and the signature still
// verifies because it is a real signature. `paid_until` cannot tell the two
// apart — a lapse keeps the date and moves the ladder — so gunnar refuses a
// fact that is not NEWER than the one it holds for that namespace.
//
// Absent = a fact signed before the field existed. gunnar accepts one, until
// it has seen a stamped fact for the same namespace; after that the unstamped
// form is a downgrade and is refused too.
optional uint64 issued_unix_ms = 8;
// **The V2 signature**: Ed25519 (64 bytes) over fields 1-6 AND field 8
// (`monetize_embed::signing::fact_message_issued`). Present exactly when
// field 8 is; one without the other is refused. It is what stops the issue
// time being ADDED or BUMPED by whoever relays the fact — an unsigned stamp
// far in the future would move this box's high-water mark past every fact
// monetize will ever mint for that namespace.
bytes issued_signature = 9;
}
service Entitlement {
rpc Get(GetEntitlementRequest) returns (GetEntitlementResponse);
// Admin level. gunnar VERIFIES the signature against its configured monetize
// public key before storing; a fact that does not verify is INVALID_ARGUMENT
// and nothing moves. Idempotent on (namespace, source).
rpc Set(SetEntitlementRequest) returns (SetEntitlementResponse);
rpc List(ListEntitlementsRequest) returns (ListEntitlementsResponse);
}
message GetEntitlementRequest {
string namespace = 1;
}
message GetEntitlementResponse {
// Absent = the namespace is Free (a control document written before
// entitlements existed loads with every namespace Free).
optional EntitlementFact fact = 1;
}
message SetEntitlementRequest {
EntitlementFact fact = 1;
}
message SetEntitlementResponse {
// The state gunnar now enforces (== fact.state when accepted).
EntitlementState state = 1;
// Sequence number of the attestation line this write appended.
uint64 attestation_seq = 2;
}
message ListEntitlementsRequest {}
message ListEntitlementsResponse {
repeated EntitlementFact facts = 1;
// The facts this box HOLDS and does not enforce: stored, and refused when it
// booted because they do not verify under its monetize key (2026-09-21, lane
// T3). `facts` lists what is enforced, so without this a box restored under
// the wrong key answers an empty list — the same answer a box with no
// customers gives. Absent = nothing was refused.
RefusedAtBoot refused_at_boot = 2;
}
message RefusedAtBoot {
uint64 count = 1;
// When the box booted and refused them (unix ms, the box's clock).
int64 at_unix_ms = 2;
// Why, in the verifier's words — e.g. a key mismatch.
string reason = 3;
repeated string namespaces = 4;
}