From 613cd024293295d290539cb54a4805d5c020bc76 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 14 Jul 2026 12:14:34 -0500 Subject: [PATCH 1/7] [docs] add ADR for binding-neutral BiDi low-level behavioral contract --- ...nnnn-bidi-low-level-behavioral-contract.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md diff --git a/docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md b/docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md new file mode 100644 index 0000000000000..956d428ee52c0 --- /dev/null +++ b/docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md @@ -0,0 +1,188 @@ +# NNNNN. Behavioral contract for the low-level WebDriver BiDi layer + +- Status: Proposed +- Discussion: _PR pending_ + +## Context + +The WebDriver BiDi specification is defined in CDDL. Any client implementation has a low-level layer +that turns typed calls into wire messages and wire messages back into typed objects. It sits above a +transport (which sends commands and correlates responses) and below the orchestration and high-level +API that program against it. *This record is about the low-level layer.* + +Because these behaviors follow from the specification rather than from taste, they are identical for any +conforming implementation, in any language — so one contract can state them for every binding at once. + +## Decision + +**Any implementation of this layer must exhibit the behaviors below, each a consequence of conforming to +the WebDriver BiDi specification.** + +An implementation must exhibit them at runtime, not merely declare them in its types or schema. A +statically-typed deserializer will fill a correctly-typed object from malformed input — most often a null +in a non-nullable field — and return it as valid unless a check is written, so a layer can type-check +perfectly and still violate the contract. Conformance is shown by the rejection firing on malformed +input, not by a happy-path round trip or matching types. + +### How to read this contract + +Each item is tagged with the *kind* of requirement it is: + +| Tier | Meaning | +|---|---| +| **Compliance** | Follows from conforming to the spec. Enumerated for comprehensiveness and correctness. | +| **Decision** | A genuine choice this record owns, not compelled by the spec. | + +The behaviors are uniform across every binding — a malformed payload is malformed in any language. Only +their *form* varies: + +- **Mechanism** — a static type system or runtime checks. +- **Object** — a dataclass, a record, a value type. +- **Exception** — a shared error *category* is required; the exact type is idiomatic. + +### Outbound (constructing and sending) + +1. **Spec strings go on the wire verbatim.** *(Compliance.)* The exact spec token appears on the wire for + method names, enum values, and fixed/const values; no casing or naming transform alters it. The + spec's `beforeunload` goes on the wire unchanged; a binding that camelCases it to `beforeUnload` fails. + +2. **Optionality and nullability are represented exactly as the spec declares.** *(Compliance.)* + - **Omitted is distinct from explicit null.** An unset optional is *absent* from the payload; an + explicit null serializes as `null`. The remote end acts on the difference. + - **Per-field nullability is honored** as declared. + - **A nullable constant is a settable value.** Where a field's value is a constant *and* nullable + (`browsingContext.setBypassCSP`, `emulation.setScriptingEnabled`), the layer must send the literal + *or* `null`, never only the literal. (A non-nullable constant is always the literal, so it can be a + fixed value.) + +3. **The definitions produce only the message body, never the envelope.** *(Compliance.)* This layer emits + the command or event body; forming the `{id, method, params}` envelope is the transport's + responsibility, not this layer's. + +4. **Outbound is validated locally, before sending.** *(Decision.)* Enum membership, required-field + presence, and no unknown properties on closed types are checked before the message leaves, so a caller + mistake is a local error rather than a server round-trip. Passing an enum value the spec doesn't + define, for instance, raises locally instead of being sent for the remote end to reject. A static + binding gets the check from the type system; a dynamic one does it explicitly. (These classes carry + only spec-modeled, validated commands; a higher layer needing a command the spec doesn't model would + create its own separate implementation rather than route untyped data through a typed one.) + +### Extensibility (cross-cutting) + +5. **Extensibility follows the spec's per-type signal, inbound and outbound.** *(Decision.)* Whether a + type admits extra properties is read from its own spec definition, per type and never a hand-maintained + list, so vendor extension works exactly where the spec permits it and validation stays strict + everywhere else. In practice: an `Extensible` type carries caller-supplied extras onto the wire (vendor + fields such as `goog:*` capabilities or vendor proxy keys) and tolerates unknown inbound properties, + while a closed type rejects unknown properties outbound (item 4) and accepts-but-ignores them inbound + (item 8). The one prohibition is never injecting arbitrary properties into a *closed* type, which would + defeat item 4. (Message-level extras are separate: the command envelope is itself `Extensible` + (`Command = { id, CommandData, Extensible }`), but it is formed above this layer per item 3, so those + extras are out of scope here — neither required nor forbidden, surfaced wherever a binding likes.) + +### Inbound (receiving and parsing) + +6. **Variants and vocabulary are resolved by the spec's declared rule.** *(Compliance.)* A union resolves + to a variant by its declared rule (discriminator value, presence of required keys, or declared + default), never a re-derived structural guess, so a valid payload always resolves to the same variant; + resolving it wrong is a parsing bug. Raising on an *unrecognized* enum token or union variant is the + strict default (a Decision, item 10), not part of this compliance floor; for a union whose arms are + all objects, a non-object payload has no variant to select and raises. + +7. **Inbound fields are validated strictly against the resolved type.** *(Decision.)* Once item 6 has + resolved the type, each field is checked against it, because a wrong field populated silently — a null + dropped into a non-nullable slot, say — misrepresents the protocol state instead of failing. Each of + these raises: a missing required field; a null in a non-nullable field; a value of the wrong primitive + type; a list/scalar cardinality mismatch; a non-object where a field expects an object. Relaxations are + reactive (item 10). **Satisfying item 6 does not satisfy item 7.** + +8. **Unknown inbound properties never cause an error.** *(Compliance.)* Every type tolerates unknown + properties — non-negotiable, because the spec permits extension. Most parsers do this by default; a + reject-unmapped setting would violate it. (Whether an unknown property is also *preserved* is item 9.) + +9. **Preserving extras is scoped to types that are both extensible and re-sendable.** *(Decision.)* + Tolerating an unknown property (item 8) is the floor; *preserving* it — **storing** it readable after + parse and **echoing** it back on serialization — is worth doing only where a received instance can go + back out onto the wire, so it happens only when both spec-derivable facts hold: + - **Extensible** — its CDDL definition includes the `Extensible` group (`Extensible = (*text => any)`), + admitting arbitrary extra properties. + - **Re-sendable** — it can appear within a command's parameters, so a received instance can be handed + back. + + The types meeting both are read from the schema, not a fixed list — cookies, capabilities, and proxy + configuration among them. A cookie read from `storage.getCookies` + with a vendor attribute, then passed to `storage.setCookie`, must reach the wire with that attribute + intact. Every other type (an extensible-but-inbound-only log entry, or a non-extensible type) tolerates + and drops (item 8). Where a type both stores inbound extras and takes caller-set outbound extras, the + two merge on serialization and a caller-set value wins. (Widening this scope, if the layer ever went + public, is the alternative weighed in Considered options.) + +10. **Inbound is strict by default, relaxed only reactively and uniformly.** *(Decision.)* The layer + rejects spec-incorrect data rather than absorbing it, because a clear error beats silently + misrepresenting the response, and it is cheaper to loosen a strict contract than to tighten a lenient + one. When a real remote end is seen sending off-spec data, the specific field or command is relaxed + through a documented, reversible change (citing the offending implementation and the spec issue), + applied to every binding at once — never one alone, and never by starting lenient. One exception is + sanctioned from the start: an enum that reports an error (an unrecognized error code in an + `ErrorResponse`), where raising would swallow the error being reported; there, an unknown token is + surfaced, not thrown. + +### Surface + +11. **Names mirror the spec, mapped to language idiom.** *(Decision.)* The surface method mirrors the spec + command and params/fields carry idiomatic names of their wire keys, so the layer reads as a direct + projection of the spec and cross-references cleanly across bindings. For example, a Python + `set_viewport(device_pixel_ratio=…)` call serializes the wire key `devicePixelRatio` under + `browsingContext.setViewport`. This is a naming convention rather than a spec requirement (keeping the + *wire* names exact is item 1); language sugar belongs in the higher public layer, not here. + +12. **Structured data is typed, not raw maps.** *(Decision.)* Params and results are typed value objects: + enums are the language's closed-vocabulary type, and discriminated-union variants are distinct types + branched on by type, not by inspecting a tag value. A `script.evaluate` result, for example, comes + back as a `StringValue` or `NumberValue`, not a raw `{type, value}` map. This typing is the foundation + the inbound items stand on: a raw-map surface structurally cannot do union dispatch (6), field checks + (7), or read-only objects (13). + +13. **Objects this layer hands to callers are read-only.** *(Decision.)* A received object that reaches a + caller (e.g. the request inside a network handler) is immutable at the top level, because mutating it + changes nothing on the wire and the layer would rather forbid the mutation than let it mislead. The + caller reads the object and acts through the higher layer. Shallow immutability is enough; nested + containers need not be deep-frozen. + +## Considered options + +The Compliance items (1, 2, 3, 6, 8) have no valid alternative; a divergence there is a bug. + +- **Specify a production method (mandate generation) rather than behavior.** Generation does not by + itself guarantee any behavior and imposes cost where it isn't ergonomic; the behaviors are identical + however the code is produced. Rejected: the contract is the behavior, generation an optional strategy. +- **Item 4 — defer to the server.** Send the command and let the remote end return an error rather than + validating locally; the spec defines those errors, so it is legal. Rejected: a local error is clearer + and cheaper than a round-trip, and a static binding gets it for free. +- **Item 5 — surface message-level extras in the definitions layer** rather than leaving them to the + transport. Rejected: the envelope is the transport's (item 3); this layer governs per-type + extensibility only. +- **Item 7 — lenient inbound.** Best-effort an off-spec response rather than raising. Rejected: silently + misrepresenting protocol state is worse than a clear error; strictness relaxes reactively (item 10) + when a real payload demands it. +- **Item 9 — keep extras broad**, preserving unknown fields on all extensible types. This is the right + choice for a public surface, which can't narrow later without dropping a field a user relies on. + Rejected here only because the layer is internal: it can scope tight and widen later at no cost. *This + is the one decision the internal premise actually turns on.* +- **Item 10 — start lenient and tighten later** rather than starting strict. Rejected on merit: it is + cheaper to loosen a strict contract than to tighten a lenient one. Being internal only lowers the cost + of the strict path (a rejection on browser drift is a dev-side fix, not a user-facing break); it is not + the reason to be strict — a careful public implementation can be strict too. +- **Item 11 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and + params aids cross-referencing and keeps bindings comparable. +- **Item 12 — raw dicts** rather than typed objects; the spec permits it. Rejected: raw maps structurally + cannot do strict dispatch, field checks, or read-only objects. +- **Item 13 — mutable received objects** rather than read-only. Rejected: a received object is + informational, so read-only prevents mistaking it for a control surface. + +## Consequences + +- The contract holds for any implementation regardless of language or production method; conformance is + checkable independently of how the layer was built. +- The per-type signals items 6/7/9 need are all derivable from the spec; a binding that discards one, or + parses into a lenient runtime, falls out of conformance on exactly the items that signal feeds. From 98e1858c818dd9bdb288c5402d18e5cf26feba12 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 16:21:55 -0500 Subject: [PATCH 2/7] [docs] number the low-level BiDi behavioral-contract ADR as 17786 --- ...ontract.md => 17786-bidi-low-level-behavioral-contract.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docs/decisions/{nnnnn-bidi-low-level-behavioral-contract.md => 17786-bidi-low-level-behavioral-contract.md} (99%) diff --git a/docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md similarity index 99% rename from docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md rename to docs/decisions/17786-bidi-low-level-behavioral-contract.md index 956d428ee52c0..7b4290624eb64 100644 --- a/docs/decisions/nnnnn-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -1,7 +1,7 @@ -# NNNNN. Behavioral contract for the low-level WebDriver BiDi layer +# 17786. Behavioral contract for the low-level WebDriver BiDi layer - Status: Proposed -- Discussion: _PR pending_ +- Discussion: https://github.com/SeleniumHQ/selenium/pull/17786 ## Context From dfe1c07075e5d273b53e71028c19013c062547b5 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 17:00:36 -0500 Subject: [PATCH 3/7] [docs] align ADR intro with the Compliance/Decision split; fix grammar --- .../17786-bidi-low-level-behavioral-contract.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 7b4290624eb64..22548ccaa5851 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -8,15 +8,16 @@ The WebDriver BiDi specification is defined in CDDL. Any client implementation has a low-level layer that turns typed calls into wire messages and wire messages back into typed objects. It sits above a transport (which sends commands and correlates responses) and below the orchestration and high-level -API that program against it. *This record is about the low-level layer.* +API that build on it. *This record is about the low-level layer.* -Because these behaviors follow from the specification rather than from taste, they are identical for any -conforming implementation, in any language — so one contract can state them for every binding at once. +These behaviors are observable at the wire boundary, so bindings diverge on them without a shared +reference. One contract can state them for every binding at once — the same behavior in any language. ## Decision -**Any implementation of this layer must exhibit the behaviors below, each a consequence of conforming to -the WebDriver BiDi specification.** +**Any implementation of this layer must exhibit the behaviors below.** Some follow from conforming to the +WebDriver BiDi specification; the rest are choices this record standardizes so bindings don't diverge — +the next section marks which is which. An implementation must exhibit them at runtime, not merely declare them in its types or schema. A statically-typed deserializer will fill a correctly-typed object from malformed input — most often a null From 5b77f3193554233cef26a3bbcf5ee1dd076e42e4 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 18:02:31 -0500 Subject: [PATCH 4/7] [docs] drop envelope/body item from BiDi contract; renumber to 12 behaviors --- ...7786-bidi-low-level-behavioral-contract.md | 90 +++++++++---------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 22548ccaa5851..3c483006cf9ba 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -7,7 +7,7 @@ The WebDriver BiDi specification is defined in CDDL. Any client implementation has a low-level layer that turns typed calls into wire messages and wire messages back into typed objects. It sits above a -transport (which sends commands and correlates responses) and below the orchestration and high-level +transport (which sends commands and correlates responses by id) and below the orchestration and high-level API that build on it. *This record is about the low-level layer.* These behaviors are observable at the wire boundary, so bindings diverge on them without a shared @@ -56,11 +56,7 @@ their *form* varies: *or* `null`, never only the literal. (A non-nullable constant is always the literal, so it can be a fixed value.) -3. **The definitions produce only the message body, never the envelope.** *(Compliance.)* This layer emits - the command or event body; forming the `{id, method, params}` envelope is the transport's - responsibility, not this layer's. - -4. **Outbound is validated locally, before sending.** *(Decision.)* Enum membership, required-field +3. **Outbound is validated locally, before sending.** *(Decision.)* Enum membership, required-field presence, and no unknown properties on closed types are checked before the message leaves, so a caller mistake is a local error rather than a server round-trip. Passing an enum value the spec doesn't define, for instance, raises locally instead of being sent for the remote end to reject. A static @@ -70,39 +66,39 @@ their *form* varies: ### Extensibility (cross-cutting) -5. **Extensibility follows the spec's per-type signal, inbound and outbound.** *(Decision.)* Whether a +4. **Extensibility follows the spec's per-type signal, inbound and outbound.** *(Decision.)* Whether a type admits extra properties is read from its own spec definition, per type and never a hand-maintained list, so vendor extension works exactly where the spec permits it and validation stays strict everywhere else. In practice: an `Extensible` type carries caller-supplied extras onto the wire (vendor fields such as `goog:*` capabilities or vendor proxy keys) and tolerates unknown inbound properties, - while a closed type rejects unknown properties outbound (item 4) and accepts-but-ignores them inbound - (item 8). The one prohibition is never injecting arbitrary properties into a *closed* type, which would - defeat item 4. (Message-level extras are separate: the command envelope is itself `Extensible` - (`Command = { id, CommandData, Extensible }`), but it is formed above this layer per item 3, so those - extras are out of scope here — neither required nor forbidden, surfaced wherever a binding likes.) + while a closed type rejects unknown properties outbound (item 3) and accepts-but-ignores them inbound + (item 7). The one prohibition is never injecting arbitrary properties into a *closed* type, which would + defeat item 3. (Message-level extras are separate: the command envelope is itself `Extensible` + (`Command = { id, CommandData, Extensible }`), but it is formed above this layer by the transport, so + those extras are out of scope here — neither required nor forbidden, surfaced wherever a binding likes.) ### Inbound (receiving and parsing) -6. **Variants and vocabulary are resolved by the spec's declared rule.** *(Compliance.)* A union resolves +5. **Variants and vocabulary are resolved by the spec's declared rule.** *(Compliance.)* A union resolves to a variant by its declared rule (discriminator value, presence of required keys, or declared default), never a re-derived structural guess, so a valid payload always resolves to the same variant; resolving it wrong is a parsing bug. Raising on an *unrecognized* enum token or union variant is the - strict default (a Decision, item 10), not part of this compliance floor; for a union whose arms are + strict default (a Decision, item 9), not part of this compliance floor; for a union whose arms are all objects, a non-object payload has no variant to select and raises. -7. **Inbound fields are validated strictly against the resolved type.** *(Decision.)* Once item 6 has +6. **Inbound fields are validated strictly against the resolved type.** *(Decision.)* Once item 5 has resolved the type, each field is checked against it, because a wrong field populated silently — a null dropped into a non-nullable slot, say — misrepresents the protocol state instead of failing. Each of these raises: a missing required field; a null in a non-nullable field; a value of the wrong primitive type; a list/scalar cardinality mismatch; a non-object where a field expects an object. Relaxations are - reactive (item 10). **Satisfying item 6 does not satisfy item 7.** + reactive (item 9). **Satisfying item 5 does not satisfy item 6.** -8. **Unknown inbound properties never cause an error.** *(Compliance.)* Every type tolerates unknown +7. **Unknown inbound properties never cause an error.** *(Compliance.)* Every type tolerates unknown properties — non-negotiable, because the spec permits extension. Most parsers do this by default; a - reject-unmapped setting would violate it. (Whether an unknown property is also *preserved* is item 9.) + reject-unmapped setting would violate it. (Whether an unknown property is also *preserved* is item 8.) -9. **Preserving extras is scoped to types that are both extensible and re-sendable.** *(Decision.)* - Tolerating an unknown property (item 8) is the floor; *preserving* it — **storing** it readable after +8. **Preserving extras is scoped to types that are both extensible and re-sendable.** *(Decision.)* + Tolerating an unknown property (item 7) is the floor; *preserving* it — **storing** it readable after parse and **echoing** it back on serialization — is worth doing only where a received instance can go back out onto the wire, so it happens only when both spec-derivable facts hold: - **Extensible** — its CDDL definition includes the `Extensible` group (`Extensible = (*text => any)`), @@ -114,37 +110,37 @@ their *form* varies: configuration among them. A cookie read from `storage.getCookies` with a vendor attribute, then passed to `storage.setCookie`, must reach the wire with that attribute intact. Every other type (an extensible-but-inbound-only log entry, or a non-extensible type) tolerates - and drops (item 8). Where a type both stores inbound extras and takes caller-set outbound extras, the + and drops (item 7). Where a type both stores inbound extras and takes caller-set outbound extras, the two merge on serialization and a caller-set value wins. (Widening this scope, if the layer ever went public, is the alternative weighed in Considered options.) -10. **Inbound is strict by default, relaxed only reactively and uniformly.** *(Decision.)* The layer - rejects spec-incorrect data rather than absorbing it, because a clear error beats silently - misrepresenting the response, and it is cheaper to loosen a strict contract than to tighten a lenient - one. When a real remote end is seen sending off-spec data, the specific field or command is relaxed - through a documented, reversible change (citing the offending implementation and the spec issue), - applied to every binding at once — never one alone, and never by starting lenient. One exception is - sanctioned from the start: an enum that reports an error (an unrecognized error code in an - `ErrorResponse`), where raising would swallow the error being reported; there, an unknown token is - surfaced, not thrown. +9. **Inbound is strict by default, relaxed only reactively and uniformly.** *(Decision.)* The layer + rejects spec-incorrect data rather than absorbing it, because a clear error beats silently + misrepresenting the response, and it is cheaper to loosen a strict contract than to tighten a lenient + one. When a real remote end is seen sending off-spec data, the specific field or command is relaxed + through a documented, reversible change (citing the offending implementation and the spec issue), + applied to every binding at once — never one alone, and never by starting lenient. One exception is + sanctioned from the start: an enum that reports an error (an unrecognized error code in an + `ErrorResponse`), where raising would swallow the error being reported; there, an unknown token is + surfaced, not thrown. ### Surface -11. **Names mirror the spec, mapped to language idiom.** *(Decision.)* The surface method mirrors the spec +10. **Names mirror the spec, mapped to language idiom.** *(Decision.)* The surface method mirrors the spec command and params/fields carry idiomatic names of their wire keys, so the layer reads as a direct projection of the spec and cross-references cleanly across bindings. For example, a Python `set_viewport(device_pixel_ratio=…)` call serializes the wire key `devicePixelRatio` under `browsingContext.setViewport`. This is a naming convention rather than a spec requirement (keeping the *wire* names exact is item 1); language sugar belongs in the higher public layer, not here. -12. **Structured data is typed, not raw maps.** *(Decision.)* Params and results are typed value objects: +11. **Structured data is typed, not raw maps.** *(Decision.)* Params and results are typed value objects: enums are the language's closed-vocabulary type, and discriminated-union variants are distinct types branched on by type, not by inspecting a tag value. A `script.evaluate` result, for example, comes back as a `StringValue` or `NumberValue`, not a raw `{type, value}` map. This typing is the foundation - the inbound items stand on: a raw-map surface structurally cannot do union dispatch (6), field checks - (7), or read-only objects (13). + the inbound items stand on: a raw-map surface structurally cannot do union dispatch (5), field checks + (6), or read-only objects (12). -13. **Objects this layer hands to callers are read-only.** *(Decision.)* A received object that reaches a +12. **Objects this layer hands to callers are read-only.** *(Decision.)* A received object that reaches a caller (e.g. the request inside a network handler) is immutable at the top level, because mutating it changes nothing on the wire and the layer would rather forbid the mutation than let it mislead. The caller reads the object and acts through the higher layer. Shallow immutability is enough; nested @@ -152,38 +148,38 @@ their *form* varies: ## Considered options -The Compliance items (1, 2, 3, 6, 8) have no valid alternative; a divergence there is a bug. +The Compliance items (1, 2, 5, 7) have no valid alternative; a divergence there is a bug. - **Specify a production method (mandate generation) rather than behavior.** Generation does not by itself guarantee any behavior and imposes cost where it isn't ergonomic; the behaviors are identical however the code is produced. Rejected: the contract is the behavior, generation an optional strategy. -- **Item 4 — defer to the server.** Send the command and let the remote end return an error rather than +- **Item 3 — defer to the server.** Send the command and let the remote end return an error rather than validating locally; the spec defines those errors, so it is legal. Rejected: a local error is clearer and cheaper than a round-trip, and a static binding gets it for free. -- **Item 5 — surface message-level extras in the definitions layer** rather than leaving them to the - transport. Rejected: the envelope is the transport's (item 3); this layer governs per-type +- **Item 4 — surface message-level extras in the definitions layer** rather than leaving them to the + transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. -- **Item 7 — lenient inbound.** Best-effort an off-spec response rather than raising. Rejected: silently - misrepresenting protocol state is worse than a clear error; strictness relaxes reactively (item 10) +- **Item 6 — lenient inbound.** Best-effort an off-spec response rather than raising. Rejected: silently + misrepresenting protocol state is worse than a clear error; strictness relaxes reactively (item 9) when a real payload demands it. -- **Item 9 — keep extras broad**, preserving unknown fields on all extensible types. This is the right +- **Item 8 — keep extras broad**, preserving unknown fields on all extensible types. This is the right choice for a public surface, which can't narrow later without dropping a field a user relies on. Rejected here only because the layer is internal: it can scope tight and widen later at no cost. *This is the one decision the internal premise actually turns on.* -- **Item 10 — start lenient and tighten later** rather than starting strict. Rejected on merit: it is +- **Item 9 — start lenient and tighten later** rather than starting strict. Rejected on merit: it is cheaper to loosen a strict contract than to tighten a lenient one. Being internal only lowers the cost of the strict path (a rejection on browser drift is a dev-side fix, not a user-facing break); it is not the reason to be strict — a careful public implementation can be strict too. -- **Item 11 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and +- **Item 10 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and params aids cross-referencing and keeps bindings comparable. -- **Item 12 — raw dicts** rather than typed objects; the spec permits it. Rejected: raw maps structurally +- **Item 11 — raw dicts** rather than typed objects; the spec permits it. Rejected: raw maps structurally cannot do strict dispatch, field checks, or read-only objects. -- **Item 13 — mutable received objects** rather than read-only. Rejected: a received object is +- **Item 12 — mutable received objects** rather than read-only. Rejected: a received object is informational, so read-only prevents mistaking it for a control surface. ## Consequences - The contract holds for any implementation regardless of language or production method; conformance is checkable independently of how the layer was built. -- The per-type signals items 6/7/9 need are all derivable from the spec; a binding that discards one, or +- The per-type signals items 5/6/8 need are all derivable from the spec; a binding that discards one, or parses into a lenient runtime, falls out of conformance on exactly the items that signal feeds. From e96582901778e16707d09f8c4843750f522a9bbd Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 18:46:57 -0500 Subject: [PATCH 5/7] [docs] extend BiDi contract item 3 to reject wrong const values outbound --- .../17786-bidi-low-level-behavioral-contract.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 3c483006cf9ba..1acbf87b46bfa 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -56,10 +56,12 @@ their *form* varies: *or* `null`, never only the literal. (A non-nullable constant is always the literal, so it can be a fixed value.) -3. **Outbound is validated locally, before sending.** *(Decision.)* Enum membership, required-field - presence, and no unknown properties on closed types are checked before the message leaves, so a caller - mistake is a local error rather than a server round-trip. Passing an enum value the spec doesn't - define, for instance, raises locally instead of being sent for the remote end to reject. A static +3. **Outbound is validated locally, before sending.** *(Decision.)* Enum and const-value membership, + required-field presence, and no unknown properties on closed types are checked before the message + leaves, so a caller mistake is a local error rather than a server round-trip. Passing an enum value the + spec doesn't define, for instance, raises locally instead of being sent for the remote end to reject — + as does a value neither the literal nor `null` for a nullable constant (a `true`/`null` field rejects + `false`; see item 2). A static binding gets the check from the type system; a dynamic one does it explicitly. (These classes carry only spec-modeled, validated commands; a higher layer needing a command the spec doesn't model would create its own separate implementation rather than route untyped data through a typed one.) From 1b0e91a293374bf0ba92fe6a1dbf4bac99c3f017 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 19:36:17 -0500 Subject: [PATCH 6/7] [docs] attribute unknown-property drop to item 8, not item 7, in BiDi contract --- .../decisions/17786-bidi-low-level-behavioral-contract.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 1acbf87b46bfa..2b5f4fdfbd1cf 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -73,8 +73,9 @@ their *form* varies: list, so vendor extension works exactly where the spec permits it and validation stays strict everywhere else. In practice: an `Extensible` type carries caller-supplied extras onto the wire (vendor fields such as `goog:*` capabilities or vendor proxy keys) and tolerates unknown inbound properties, - while a closed type rejects unknown properties outbound (item 3) and accepts-but-ignores them inbound - (item 7). The one prohibition is never injecting arbitrary properties into a *closed* type, which would + while a closed type rejects unknown properties outbound (item 3), accepts them inbound without erroring + (item 7), and never preserves them (item 8). The one prohibition is never injecting arbitrary + properties into a *closed* type, which would defeat item 3. (Message-level extras are separate: the command envelope is itself `Extensible` (`Command = { id, CommandData, Extensible }`), but it is formed above this layer by the transport, so those extras are out of scope here — neither required nor forbidden, surfaced wherever a binding likes.) @@ -112,7 +113,8 @@ their *form* varies: configuration among them. A cookie read from `storage.getCookies` with a vendor attribute, then passed to `storage.setCookie`, must reach the wire with that attribute intact. Every other type (an extensible-but-inbound-only log entry, or a non-extensible type) tolerates - and drops (item 7). Where a type both stores inbound extras and takes caller-set outbound extras, the + it without erroring (item 7) but does not preserve it. Where a type both stores inbound extras and takes + caller-set outbound extras, the two merge on serialization and a caller-set value wins. (Widening this scope, if the layer ever went public, is the alternative weighed in Considered options.) From 412d9f2dca099897e11a9ef4c081fc07462855b2 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 16 Jul 2026 08:43:56 -0500 Subject: [PATCH 7/7] [docs] drop shared-error-category requirement from BiDi contract; require only that malformed input raises --- docs/decisions/17786-bidi-low-level-behavioral-contract.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 2b5f4fdfbd1cf..97853dacdb51b 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -39,7 +39,8 @@ their *form* varies: - **Mechanism** — a static type system or runtime checks. - **Object** — a dataclass, a record, a value type. -- **Exception** — a shared error *category* is required; the exact type is idiomatic. +- **Exception** — malformed input must *raise* rather than return a bad value; the error's type and + hierarchy are idiomatic. ### Outbound (constructing and sending)