diff --git a/MIGRATION.md b/MIGRATION.md index 2489b73..b0a2534 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,58 @@ +# Migration Guide + +- [0.9.0 → 0.9.1](#migrating-from-090-to-091) — command authorisation requirements; schema selection relaxed +- [0.8.x → 0.9.0](#migrating-from-08x-to-090) — BSP → BEST rename; conformant CloudEvents 1.0 profile + +--- + +# Migrating from 0.9.0 to 0.9.1 + +Spec 0.9.1 changes **no wire format** — hence a patch bump. It **relaxes** one requirement and **adds** normative requirements around authorising commands. Nothing a 0.9.0 client sends becomes invalid, and no client change is needed; some 0.9.0 *servers* will find they were relying on behaviour the spec now names as wrong. + +## Relaxed — schema selection + +0.9.0 required servers to select the validation schema "keyed by the command `type` field". 0.9.1 requires only that the schema come from the server's **own catalogue**, and permits the lookup to be keyed by `type` **or** by the schema name carried in `dataschema`. + +This restores the intent of the original requirement. The security control was always *don't dereference a caller-supplied URI* — not *which server-owned identifier keys the lookup*. `dataschema` is best understood as a **selector, not a location**. + +- A server already keying on `type` is **unchanged and still conformant**. No action needed. +- A server that wants to key on the catalogue schema name may now do so. +- Both MUST-NOT-fetch rules are unchanged and still apply. + +## Added — one identifier, or a total mapping + +| # | Requirement | Why it is new | +|---|---|---| +| 1 | Schema selection, authorisation, and dispatch **MUST** key on the same identifier | Authorising under one identifier while validating or dispatching under another is a confused-deputy vulnerability | +| 2 | A derivation between `type` and `schema` **MUST** be total and unambiguous over the catalogue, or the identifier **MUST** be resolved once and reused | PascalCase↔kebab-case transforms are commonly lossy; the failure is silent | +| 3 | Where `type` and `dataschema` disagree, a server **MUST NOT** satisfy one decision from each | Reject, or resolve from one field consistently and log the divergence | +| 4 | A command whose schema cannot be resolved **MUST** be rejected | Forwarding unvalidated is not an acceptable fallback | +| 5 | Servers **MUST** document their version policy, and not partially honour a declared version | A silently substituted version dead-ends downstream handlers | + +**If you derive one identifier from the other, check the transform now.** The usual symptom is a policy error naming an operation the operator never configured — a correctly permitted command denied because the authorisation key and the schema key diverged. + +## Added — command authorisation + +`POST /commands` is one endpoint for every operation a service accepts, and those operations do not share a blast radius. Coarse Read/Write scopes are no longer sufficient on their own. + +| # | Requirement | Level | +|---|---|---| +| 6 | Per-command policy per credential/service/role, evaluated on every submission | SHOULD | +| 7 | Policy is **deny-by-default**; an empty policy MUST NOT mean "allow everything" | MUST | +| 8 | Policy scoped to the deployment it governs (a shared policy grants prod access as a side effect of enabling a command in test) | MUST | +| 9 | `GET /commands` reflects what the authenticated caller may actually submit | SHOULD | +| 10 | Actor fields in `data` MUST NOT be treated as authenticated identity; acting-principal fields are rejected-on-mismatch or overwritten (overwriting preferred) | MUST | +| 11 | A field naming a subject other than the caller MUST have the caller's access to that subject authorised | MUST | +| 12 | Authorisation MUST NOT be inferred from the payload validating | MUST | +| 13 | Destructive/irreversible/compliance-bypassing commands SHOULD require a control beyond the credential, and that control MUST NOT be self-serviceable | SHOULD / MUST | +| 14 | A log-only approval mode MUST NOT be the default, and the active mode MUST be discoverable by operators | MUST | + +## Clients and callers + +No changes. `dataschema` remains required and remains the absolute catalogue URI. + +--- + # Migrating from 0.8.x to 0.9.0 Spec 0.9.0 makes two coordinated breaking changes: the protocol short name is renamed **BSP → BEST** (the full name *Behavioral State Protocol* is unchanged), and the message envelope becomes a **conformant CloudEvents 1.0 profile**. This page lists everything an implementation must change. @@ -14,7 +69,7 @@ Spec 0.9.0 makes two coordinated breaking changes: the protocol short name is re | 6 | Unknown envelope attributes | Rejected (`additionalProperties: false`) | **Ignored** — never reject a message for carrying unknown attributes | | 7 | `source` field | Any string | URI-reference (RFC 3986) — bare names/routing keys remain valid; strings with spaces or invalid URI characters are not | -Unchanged: all capability endpoint paths (`/commands`, `/events`, `/queries`, `/subscriptions`), status codes, catalogue shapes, auth declaration, tenant routing rules, and the server-side validation model (validate by `type` against your own catalogue; never fetch the caller's `dataschema` URI). +Unchanged: all capability endpoint paths (`/commands`, `/events`, `/queries`, `/subscriptions`), status codes, catalogue shapes, auth declaration, tenant routing rules, and the server-side validation model (validate against your own catalogue; never fetch the caller's `dataschema` URI). > **Transition alias (non-normative):** a host **may** continue serving the old `/.well-known/bsp` path and accepting relative `dataschema` values during a migration window. Only the new forms are conformant with 0.9.0; the alias is a courtesy to un-migrated clients and should be removed once callers have moved. diff --git a/ROADMAP.md b/ROADMAP.md index aa98d63..af04ab7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,10 +1,12 @@ # BEST — Roadmap and Status -Last updated: 2026-07-22 (post spec/v0.9.0). +Last updated: 2026-07-30 (post spec/v0.9.1). ## Where the protocol stands -**v0.9.0** (current stable) was a deliberate identity-and-conformance release, executed as one breaking migration: +**v0.9.1** (current stable) is a security-surface release with no wire-format change. It fills the largest remaining gap — the spec described *how* to authenticate a caller but almost nothing about authorising an individual command, leaving the Read/Write scope table as the whole model. 0.9.1 adds deny-by-default per-command policy, actor binding (payload fields naming a principal carry no authority), and controls for destructive commands. It also **relaxes** the 0.9.0 rule that schema selection be keyed by `type`: the security control was always *don't dereference the caller's URI*, never *which server-owned identifier keys the lookup*, and the stricter wording had pushed implementations into lossy PascalCase↔kebab-case derivations that silently split authorisation from validation. See [MIGRATION.md](MIGRATION.md#migrating-from-090-to-091). + +**v0.9.0** was a deliberate identity-and-conformance release, executed as one breaking migration: - **Renamed BSP → BEST** (BEhavioral STate; the full name *Behavioral State Protocol* is unchanged). Rationale: "BSP" collides heavily inside computing (Binary Space Partitioning, Board Support Package, Bulk Synchronous Parallel); "BEST" is memorable, positions naturally against REST, and `/.well-known/best` plus the npm names were free. - **Became a conformant CloudEvents 1.0 profile.** Every valid BEST message is a valid CloudEvents 1.0 message. The former deviations were resolved: `dataschema` is the absolute catalogue URI on the wire, `source` is a URI-reference, unknown envelope attributes are ignored rather than rejected. PascalCase `type` and JSON-only content remain as profile *restrictions*. See [design decisions](specs/design-decisions.md#cloudevents-conformance). @@ -47,6 +49,7 @@ Identified during the 2026-07 review; none are scheduled yet. Ordered by expecte | Version | Date | Highlights | |---|---|---| +| spec/v0.9.1 | 2026-07-30 | Command authorisation requirements (deny-by-default, actor binding, high-impact controls); schema selection relaxed to any server-owned identifier; same-identifier rule for selection/authz/dispatch. No wire change | | spec/v0.9.0 | 2026-07-22 | BSP → BEST rename; conformant CloudEvents 1.0 profile; MIGRATION.md; standards artifacts | | spec/v0.8.1 | 2026-07-22 | Consolidated SPEC.md; example `dataschema` fixes; OpenAPI synced with spec surface | | spec/v0.8.0 | 2026-07 | A2A transport binding removed | diff --git a/specs/agents/commands.md b/specs/agents/commands.md index a31a4ae..7a5ae48 100644 --- a/specs/agents/commands.md +++ b/specs/agents/commands.md @@ -36,9 +36,11 @@ Commands use the **CloudEvents 1.0 envelope** as wire format. The same envelope > **BEST is a conformant CloudEvents 1.0 profile.** Every valid BEST message is a valid CloudEvents 1.0 message; BEST only *restricts* the envelope (PascalCase `type`, JSON-only content, `dataschema` required for commands). CloudEvents SDKs, brokers, and validators work with BEST traffic unchanged. See [Design Decisions — CloudEvents Conformance](/specs/design-decisions#cloudevents-conformance). -The `dataschema` field in an **incoming command** is informational metadata — it documents which schema the client used when constructing the payload. It is **not** an instruction to the server. The server selects the schema to validate against using the `type` field, by looking up that type in its own command catalogue. A well-formed client will have fetched the schema from `GET /commands` and its `dataschema` value will match what the server holds — but the server never needs to fetch it. +The `dataschema` field in an **incoming command** is a **selector, not a location** — it names an entry in the server's own command catalogue. The server validates against a schema it owns, selected either from the `type` or from the schema name `dataschema` carries; what it **MUST NOT** do is fetch the caller's URI. A well-formed client takes its `dataschema` value verbatim from `GET /commands`, so the two agree. -> **Note:** A server that fetches the caller-supplied `dataschema` URI to perform validation would be both architecturally wrong (the server owns its schema catalogue) and a security risk (caller-controlled URI fetch is an SSRF vector). See [Security Considerations](/specs/security#command-ingestion-dataschema-validation). +Servers **MUST** key schema selection, authorisation, and dispatch on the same identifier. `type` is PascalCase and the catalogue's `schema` is kebab-case; where a server derives one from the other, a transformation that is not total over its catalogue will drift these decisions apart. + +> **Note:** A server that fetches the caller-supplied `dataschema` URI to perform validation would be both architecturally wrong (the server owns its schema catalogue) and a security risk (caller-controlled URI fetch is an SSRF vector). See [Security Considerations](/specs/security#command-ingestion-schema-selection). | Field | Type | Required | Description | |---|---|---|---| diff --git a/specs/design-decisions.md b/specs/design-decisions.md index 8d5221d..a13e71f 100644 --- a/specs/design-decisions.md +++ b/specs/design-decisions.md @@ -265,7 +265,7 @@ BEST uses the CloudEvents 1.0 envelope as its wire format for commands and event ### Why -CloudEvents 1.0 is a widely understood, well-structured envelope that LLM clients can read, reason about, and generate natively. Full conformance means BEST messages work unchanged with CloudEvents SDKs, brokers, and validators — no adaptation layer at the boundary — and BEST composes with the CNCF ecosystem instead of forking it. The earlier deviations turned out to be unnecessary: `source` as "any string" was already almost always a valid URI-reference, casing and JSON-only rules are legitimate profile restrictions, and the relative-`dataschema` portability argument was moot because servers validate against their own catalogue keyed by `type` and never dereference the field anyway. +CloudEvents 1.0 is a widely understood, well-structured envelope that LLM clients can read, reason about, and generate natively. Full conformance means BEST messages work unchanged with CloudEvents SDKs, brokers, and validators — no adaptation layer at the boundary — and BEST composes with the CNCF ecosystem instead of forking it. The earlier deviations turned out to be unnecessary: `source` as "any string" was already almost always a valid URI-reference, casing and JSON-only rules are legitimate profile restrictions, and the relative-`dataschema` portability argument was moot because servers validate against their own catalogue and never dereference the field anyway. ### Profile restrictions @@ -274,13 +274,13 @@ CloudEvents 1.0 is a widely understood, well-structured envelope that LLM client | `type` casing | No casing requirement | PascalCase mandated (e.g. `ProposeCounter`) | Consistency for LLM tooling; catalogue `schema` names are kebab-case, `type` is PascalCase — distinct fields with distinct purposes. | | `datacontenttype` | Any media type | `"application/json"` only | BEST is JSON-only. Other content types would require out-of-band schema negotiation the protocol does not define. | | `dataschema` presence | Optional | Required for commands; optional for events | Commands are validated before queuing; untyped events are a supported pattern. | -| `dataschema` value | Absolute URI | The catalogue entry's absolute URI (resolves to `GET /commands/{schema}/{version}`) | One canonical value; servers still validate from their own catalogue keyed by `type` and **never fetch** the caller-supplied URI (SSRF — see [Security](/specs/security#command-ingestion-dataschema-validation)). | +| `dataschema` value | Absolute URI | The catalogue entry's absolute URI (resolves to `GET /commands/{schema}/{version}`) | One canonical value; servers still validate from their **own** catalogue and **never fetch** the caller-supplied URI (SSRF — see [Security](/specs/security#command-ingestion-schema-selection)). | | `source` | URI-reference, absolute recommended | Same, with an added rule: never treated as authenticated identity | Caller-declared origin; authorisation derives from credentials, not `source`. | | Extension attributes | Producers may add them | Permitted; BEST defines none and messages must not rely on them | Consumers **must** ignore unknown attributes rather than reject — this also implements BEST's forward-compatibility rule (see [Versioning](/specs/versioning)). | ### Implication for implementers - BEST messages may be validated with CloudEvents 1.0 validators and constructed with CloudEvents SDKs. -- Servers validate command payloads against their **own** catalogue schema selected by `type`; the wire `dataschema` is informational and is never fetched. +- Servers validate command payloads against their **own** catalogue schema, selected by `type` or by the schema name `dataschema` carries; the wire `dataschema` is a selector, never fetched. - Consumers and servers must tolerate unknown envelope attributes (ignore, don't reject). - CloudEvents broker integration requires no envelope adaptation. diff --git a/specs/security.md b/specs/security.md index 7727154..9a9146f 100644 --- a/specs/security.md +++ b/specs/security.md @@ -19,6 +19,34 @@ Implementations **SHOULD** define distinct authorisation scopes or roles: `GET /events` **MUST** require authentication and tenant-scoped authorisation unless a specific event stream is explicitly designated public. Unauthenticated callers **MUST NOT** be able to read domain events. +## Command Authorisation + +The scopes above are necessary but not sufficient. `POST /commands` is a single endpoint through which every named operation a service accepts is invoked, and those operations do not share a blast radius — submitting a timesheet and erasing a person's data arrive at the same URL under the same Write scope. A credential that may send one command **MUST NOT** thereby be able to send all of them. + +### Per-command policy + +- Servers **SHOULD** maintain an explicit policy of which command types each credential, service, or role may submit, and evaluate it on every submission. +- That policy **MUST** be deny-by-default: a command type with no entry is denied. Absence of a rule is not permission, and an empty policy **MUST NOT** mean "allow everything". +- Policy **MUST** be scoped to the deployment it governs. A policy shared across environments grants production access as a side effect of enabling a command in test. +- `GET /commands` **SHOULD** return the commands the authenticated caller may actually submit. A catalogue advertising commands the caller will be denied causes agents to plan operations that cannot complete, and is indistinguishable to them from a broken service. +- A denial **SHOULD** be reported distinguishably from an unknown command type, so an operator can tell a missing policy entry from a missing schema. Where catalogue membership is itself confidential, this **MAY** be collapsed into a single response. + +### Actor binding + +Command payloads routinely carry fields naming a person or subject — an approver, creator, sender, owner, tenant. These are caller-supplied and carry no more authority than [`source`](#cloudevent-source-field) does. + +- Servers **MUST NOT** treat an actor field in `data` as authenticated identity. +- Where a field identifies the **acting** principal, the server **MUST** either reject a payload whose value differs from the authenticated principal, or overwrite it with the verified value before dispatch. +- Overwriting **SHOULD** be preferred to demanding an echo. A field whose only acceptable value is already determined by the credential is pure failure surface: a caller can only restate it correctly or receive a confusing rejection, and callers frequently do not know their own internal identifiers. Stamping cannot change the outcome for a caller that sent the right value, and converts a guaranteed rejection into success for one that did not. +- Where a field identifies a **subject other than** the caller — whose invoice, whose engagement, whose organisation — the server **MUST** authorise the caller's access to that subject. Such a field is a reference, and an unchecked reference is an insecure direct object reference. +- Authorisation **MUST NOT** be inferred from the payload validating. Schema validation establishes shape, never permission. + +### High-impact commands + +- Commands that are destructive, irreversible, or that bypass a compliance control **SHOULD** require a control beyond the submitting credential: human approval, a second principal, or an out-of-band confirmation. +- Such a control **MUST NOT** be self-serviceable. If the approval step can be performed by the same credential that submitted the command, it is an audit trail, not a control. +- Where a server offers an approval mode that only logs what it would otherwise reject, that mode **MUST NOT** be the default, and its active mode **MUST** be discoverable by operators. + ## Credential Passthrough at Intermediaries An intermediary that sits between end callers and a BEST endpoint (an MCP server, a gateway, a multi-user chat backend) may need to forward each caller's *own* credential upstream instead of authenticating with a single fixed identity. Per-caller credentials are preferable to a shared key — the BEST service can then apply per-user authorisation and audit — but forwarding creates a credential-leakage risk that implementations must control: @@ -31,18 +59,31 @@ An intermediary that sits between end callers and a BEST endpoint (an MCP server The reference MCP server (`@behavioralstate/best-mcp`) implements this contract via its `allowBearerPassthrough` connection setting — see the [mcp-server README](https://github.com/behavioralstate/spec/tree/main/mcp-server#http--per-request-credential-overrides-multi-user-backends). -## Command Ingestion — `dataschema` Validation +## Command Ingestion — Schema Selection -The `dataschema` field in an inbound command is informational metadata — it documents which schema the client used when constructing the payload. It is not an instruction to the server. Servers select the validation schema using the `type` field, by looking up the command type in their own catalogue. +The `dataschema` field in an inbound command is a **selector, not a location**. It names an entry the server already owns and serves at `GET /commands/{schema}/{version}`, and a conformant client takes its value verbatim from the command catalogue. The security property that matters is that the validating schema comes from the server's own catalogue — *not* which server-owned identifier keys that lookup. -A server that fetches the caller-supplied `dataschema` URI to perform validation is architecturally incorrect: the server owns its schema catalogue and does not need the client to point it to a schema. It is also a security risk: a caller can supply an internal URI — a cloud metadata service (`http://169.254.169.254`), an internal database, or a private host — and the server becomes an unwitting proxy. This is a Server-Side Request Forgery (SSRF) attack. +A server that **fetches** the caller-supplied `dataschema` URI to perform validation is architecturally incorrect: the server owns its schema catalogue and does not need the client to point it to a schema. It is also a security risk: a caller can supply an internal URI — a cloud metadata service (`http://169.254.169.254`), an internal database, or a private host — and the server becomes an unwitting proxy. This is a Server-Side Request Forgery (SSRF) attack. **Requirements:** -- Servers **MUST** select the schema for validation from their own catalogue, keyed by the command `type` field. +- Servers **MUST** select the schema for validation from their own catalogue. The lookup **MAY** be keyed by the command `type`, or by the schema name carried in `dataschema`, provided the entry selected is one the server owns. - Servers **MUST NOT** fetch the caller-supplied `dataschema` URI for any purpose. -- Servers **SHOULD** verify that `dataschema` matches a known entry in `GET /commands` and reject commands whose `dataschema` does not match a server-owned catalogue URI. +- Servers **MUST** reject a command whose schema cannot be resolved to a catalogue entry. Forwarding an unvalidated payload is not an acceptable fallback — a resolution miss must fail closed. +- Servers **MUST** document which version policy they apply. A server that honours a caller-declared version **MUST** reject a version that does not exist rather than silently substituting another; a server that always selects the latest **MUST** ignore the caller's declared version rather than partially honouring it. +- Servers **SHOULD** reject a command whose `dataschema` does not match a known entry in `GET /commands`, even when the schema was selected by `type`. - If a server does support fetching remote schemas (for example, for cross-service federation), it **MUST** restrict URI schemes to HTTPS, apply a strict allowlist, disable redirects, and enforce fetch size, depth, and timeout limits. +### One identifier, or a total mapping + +`type` (PascalCase) and the catalogue's `schema` (kebab-case) are distinct fields naming the same operation. Servers commonly derive one from the other by string transformation, and that derivation is where authorisation and validation drift apart. + +**Requirements:** +- Schema selection, authorisation, and dispatch **MUST** be keyed on the same identifier. A server that authorises a command under one identifier while validating or dispatching it under another can be induced to authorise operation A and execute operation B — a confused-deputy vulnerability. +- Where a server derives one identifier from the other, that transformation **MUST** be total and unambiguous over its catalogue. If it is not — because some names do not round-trip, or two entries collapse onto one key — the server **MUST** resolve the identifier once and use the single resolved value for every subsequent decision. +- Where `type` and `dataschema` disagree about which operation is being invoked, servers **MUST NOT** satisfy one decision from each. Reject the command, or resolve consistently from one field and log the divergence. + +> **Why this is worth stating.** A lossy `type`→`schema` transformation fails in two directions. If the *authorisation* key misses, a correctly permitted command is denied with a policy error naming an operation the operator never configured — expensive to diagnose, but safe. If the *validation* key misses while authorisation succeeds, the caller has selected their own contract. Deny-by-default policy (see [Command Authorisation](#command-authorisation)) keeps the second case from being reachable in most implementations, but it should not be the only thing standing in the way. + ## Command Replay Protection CloudEvent `id` is a UUID and **MUST** be treated as an idempotency key by servers. diff --git a/version.json b/version.json index f16c1a1..eb9148b 100644 --- a/version.json +++ b/version.json @@ -1 +1 @@ -{ "version": "0.9.0" } +{ "version": "0.9.1" }