From a1edae7cbd780c2ecabef381253c7ff90745b397 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 14 Jul 2026 12:14:34 -0500 Subject: [PATCH 01/19] [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 51bf66e802de1f3684c82265f97ac9089ff19df3 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 16:21:55 -0500 Subject: [PATCH 02/19] [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 29bc002ba372b34b6026a27154441ac64e081b1c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 17:00:36 -0500 Subject: [PATCH 03/19] [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 db0fb5d9481b92f484536b06bc9c172168757a09 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 18:02:31 -0500 Subject: [PATCH 04/19] [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 dda19aafe41fc06c4aac67c15d05c30337ea1628 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 18:46:57 -0500 Subject: [PATCH 05/19] [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 6eeeb06f168a41aa22306970375a7e1de9325379 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 15 Jul 2026 19:36:17 -0500 Subject: [PATCH 06/19] [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 46926620e8442b5923f38ab90e3e895767e17f0b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 16 Jul 2026 08:43:56 -0500 Subject: [PATCH 07/19] [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) From 6ecc0726dc0a6bbf8334eb6e85c5951b0772df7e Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 24 Jul 2026 12:02:13 -0500 Subject: [PATCH 08/19] [docs] revise BiDi behavioral contract per review: corruption/absence split, relaxation scope, consistency pass --- ...7786-bidi-low-level-behavioral-contract.md | 175 ++++++++++-------- 1 file changed, 96 insertions(+), 79 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 97853dacdb51b..5b1f45151da84 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -15,9 +15,9 @@ reference. One contract can state them for every binding at once — the same be ## Decision -**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. +**Any implementation of this layer must exhibit the behaviors below.** Some are settled by the spec; 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 @@ -31,7 +31,7 @@ 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. | +| **Compliance** | The spec determines the answer; there is nothing for this record to decide. | | **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 @@ -44,8 +44,8 @@ their *form* varies: ### 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 +1. **Spec strings go on the wire verbatim.** *(Compliance.)* The exact spec string 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.)* @@ -57,52 +57,69 @@ 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 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.) +3. **Outbound is validated locally, before sending.** *(Decision.)* A caller mistake is caught here as a + local error, rather than sent for the remote end to reject on a round-trip. Three things are checked + before the message leaves: enum and const-value membership, required-field presence, and no unknown + properties on a closed type. An undefined enum value raises locally; so does a value that is neither + the literal nor `null` for a nullable constant (a `true`/`null` field rejects `false`; see item 2). A + static binding gets these from its type system; a dynamic one checks them explicitly. This layer + carries only spec-modeled commands — a higher layer needing one the spec does not model builds its own + path rather than routing untyped data through a typed one. ### Extensibility (cross-cutting) -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 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.) +4. **Extensibility follows the spec's per-type signal.** *(Decision.)* Whether a type admits extra + properties is read from its own CDDL definition, per type and never a hand-maintained list, so vendor + extension works exactly where the spec permits it. Outbound, an `Extensible` type carries + caller-supplied extras onto the wire (vendor fields such as `goog:*` capabilities or vendor proxy + keys), while a closed type rejects them (item 3); injecting extras into a closed type is the single + prohibition here. Inbound, the signal governs only whether extras are *preserved* (item 8), not + whether they are tolerated — tolerance is uniform across every type (item 6). The message envelope is + itself `Extensible` (`Command = { id, CommandData, Extensible }`), but the transport forms it above + this layer, so those message-level extras are out of scope. ### Inbound (receiving and parsing) 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 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. - -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 9). **Satisfying item 5 does not satisfy item 6.** - -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 8.) + to a variant by its declared rule — a discriminator value, the presence of required keys, or a + declared default — never a re-derived structural guess, so a valid payload always resolves to the same + variant, and resolving it wrong is a parsing bug. An *unrecognized* token or variant is out of scope + here — raising on it is the strict default (item 7). For a union whose arms are all objects, a + non-object payload selects no variant and raises. + +6. **Inbound payloads are validated against the resolved type.** *(Decision.)* Once item 5 has resolved + the type, every key in the payload falls into one of three cases, and a declared field is checked + rather than populated silently: + - **Corruption — always raises.** 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. The wire + asserted something untrue, and absorbing it is what misrepresents protocol state. + - **Absence — raises.** A required field is missing. Nothing untrue was asserted and every field that + did arrive is still correct, so absence is the one case a relaxation may reach (item 7). + - **Undeclared key — never raises.** Every type tolerates a property it does not define, open or + closed; most parsers do this by default, and a reject-unmapped setting would violate it. The spec + does not compel this — §4 Transport gives no normative requirements for local ends, and + `Extensible` is declared per type — so universal tolerance is this record's forward-compatibility + choice. Whether such a key is also *preserved* is item 8. + +7. **Inbound is strict by default.** *(Decision.)* Unrecognized enum tokens and union variants (item 5) + and failed field checks (item 6) raise; a strict contract is cheaper to loosen than a lenient one is + to tighten. A specific field may be relaxed where a real remote end sends it off-spec, and any such + relaxation is bounded: + - **Only absence is relaxable** (item 6): never corruption, and never a field item 5 dispatches on. + - **It removes a check; it never exposes a type, variant, or enum value the spec does not define.** + Inventing one to absorb a malformed payload models a browser's bug as protocol, and it outlives + the bug. + - **The tolerated absence is still reported**, never silently absorbed. A binding *may* additionally + let a caller admit an uncatalogued absence at runtime, so a user is not blocked until the next + release; that is a per-binding convenience, not required here, and it too reports. + + An error the remote reports always raises as that error, whatever else is relaxed. A malformed part + of an `ErrorResponse` degrades what the raised error carries but never replaces it with a parse + failure — the command did fail, and an error response has no protocol state to misrepresent beyond the + failure itself. 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 + Tolerating an undeclared key (item 6) 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)`), @@ -110,42 +127,32 @@ their *form* varies: - **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` + Which types meet both is determined by each type's spec definition, not a hand-maintained 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 - 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.) - -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. + intact. Any other type — an + inbound-only extensible type such as a log entry, or a closed type — tolerates an undeclared key + without erroring (item 6) 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, should the layer ever go public, is the alternative weighed in Considered options. ### Surface -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. +9. **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. -11. **Structured data is typed, not raw maps.** *(Decision.)* Params and results are typed value objects: +10. **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 (5), field checks - (6), or read-only objects (12). + (6), or read-only objects (11). -12. **Objects this layer hands to callers are read-only.** *(Decision.)* A received object that reaches a +11. **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 @@ -153,7 +160,7 @@ their *form* varies: ## Considered options -The Compliance items (1, 2, 5, 7) have no valid alternative; a divergence there is a bug. +The Compliance items (1, 2, 5) 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 @@ -165,21 +172,31 @@ The Compliance items (1, 2, 5, 7) have no valid alternative; a divergence there transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. - **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) + misrepresenting protocol state is worse than a clear error; strictness loosens on evidence (item 7) when a real payload demands it. +- **Item 6 — do not enforce required-ness inbound at all**, treating every field as possibly absent. + Rejected: it erases the signal, and a divergence never recorded is never reported upstream or retired. + webdriverbidi-net retired six such relaxations in nineteen months precisely because strictness + surfaced them; a binding enforcing nothing accumulates the same divergences with no record of any. +- **Item 7 — 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 7 — mandate a runtime escape hatch** for uncatalogued absences, as a required behavior of every + binding. Not adopted: no implementation ships that exact shape (net's `TransportErrorBehavior` discards + the whole message rather than admitting the absent field), and a global strictness toggle is a + configuration concern this record otherwise scopes out. Left as a per-binding option instead — + permitted because it spares a user the release-cadence wait, not required because the catalogued + relaxation path already covers the divergences that recur. - **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 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 10 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and +- **Item 9 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and params aids cross-referencing and keeps bindings comparable. -- **Item 11 — raw dicts** rather than typed objects; the spec permits it. Rejected: raw maps structurally +- **Item 10 — 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 12 — mutable received objects** rather than read-only. Rejected: a received object is +- **Item 11 — mutable received objects** rather than read-only. Rejected: a received object is informational, so read-only prevents mistaking it for a control surface. ## Consequences @@ -187,4 +204,4 @@ The Compliance items (1, 2, 5, 7) have no valid alternative; a divergence there - 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 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. + parses into a lenient runtime, falls out of conformance on exactly the items that depend on it. From e9d27912828656c3202ceba0984781b516017ef3 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 28 Jul 2026 21:43:48 -0500 Subject: [PATCH 09/19] =?UTF-8?q?[docs]=20BiDi=20contract:=20three=20behav?= =?UTF-8?q?ioral=20decisions=20=E2=80=94=20outbound=20validation,=20inboun?= =?UTF-8?q?d=20validation=20&=20retention,=20typed=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7786-bidi-low-level-behavioral-contract.md | 299 +++++++----------- 1 file changed, 115 insertions(+), 184 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 5b1f45151da84..292ab8c95d680 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -13,195 +13,126 @@ 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 reference. One contract can state them for every binding at once — the same behavior in any language. +The transport layer (the connection that carries messages and matches responses to their commands) and +the orchestration layer (session lifecycle, event subscription and routing, and the high-level API these +feed) are out of scope. + ## Decision -**Any implementation of this layer must exhibit the behaviors below.** Some are settled by the spec; 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 -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** | The spec determines the answer; there is nothing for this record to decide. | -| **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** — malformed input must *raise* rather than return a bad value; the error's type and - hierarchy are idiomatic. - -### Outbound (constructing and sending) - -1. **Spec strings go on the wire verbatim.** *(Compliance.)* The exact spec string 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. **Outbound is validated locally, before sending.** *(Decision.)* A caller mistake is caught here as a - local error, rather than sent for the remote end to reject on a round-trip. Three things are checked - before the message leaves: enum and const-value membership, required-field presence, and no unknown - properties on a closed type. An undefined enum value raises locally; so does a value that is neither - the literal nor `null` for a nullable constant (a `true`/`null` field rejects `false`; see item 2). A - static binding gets these from its type system; a dynamic one checks them explicitly. This layer - carries only spec-modeled commands — a higher layer needing one the spec does not model builds its own - path rather than routing untyped data through a typed one. - -### Extensibility (cross-cutting) - -4. **Extensibility follows the spec's per-type signal.** *(Decision.)* Whether a type admits extra - properties is read from its own CDDL definition, per type and never a hand-maintained list, so vendor - extension works exactly where the spec permits it. Outbound, an `Extensible` type carries - caller-supplied extras onto the wire (vendor fields such as `goog:*` capabilities or vendor proxy - keys), while a closed type rejects them (item 3); injecting extras into a closed type is the single - prohibition here. Inbound, the signal governs only whether extras are *preserved* (item 8), not - whether they are tolerated — tolerance is uniform across every type (item 6). The message envelope is - itself `Extensible` (`Command = { id, CommandData, Extensible }`), but the transport forms it above - this layer, so those message-level extras are out of scope. - -### Inbound (receiving and parsing) - -5. **Variants and vocabulary are resolved by the spec's declared rule.** *(Compliance.)* A union resolves - to a variant by its declared rule — a discriminator value, the presence of required keys, or a - declared default — never a re-derived structural guess, so a valid payload always resolves to the same - variant, and resolving it wrong is a parsing bug. An *unrecognized* token or variant is out of scope - here — raising on it is the strict default (item 7). For a union whose arms are all objects, a - non-object payload selects no variant and raises. - -6. **Inbound payloads are validated against the resolved type.** *(Decision.)* Once item 5 has resolved - the type, every key in the payload falls into one of three cases, and a declared field is checked - rather than populated silently: - - **Corruption — always raises.** 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. The wire - asserted something untrue, and absorbing it is what misrepresents protocol state. - - **Absence — raises.** A required field is missing. Nothing untrue was asserted and every field that - did arrive is still correct, so absence is the one case a relaxation may reach (item 7). - - **Undeclared key — never raises.** Every type tolerates a property it does not define, open or - closed; most parsers do this by default, and a reject-unmapped setting would violate it. The spec - does not compel this — §4 Transport gives no normative requirements for local ends, and - `Extensible` is declared per type — so universal tolerance is this record's forward-compatibility - choice. Whether such a key is also *preserved* is item 8. - -7. **Inbound is strict by default.** *(Decision.)* Unrecognized enum tokens and union variants (item 5) - and failed field checks (item 6) raise; a strict contract is cheaper to loosen than a lenient one is - to tighten. A specific field may be relaxed where a real remote end sends it off-spec, and any such - relaxation is bounded: - - **Only absence is relaxable** (item 6): never corruption, and never a field item 5 dispatches on. - - **It removes a check; it never exposes a type, variant, or enum value the spec does not define.** - Inventing one to absorb a malformed payload models a browser's bug as protocol, and it outlives - the bug. - - **The tolerated absence is still reported**, never silently absorbed. A binding *may* additionally - let a caller admit an uncatalogued absence at runtime, so a user is not blocked until the next - release; that is a per-binding convenience, not required here, and it too reports. - - An error the remote reports always raises as that error, whatever else is relaxed. A malformed part - of an `ErrorResponse` degrades what the raised error carries but never replaces it with a parse - failure — the command did fail, and an error response has no protocol state to misrepresent beyond the - failure itself. - -8. **Preserving extras is scoped to types that are both extensible and re-sendable.** *(Decision.)* - Tolerating an undeclared key (item 6) 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. - - Which types meet both is determined by each type's spec definition, not a hand-maintained 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. Any other type — an - inbound-only extensible type such as a log entry, or a closed type — tolerates an undeclared key - without erroring (item 6) 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, should the layer ever go public, is the alternative weighed in Considered options. - -### Surface - -9. **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. - -10. **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 (5), field checks - (6), or read-only objects (11). - -11. **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. +The spec settles the layer's baseline: spec strings reach the wire verbatim, an *omitted* field stays +distinct from an explicit *null*, and a union resolves by the spec's declared rule rather than a structural +guess. What the spec leaves open, this record decides. + +Where the type system prevents a violation it satisfies the contract: a static binding whose types make +an invalid outbound payload unconstructable meets decision 1 at compile time. Inbound is different — a +statically-typed deserializer fills a correctly-typed object from malformed input (typically a null in a +non-nullable field) and returns it as valid, so the inbound decisions must be exhibited at runtime, not +merely declared in types or schema. + +1. **Outbound is validated before sending.** A caller mistake surfaces as a local error rather than a + remote round-trip. For all commands defined in the spec: + - **Error if an enum value is undefined**, or a nullable constant is set to anything but its literal + or null; + - **Error unless all required fields are set**; + - **Error if an unknown property is set**, unless the spec marks its type as extensible. + + A static binding gets these from its type system; a dynamic one must check them explicitly. + +2. **Inbound is validated against the resolved type.** Process error responses first: a remote error + surfaces as an error even if its payload fails validation — the checks below must not turn it into a + local serialization failure. The error's contents and shape are otherwise out of scope. + + Otherwise, once the spec's union rule resolves the payload's type (if applicable), each mismatch is + handled by its kind: + 1. **Error if corrupted** — the value cannot fit the resolved type: a null in a non-nullable field, a + wrong primitive type, a cardinality mismatch, a non-object where an object is expected. + 2. **Warn if a required field is missing** — the field must be left *omitted* (not an explicit *null*, + and not a generic placeholder), so an implementation based on a previously generated spec does not fail + against a browser implementing a more recent one. An absent field reads the same whether it reflects + that lag or a genuine defect — the layer cannot tell them apart — so it warns rather than errors, + leaving a strict mode to escalate to an error for callers who want it. + 3. **Warn if a property is undeclared** — a property the type does not define is tolerated rather than + rejected (forward-compatibility). Where an extensible type's data can be sent again through a command — + the received type or its command-parameter counterpart is extensible — any unknown property it arrived + with is kept on the object, so a caller can reproduce it on the wire: a vendor attribute received on a + `network.Cookie` can be set again through `storage.setCookie` unchanged. + + Each warning identifies the type and the field or property at fault; its level, format, and channel are + the implementation's. + + A closed vocabulary with no catch-all — a union such as `script.RemoteValue`, or an enum token outside + its defined set — errors on the unknown member rather than coercing it onto a defined one; an error + response's code is the exception, surfacing under the rule above rather than failing here. + +3. **All objects must be typed, with spec-mirrored names.** + - **Typed, not raw maps.** Parameters and results are typed value objects: an enum is the language's + closed-vocabulary type, and each variant of a union is a distinct type, branched on by type rather than + by inspecting a tag value. + - **Full numeric precision.** An int64- or bigint-range value uses the language's wide-integer or + arbitrary-precision type, never a narrowing double. + - **Spec-mirrored names.** Method and property names mirror the spec command and its wire keys, in the + language's idiom. + - **Faithful to what was received.** The layer represents received values as they arrived — no + normalization, coercion, or lossy re-encoding — so what a caller reads back is what the wire carried. + + Further language sugar belongs in the higher public layer, not here. ## Considered options -The Compliance items (1, 2, 5) 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 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 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 6 — lenient inbound.** Best-effort an off-spec response rather than raising. Rejected: silently - misrepresenting protocol state is worse than a clear error; strictness loosens on evidence (item 7) - when a real payload demands it. -- **Item 6 — do not enforce required-ness inbound at all**, treating every field as possibly absent. - Rejected: it erases the signal, and a divergence never recorded is never reported upstream or retired. - webdriverbidi-net retired six such relaxations in nineteen months precisely because strictness - surfaced them; a binding enforcing nothing accumulates the same divergences with no record of any. -- **Item 7 — 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 7 — mandate a runtime escape hatch** for uncatalogued absences, as a required behavior of every - binding. Not adopted: no implementation ships that exact shape (net's `TransportErrorBehavior` discards - the whole message rather than admitting the absent field), and a global strictness toggle is a - configuration concern this record otherwise scopes out. Left as a per-binding option instead — - permitted because it spares a user the release-cadence wait, not required because the catalogued - relaxation path already covers the divergences that recur. -- **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 9 — free naming** rather than mirroring the spec. Rejected: mirroring the spec command and - params aids cross-referencing and keeps bindings comparable. -- **Item 10 — 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 11 — mutable received objects** rather than read-only. Rejected: a received object is - informational, so read-only prevents mistaking it for a control surface. +- **Mandate generation rather than specify behavior.** Generation does not by itself guarantee any + behavior and adds 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. +- **Rely on the type system alone, with no runtime conformance.** Let outbound checks fall out of + compilation and inbound objects out of casting, with no runtime rejection. Rejected: a statically-typed + deserializer fills a correctly-typed object from malformed input and returns it as valid, so types alone + pass inbound corruption through — the contract has to fire at runtime. +- **Defer outbound validation to the server** (decision 1). Send the command and let the remote end return + the error the spec defines. Rejected: a local error is clearer and cheaper than a round-trip, and a static + binding gets it for free. +- **Enforce required-ness inbound too** (decision 2). Error on a missing required field as the outbound path + does. Rejected: the remote end is not ours to control, so a browser lagging a newly-required field would + cost the caller the whole message until Selenium regenerated and shipped a fix — a hard block over a value + no caller depended on. Tolerating absence costs the caller nothing and never blocks them. +- **Keep inbound strict, relaxing reactively via a manifest** (decision 2). Type inbound required-ness as + present-or-error, and annotate the specific lagging fields in a checked-in manifest so the generator + relaxes only those. Rejected: inbound strictness has no user-facing value, and even scoped to one field it + still blocks the caller until the project notices the lag, annotates it, and ships a release — a reactive + burden Selenium cannot promise. Tolerating absence and warning preserves the same signal with nothing to + maintain. +- **Tolerate malformed values, not only absence** (decision 2). Best-effort a wrong-typed or unmappable + value rather than erroring. Rejected: unlike a missing field, a present-but-invalid value cannot yield a + valid typed object — tolerating it means a placeholder or a broken object, the failure this layer exists + to prevent. +- **Tolerate an unknown `RemoteValue` variant instead of erroring** (decision 2). Surface an unknown-value + carrier that keeps the raw discriminator rather than erroring. Rejected: the trigger is rare (the + value-type set is near-complete) and the carrier is a permanent cost — an unknown branch every exhaustive + match must handle. Its one merit — a carrier is more recoverable than an error, since a consumer can + re-derive "throw on unknown" on top of it, not the reverse — does not outweigh that; a binding that wants + it can still layer one on top. +- **Enforce required-ness in the object's constructor** (decisions 1 & 2). Let the object reject a missing + field itself rather than at the serialization boundary. Rejected: constructor enforcement is symmetric — + it rejects an incomplete inbound payload as readily as an outbound one, making tolerated absence + impossible. A permissive object with enforcement at the boundaries is what lets the two directions differ. +- **Surface message-level extras in this layer** (decision 2) rather than leaving them to the transport. + Rejected: the envelope is the transport's; this layer governs per-type extensibility only. +- **Retain extras on every extensible type** (decision 2), not only those that are sent back. This is the right + choice for a public surface, which cannot narrow later without dropping a field users rely on. Rejected + here only because the layer is internal: it can scope tight now and widen later at no cost. *This is the + one decision the internal premise turns on.* +- **Raw dicts rather than typed objects** (decision 3); the spec permits it. Rejected: raw maps structurally + cannot do union dispatch, field checks, or a faithful record of what was received. +- **Free naming rather than mirroring the spec** (decision 3). Rejected: divergent surface names let the + bindings drift out of step with the spec and one another, defeating the cross-binding coherence that is the + point of one contract across many languages. ## 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 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 depend on it. +- Conformance is checkable independently of how the layer is built — any language, generated or + hand-written — so long as the runtime behavior matches. +- Required-ness is asymmetric: a required field must be present to send (decision 1) but is tolerated when + absent on receipt (decision 2) — the layer validates what it controls and accepts what it does not. +- Tolerating absence constrains the type, not just the deserializer: a static binding cannot type an inbound + field non-null yet leave it omitted when missing. A nullable slot suffices for most fields (real data is + never null there, so null marks omitted); a required *nullable* field (network `context`/`navigation`, + response sizes, log `text`, ~30 in all) instead needs omitted kept distinct from null. The trigger is + schema-detectable (`required ∧ nullable`). From aee5bd44f37c535363d4bde5b1eefabe7550e766 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 29 Jul 2026 13:30:20 -0500 Subject: [PATCH 10/19] [docs] BiDi contract: four-decision restructure + review fixes (type validity floor, numeric boundary, event params, union resolution) --- ...7786-bidi-low-level-behavioral-contract.md | 105 +++++++++--------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 292ab8c95d680..512a80e76641c 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -19,52 +19,51 @@ feed) are out of scope. ## Decision -The spec settles the layer's baseline: spec strings reach the wire verbatim, an *omitted* field stays +The spec fixes the layer's baseline — spec strings reach the wire verbatim, an *omitted* field stays distinct from an explicit *null*, and a union resolves by the spec's declared rule rather than a structural -guess. What the spec leaves open, this record decides. - -Where the type system prevents a violation it satisfies the contract: a static binding whose types make -an invalid outbound payload unconstructable meets decision 1 at compile time. Inbound is different — a -statically-typed deserializer fills a correctly-typed object from malformed input (typically a null in a -non-nullable field) and returns it as valid, so the inbound decisions must be exhibited at runtime, not -merely declared in types or schema. - -1. **Outbound is validated before sending.** A caller mistake surfaces as a local error rather than a - remote round-trip. For all commands defined in the spec: - - **Error if an enum value is undefined**, or a nullable constant is set to anything but its literal - or null; - - **Error unless all required fields are set**; - - **Error if an unknown property is set**, unless the spec marks its type as extensible. - - A static binding gets these from its type system; a dynamic one must check them explicitly. - -2. **Inbound is validated against the resolved type.** Process error responses first: a remote error - surfaces as an error even if its payload fails validation — the checks below must not turn it into a - local serialization failure. The error's contents and shape are otherwise out of scope. - - Otherwise, once the spec's union rule resolves the payload's type (if applicable), each mismatch is - handled by its kind: - 1. **Error if corrupted** — the value cannot fit the resolved type: a null in a non-nullable field, a - wrong primitive type, a cardinality mismatch, a non-object where an object is expected. - 2. **Warn if a required field is missing** — the field must be left *omitted* (not an explicit *null*, - and not a generic placeholder), so an implementation based on a previously generated spec does not fail - against a browser implementing a more recent one. An absent field reads the same whether it reflects - that lag or a genuine defect — the layer cannot tell them apart — so it warns rather than errors, - leaving a strict mode to escalate to an error for callers who want it. - 3. **Warn if a property is undeclared** — a property the type does not define is tolerated rather than - rejected (forward-compatibility). Where an extensible type's data can be sent again through a command — - the received type or its command-parameter counterpart is extensible — any unknown property it arrived - with is kept on the object, so a caller can reproduce it on the wire: a vendor attribute received on a - `network.Cookie` can be set again through `storage.setCookie` unchanged. +guess; what it leaves open, this record decides, as behavior observable at the wire boundary, whatever a +binding's types guarantee on their own. + +1. **A value must be a valid instance of its declared type — sent or received.** The layer neither sends + nor accepts one that does not fit: + - **structurally** — a null in a non-nullable field, a wrong primitive type, a cardinality mismatch (a + list where a single value is declared, or the reverse), or a non-object where a typed object is + declared. A primitive matches by JSON kind, not language representation: `number` admits any JSON + number, while `integer` rejects a fractional or float-encoded one (a whole-valued `5.0` included); + neither direction coerces across the boundary silently. + - **by vocabulary** — an enum value outside its defined set, a nullable constant set to anything but its + literal or null, an unresolvable member of a closed union (such as `script.RemoteValue`), or a payload + that resolves to no variant at all (a missing discriminator, or structural fields insufficient to + select one). + + A static binding gets this from its type system — an invalid outbound payload is unconstructable — but a + correctly-typed object can still be filled from malformed input, so inbound the check must run at runtime; + a dynamic binding checks both directions explicitly. + +2. **Outbound, a caller's own missing or extra field is rejected.** For any command defined in the spec, a + missing required field, or an unknown property on a non-extensible type, is a local error rather than a + remote round-trip — strict, because the payload is the caller's to get right. + +3. **Inbound, those same two are tolerated.** Process error responses first: a remote error surfaces as an + error even if its payload fails validation — an unrecognized error code included — and the checks here + must not turn it into a local serialization failure; the error's contents are otherwise out of scope. + Otherwise these rules govern every typed payload the layer parses — a command's result and a + server-initiated event's parameters alike, with response correlation and event routing out of scope: + - **A missing required field warns, and is left *omitted*** — not an explicit *null*, and not a generic + placeholder — so a binding generated from a newer schema does not fail against a browser still omitting + a newly-required field. An absent field reads the same whether it reflects that lag or a genuine + defect — the layer cannot tell them apart — so it warns rather than errors, with an opt-in strict mode + to escalate to an error. + - **An undeclared property warns, and is tolerated** (forward-compatibility). Where the type is + round-trippable — the received type or its command-parameter counterpart is extensible — the unknown + property is kept on the object, so a caller can reproduce it on the wire: a vendor attribute received on + a `network.Cookie` can be set again through `storage.setCookie` unchanged. Other undeclared properties + are tolerated but not retained. Each warning identifies the type and the field or property at fault; its level, format, and channel are the implementation's. - A closed vocabulary with no catch-all — a union such as `script.RemoteValue`, or an enum token outside - its defined set — errors on the unknown member rather than coercing it onto a defined one; an error - response's code is the exception, surfacing under the rule above rather than failing here. - -3. **All objects must be typed, with spec-mirrored names.** +4. **All objects must be typed, with spec-mirrored names.** - **Typed, not raw maps.** Parameters and results are typed value objects: an enum is the language's closed-vocabulary type, and each variant of a union is a distinct type, branched on by type rather than by inspecting a tag value. @@ -86,42 +85,42 @@ merely declared in types or schema. compilation and inbound objects out of casting, with no runtime rejection. Rejected: a statically-typed deserializer fills a correctly-typed object from malformed input and returns it as valid, so types alone pass inbound corruption through — the contract has to fire at runtime. -- **Defer outbound validation to the server** (decision 1). Send the command and let the remote end return +- **Defer outbound validation to the server** (decision 2). Send the command and let the remote end return the error the spec defines. Rejected: a local error is clearer and cheaper than a round-trip, and a static binding gets it for free. -- **Enforce required-ness inbound too** (decision 2). Error on a missing required field as the outbound path +- **Enforce required-ness inbound too** (decision 3). Error on a missing required field as the outbound path does. Rejected: the remote end is not ours to control, so a browser lagging a newly-required field would cost the caller the whole message until Selenium regenerated and shipped a fix — a hard block over a value no caller depended on. Tolerating absence costs the caller nothing and never blocks them. -- **Keep inbound strict, relaxing reactively via a manifest** (decision 2). Type inbound required-ness as +- **Keep inbound strict, relaxing reactively via a manifest** (decision 3). Type inbound required-ness as present-or-error, and annotate the specific lagging fields in a checked-in manifest so the generator relaxes only those. Rejected: inbound strictness has no user-facing value, and even scoped to one field it still blocks the caller until the project notices the lag, annotates it, and ships a release — a reactive burden Selenium cannot promise. Tolerating absence and warning preserves the same signal with nothing to maintain. -- **Tolerate malformed values, not only absence** (decision 2). Best-effort a wrong-typed or unmappable +- **Tolerate malformed values, not only absence** (decision 1). Best-effort a wrong-typed or unmappable value rather than erroring. Rejected: unlike a missing field, a present-but-invalid value cannot yield a valid typed object — tolerating it means a placeholder or a broken object, the failure this layer exists to prevent. -- **Tolerate an unknown `RemoteValue` variant instead of erroring** (decision 2). Surface an unknown-value +- **Tolerate an unknown `RemoteValue` variant instead of erroring** (decision 1). Surface an unknown-value carrier that keeps the raw discriminator rather than erroring. Rejected: the trigger is rare (the value-type set is near-complete) and the carrier is a permanent cost — an unknown branch every exhaustive match must handle. Its one merit — a carrier is more recoverable than an error, since a consumer can re-derive "throw on unknown" on top of it, not the reverse — does not outweigh that; a binding that wants it can still layer one on top. -- **Enforce required-ness in the object's constructor** (decisions 1 & 2). Let the object reject a missing +- **Enforce required-ness in the object's constructor** (decisions 2 & 3). Let the object reject a missing field itself rather than at the serialization boundary. Rejected: constructor enforcement is symmetric — it rejects an incomplete inbound payload as readily as an outbound one, making tolerated absence impossible. A permissive object with enforcement at the boundaries is what lets the two directions differ. -- **Surface message-level extras in this layer** (decision 2) rather than leaving them to the transport. +- **Surface message-level extras in this layer** (decision 3) rather than leaving them to the transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. -- **Retain extras on every extensible type** (decision 2), not only those that are sent back. This is the right +- **Retain extras on every extensible type** (decision 3), not only those that are sent back. This is the right choice for a public surface, which cannot narrow later without dropping a field users rely on. Rejected here only because the layer is internal: it can scope tight now and widen later at no cost. *This is the one decision the internal premise turns on.* -- **Raw dicts rather than typed objects** (decision 3); the spec permits it. Rejected: raw maps structurally +- **Raw dicts rather than typed objects** (decision 4); the spec permits it. Rejected: raw maps structurally cannot do union dispatch, field checks, or a faithful record of what was received. -- **Free naming rather than mirroring the spec** (decision 3). Rejected: divergent surface names let the +- **Free naming rather than mirroring the spec** (decision 4). Rejected: divergent surface names let the bindings drift out of step with the spec and one another, defeating the cross-binding coherence that is the point of one contract across many languages. @@ -129,8 +128,8 @@ merely declared in types or schema. - Conformance is checkable independently of how the layer is built — any language, generated or hand-written — so long as the runtime behavior matches. -- Required-ness is asymmetric: a required field must be present to send (decision 1) but is tolerated when - absent on receipt (decision 2) — the layer validates what it controls and accepts what it does not. +- Required-ness is asymmetric: a required field must be present to send (decision 2) but is tolerated when + absent on receipt (decision 3) — the layer validates what it controls and accepts what it does not. - Tolerating absence constrains the type, not just the deserializer: a static binding cannot type an inbound field non-null yet leave it omitted when missing. A nullable slot suffices for most fields (real data is never null there, so null marks omitted); a required *nullable* field (network `context`/`navigation`, From bd68e1c5fe6f06f833e3e81d57972703f44011ed Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 30 Jul 2026 11:13:36 -0500 Subject: [PATCH 11/19] [docs] BiDi contract: restructure decisions into representation, outbound, and inbound --- ...7786-bidi-low-level-behavioral-contract.md | 209 +++++++++--------- 1 file changed, 110 insertions(+), 99 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 512a80e76641c..528d8f3f9f630 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -10,128 +10,139 @@ that turns typed calls into wire messages and wire messages back into typed obje 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 -reference. One contract can state them for every binding at once — the same behavior in any language. +These behaviors surface only at the wire boundary, and nothing shared enforces them, so bindings drift +apart without a common reference. The transport layer (the connection that carries messages and matches responses to their commands) and the orchestration layer (session lifecycle, event subscription and routing, and the high-level API these -feed) are out of scope. +feed) are out of scope, as is any ergonomic sugar over these types that a higher public layer adds. ## Decision -The spec fixes the layer's baseline — spec strings reach the wire verbatim, an *omitted* field stays -distinct from an explicit *null*, and a union resolves by the spec's declared rule rather than a structural -guess; what it leaves open, this record decides, as behavior observable at the wire boundary, whatever a -binding's types guarantee on their own. - -1. **A value must be a valid instance of its declared type — sent or received.** The layer neither sends - nor accepts one that does not fit: - - **structurally** — a null in a non-nullable field, a wrong primitive type, a cardinality mismatch (a - list where a single value is declared, or the reverse), or a non-object where a typed object is - declared. A primitive matches by JSON kind, not language representation: `number` admits any JSON - number, while `integer` rejects a fractional or float-encoded one (a whole-valued `5.0` included); - neither direction coerces across the boundary silently. - - **by vocabulary** — an enum value outside its defined set, a nullable constant set to anything but its - literal or null, an unresolvable member of a closed union (such as `script.RemoteValue`), or a payload - that resolves to no variant at all (a missing discriminator, or structural fields insufficient to - select one). - - A static binding gets this from its type system — an invalid outbound payload is unconstructable — but a - correctly-typed object can still be filled from malformed input, so inbound the check must run at runtime; - a dynamic binding checks both directions explicitly. - -2. **Outbound, a caller's own missing or extra field is rejected.** For any command defined in the spec, a - missing required field, or an unknown property on a non-extensible type, is a local error rather than a - remote round-trip — strict, because the payload is the caller's to get right. - -3. **Inbound, those same two are tolerated.** Process error responses first: a remote error surfaces as an - error even if its payload fails validation — an unrecognized error code included — and the checks here - must not turn it into a local serialization failure; the error's contents are otherwise out of scope. - Otherwise these rules govern every typed payload the layer parses — a command's result and a - server-initiated event's parameters alike, with response correlation and event routing out of scope: - - **A missing required field warns, and is left *omitted*** — not an explicit *null*, and not a generic - placeholder — so a binding generated from a newer schema does not fail against a browser still omitting - a newly-required field. An absent field reads the same whether it reflects that lag or a genuine - defect — the layer cannot tell them apart — so it warns rather than errors, with an opt-in strict mode - to escalate to an error. - - **An undeclared property warns, and is tolerated** (forward-compatibility). Where the type is - round-trippable — the received type or its command-parameter counterpart is extensible — the unknown - property is kept on the object, so a caller can reproduce it on the wire: a vendor attribute received on - a `network.Cookie` can be set again through `storage.setCookie` unchanged. Other undeclared properties - are tolerated but not retained. - - Each warning identifies the type and the field or property at fault; its level, format, and channel are - the implementation's. - -4. **All objects must be typed, with spec-mirrored names.** - - **Typed, not raw maps.** Parameters and results are typed value objects: an enum is the language's - closed-vocabulary type, and each variant of a union is a distinct type, branched on by type rather than - by inspecting a tag value. - - **Full numeric precision.** An int64- or bigint-range value uses the language's wide-integer or - arbitrary-precision type, never a narrowing double. - - **Spec-mirrored names.** Method and property names mirror the spec command and its wire keys, in the - language's idiom. - - **Faithful to what was received.** The layer represents received values as they arrived — no - normalization, coercion, or lossy re-encoding — so what a caller reads back is what the wire carried. - - Further language sugar belongs in the higher public layer, not here. +The spec fixes the layer's baseline: spec strings reach the wire verbatim, an *omitted* field stays +distinct from an explicit `null`, and a union resolves by the rule the spec declares. What it leaves open, +this record decides in three parts. The first is the typed **representation** the layer exposes; the other +two are behavior observable at the wire boundary: what it sends **outbound**, and what it receives +**inbound**. + +### Representation + +The layer mirrors the spec's modules in the language: each command becomes a typed call, each event a +typed payload, and each data type a typed object. A caller creates them to send and reads them on receipt. + +1. **Represent payloads as typed objects, not raw maps.** Parameters, results, and event payloads must be + typed objects: an enum is the language's closed-vocabulary type, and each variant of a union is a + distinct type. Where the spec marks a type extensible, the object also carries an untyped map for the + fields the spec does not declare; a non-extensible type does not. +2. **Mirror the spec's command and field names.** Method and field names must follow the spec command + and its wire keys, in the language's idiom. +3. **Preserve a numeric value's full range and precision.** The native type chosen for a numeric value must + cover the full range the spec declares for it, with no narrowing or lossy conversion. BiDi integers stay + within the JS safe-integer range (`js-int`/`js-uint`, `±(2^53 − 1)`): too wide for a 32-bit integer, + though a 64-bit integer or a double holds them exactly. +4. **Hold a value strictly to its declared type, with no coercion.** A value must be a valid instance of its + declared type; the layer must treat it as invalid when it fails: + - **structurally**: a `null` in a non-nullable field, an incorrect primitive type, a cardinality + mismatch (a list where a single value is declared, or vice versa), or a non-object where a typed object + is expected. A primitive matches by JSON kind, not language representation: `number` admits any JSON + number, while `integer` rejects fractional or float-encoded values (including a whole-valued `5.0`). + - **by vocabulary**: an enum value outside its defined set, a nullable constant set to anything other + than its literal or `null`, an unresolvable variant of a closed union (such as `script.RemoteValue`), + or a payload that fails to select any variant (e.g., a missing discriminator or insufficient structural + fields). + +### Outbound + +An outbound payload is Selenium's responsibility. Unless it strictly conforms to the spec, the layer must +error instead of sending it (caught at compile time where a binding's types allow, otherwise at runtime). + +5. **Reject an invalid or missing value.** An invalid value does not match its declared type (decision 4); + a missing one is a required field without a value. +6. **Send an extra field only on an extensible type.** An extensible type carries it in the map (decision 1) + and serializes it; a non-extensible type has no such map, so an extra field cannot be represented. + +### Inbound + +An inbound payload is the remote end's responsibility; the layer must tolerate what it can accurately +represent and reject only what it cannot, providing flexibility for a remote end on a spec version newer or +older than the one the binding validates against. + +7. **Reject an invalid value.** A value that is present but not a valid instance (decision 4) + cannot yield a valid object, so the layer must raise an error. +8. **Tolerate a missing required field.** The layer must represent the missing field as *omitted* rather + than an explicit `null` or a substituted placeholder. By default it must log a warning with the + details; an optional strict mode may raise an error instead. +9. **Tolerate an undeclared field.** If the type is declared extensible, the layer must preserve the + field in the type's map (decision 1). If it is not, the layer must log a warning that an undeclared field + was received, and drop it. +10. **Preserve received values faithfully.** Every value the object holds, a declared field or a retained + extra alike, must be exactly what the wire carried, with no normalization or lossy re-encoding. + +An error response must error for the provided reason, an unrecognized error code included, even if that +reason would otherwise fail one of the validations above. ## Considered options -- **Mandate generation rather than specify behavior.** Generation does not by itself guarantee any - behavior and adds 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. +- **Mandate generation rather than specify behavior.** Require each binding to generate its + (de)serialization from the schema, rather than specify the behavior it must exhibit however built. + Rejected: generation does not by itself guarantee any behavior and adds cost where it isn't ergonomic; the + behaviors are identical however the code is produced, so the contract is the behavior and generation an + optional strategy. - **Rely on the type system alone, with no runtime conformance.** Let outbound checks fall out of compilation and inbound objects out of casting, with no runtime rejection. Rejected: a statically-typed deserializer fills a correctly-typed object from malformed input and returns it as valid, so types alone - pass inbound corruption through — the contract has to fire at runtime. -- **Defer outbound validation to the server** (decision 2). Send the command and let the remote end return + pass inbound corruption through, and the contract has to fire at runtime. +- **Raw dicts rather than typed objects** (decision 1); the spec permits it. Rejected: raw maps structurally + cannot do union dispatch, validated field access, or closed-vocabulary enums. +- **Free naming rather than mirroring the spec** (decision 2). Rejected: divergent surface names let the + bindings drift out of step with the spec and one another, defeating the cross-binding coherence that is the + point of one contract across many languages. +- **Defer outbound validation to the server** (decision 5). Send the command and let the remote end return the error the spec defines. Rejected: a local error is clearer and cheaper than a round-trip, and a static binding gets it for free. -- **Enforce required-ness inbound too** (decision 3). Error on a missing required field as the outbound path +- **Enforce required-ness in the object's constructor** (decisions 5 and 8). Let the object reject a missing + field itself rather than at the serialization boundary. Rejected: constructor enforcement is symmetric: + it rejects an incomplete inbound payload as readily as an outbound one, making tolerated absence + impossible. A permissive object with enforcement at the boundaries is what lets the two directions differ. +- **Tolerate malformed values, not only absence** (decision 7). Best-effort a wrong-typed or unmappable + value rather than erroring. Rejected: unlike a missing field, a present-but-invalid value cannot yield a + valid typed object, and tolerating it means a placeholder or a broken object, the failure this layer + exists to prevent. +- **Tolerate an unknown `RemoteValue` variant instead of erroring** (decision 7). Surface an unknown-value + carrier that keeps the raw discriminator rather than erroring. Rejected: the trigger is rare (the + value-type set is near-complete), and the carrier is a permanent cost, an unknown branch every exhaustive + match must handle. Its one merit is recoverability: a consumer can re-derive "throw on unknown" on top of + a carrier, but not the reverse. That does not outweigh the cost, and a binding that wants a carrier can + still layer one on top. +- **Enforce required-ness inbound too** (decision 8). Error on a missing required field as the outbound path does. Rejected: the remote end is not ours to control, so a browser lagging a newly-required field would - cost the caller the whole message until Selenium regenerated and shipped a fix — a hard block over a value + cost the caller the whole message until Selenium regenerated and shipped a fix, a hard block over a value no caller depended on. Tolerating absence costs the caller nothing and never blocks them. -- **Keep inbound strict, relaxing reactively via a manifest** (decision 3). Type inbound required-ness as +- **Keep inbound strict, relaxing reactively via a manifest** (decision 8). Type inbound required-ness as present-or-error, and annotate the specific lagging fields in a checked-in manifest so the generator relaxes only those. Rejected: inbound strictness has no user-facing value, and even scoped to one field it - still blocks the caller until the project notices the lag, annotates it, and ships a release — a reactive + still blocks the caller until the project notices the lag, annotates it, and ships a release, a reactive burden Selenium cannot promise. Tolerating absence and warning preserves the same signal with nothing to maintain. -- **Tolerate malformed values, not only absence** (decision 1). Best-effort a wrong-typed or unmappable - value rather than erroring. Rejected: unlike a missing field, a present-but-invalid value cannot yield a - valid typed object — tolerating it means a placeholder or a broken object, the failure this layer exists - to prevent. -- **Tolerate an unknown `RemoteValue` variant instead of erroring** (decision 1). Surface an unknown-value - carrier that keeps the raw discriminator rather than erroring. Rejected: the trigger is rare (the - value-type set is near-complete) and the carrier is a permanent cost — an unknown branch every exhaustive - match must handle. Its one merit — a carrier is more recoverable than an error, since a consumer can - re-derive "throw on unknown" on top of it, not the reverse — does not outweigh that; a binding that wants - it can still layer one on top. -- **Enforce required-ness in the object's constructor** (decisions 2 & 3). Let the object reject a missing - field itself rather than at the serialization boundary. Rejected: constructor enforcement is symmetric — - it rejects an incomplete inbound payload as readily as an outbound one, making tolerated absence - impossible. A permissive object with enforcement at the boundaries is what lets the two directions differ. -- **Surface message-level extras in this layer** (decision 3) rather than leaving them to the transport. +- **Surface message-level extras in this layer** (decision 9) rather than leaving them to the transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. -- **Retain extras on every extensible type** (decision 3), not only those that are sent back. This is the right - choice for a public surface, which cannot narrow later without dropping a field users rely on. Rejected - here only because the layer is internal: it can scope tight now and widen later at no cost. *This is the - one decision the internal premise turns on.* -- **Raw dicts rather than typed objects** (decision 4); the spec permits it. Rejected: raw maps structurally - cannot do union dispatch, field checks, or a faithful record of what was received. -- **Free naming rather than mirroring the spec** (decision 4). Rejected: divergent surface names let the - bindings drift out of step with the spec and one another, defeating the cross-binding coherence that is the - point of one contract across many languages. +- **Retain extras only where they can be sent back** (decision 9), narrower than every extensible type. + Rejected: "can be sent back" has no schema-defined mapping (a `network.Cookie` round-trips through the + differently-typed `storage.PartialCookie`), so the scope would differ across bindings. The spec's + `Extensible` marker sanctions the extra fields themselves; retaining them on every extensible type is + Selenium's own choice, at the cost of only a few kept fields on types nothing sends back. ## Consequences -- Conformance is checkable independently of how the layer is built — any language, generated or - hand-written — so long as the runtime behavior matches. -- Required-ness is asymmetric: a required field must be present to send (decision 2) but is tolerated when - absent on receipt (decision 3) — the layer validates what it controls and accepts what it does not. +- Conformance is checkable independently of how the layer is built, in any language and whether generated + or hand-written, so long as the runtime behavior matches. +- Outbound validity differs in cost by binding: a static binding gets it from construction, while a + dynamic binding must enforce it with an explicit runtime check. The contract requires the behavior from + both; where a dynamic binding does not yet check, that is a gap to close, not an exemption. +- Required-ness is asymmetric: a required field must be present to send (decision 5) but is tolerated when + absent on receipt (decision 8); the layer validates what it controls and accepts what it does not. - Tolerating absence constrains the type, not just the deserializer: a static binding cannot type an inbound - field non-null yet leave it omitted when missing. A nullable slot suffices for most fields (real data is - never null there, so null marks omitted); a required *nullable* field (network `context`/`navigation`, - response sizes, log `text`, ~30 in all) instead needs omitted kept distinct from null. The trigger is + field non-null yet leave it *omitted* when missing. A nullable slot suffices for most fields (real data is + never `null` there, so `null` marks *omitted*); a required *nullable* field (network `context`/`navigation`, + response sizes, log `text`, ~30 in all) instead needs *omitted* kept distinct from `null`. The trigger is schema-detectable (`required ∧ nullable`). From 30817c619ddcfa6358764fb2c56f6fc917c4f414 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 17:27:56 -0500 Subject: [PATCH 12/19] [docs] BiDi contract: allow a shared union carrier, define unresolvable as an undeclared discriminator --- .../17786-bidi-low-level-behavioral-contract.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 528d8f3f9f630..68ab0e6d8313f 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -32,8 +32,10 @@ typed payload, and each data type a typed object. A caller creates them to send 1. **Represent payloads as typed objects, not raw maps.** Parameters, results, and event payloads must be typed objects: an enum is the language's closed-vocabulary type, and each variant of a union is a - distinct type. Where the spec marks a type extensible, the object also carries an untyped map for the - fields the spec does not declare; a non-extensible type does not. + distinct type, though variants with identical fields may share a single type that records which variant a + value is. Where the spec marks a type extensible, the object also carries an untyped map for the fields + the spec does not declare; a non-extensible type does not. A key the type declares must never appear in + that map. 2. **Mirror the spec's command and field names.** Method and field names must follow the spec command and its wire keys, in the language's idiom. 3. **Preserve a numeric value's full range and precision.** The native type chosen for a numeric value must @@ -47,9 +49,9 @@ typed payload, and each data type a typed object. A caller creates them to send is expected. A primitive matches by JSON kind, not language representation: `number` admits any JSON number, while `integer` rejects fractional or float-encoded values (including a whole-valued `5.0`). - **by vocabulary**: an enum value outside its defined set, a nullable constant set to anything other - than its literal or `null`, an unresolvable variant of a closed union (such as `script.RemoteValue`), - or a payload that fails to select any variant (e.g., a missing discriminator or insufficient structural - fields). + than its literal or `null`, a closed-union discriminator the spec does not declare (such as an unknown + `script.RemoteValue` type), or a payload that fails to select any variant (e.g., a missing + discriminator or insufficient structural fields). ### Outbound @@ -113,7 +115,9 @@ reason would otherwise fail one of the validations above. value-type set is near-complete), and the carrier is a permanent cost, an unknown branch every exhaustive match must handle. Its one merit is recoverability: a consumer can re-derive "throw on unknown" on top of a carrier, but not the reverse. That does not outweigh the cost, and a binding that wants a carrier can - still layer one on top. + still layer one on top. This covers only a carrier for a discriminator the spec does not declare; a shared + carrier for a declared variant a binding has not modeled distinctly is the decision-1 representation + choice, not this behavior. - **Enforce required-ness inbound too** (decision 8). Error on a missing required field as the outbound path does. Rejected: the remote end is not ours to control, so a browser lagging a newly-required field would cost the caller the whole message until Selenium regenerated and shipped a fix, a hard block over a value From c7476ab48073bf5a983341829d3de39b5dbe9b8a Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 18:59:35 -0500 Subject: [PATCH 13/19] [docs] BiDi contract: clarify fidelity is of the value, not its byte-form --- .../17786-bidi-low-level-behavioral-contract.md | 10 +++++++--- 1 file changed, 7 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 68ab0e6d8313f..39c25b5025daf 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -41,7 +41,8 @@ typed payload, and each data type a typed object. A caller creates them to send 3. **Preserve a numeric value's full range and precision.** The native type chosen for a numeric value must cover the full range the spec declares for it, with no narrowing or lossy conversion. BiDi integers stay within the JS safe-integer range (`js-int`/`js-uint`, `±(2^53 − 1)`): too wide for a 32-bit integer, - though a 64-bit integer or a double holds them exactly. + though a 64-bit integer or a double holds them exactly. A field with a narrower declared range may use a + narrower native type that still covers it. 4. **Hold a value strictly to its declared type, with no coercion.** A value must be a valid instance of its declared type; the layer must treat it as invalid when it fails: - **structurally**: a `null` in a non-nullable field, an incorrect primitive type, a cardinality @@ -77,8 +78,11 @@ older than the one the binding validates against. 9. **Tolerate an undeclared field.** If the type is declared extensible, the layer must preserve the field in the type's map (decision 1). If it is not, the layer must log a warning that an undeclared field was received, and drop it. -10. **Preserve received values faithfully.** Every value the object holds, a declared field or a retained - extra alike, must be exactly what the wire carried, with no normalization or lossy re-encoding. +10. **Preserve received values faithfully.** Fidelity is of the value, not its byte-form: a binding may hold + any value it parses, a declared field or a retained extra alike, in an ergonomic native type (a 64-bit + integer for a `js-int`, a date object for a date), provided it loses nothing and can reproduce what the + wire carried. It must not truncate, round, re-case, or otherwise normalize a value beyond + recovery. An error response must error for the provided reason, an unrecognized error code included, even if that reason would otherwise fail one of the validations above. From 07a20bf8841eb604d33459dc6035314e8ff13c1c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sat, 1 Aug 2026 11:08:21 -0500 Subject: [PATCH 14/19] [docs] BiDi contract: mark decision 4 as a definition enforced by decisions 5 and 7 --- docs/decisions/17786-bidi-low-level-behavioral-contract.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 39c25b5025daf..f20955c8710f6 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -43,8 +43,9 @@ typed payload, and each data type a typed object. A caller creates them to send within the JS safe-integer range (`js-int`/`js-uint`, `±(2^53 − 1)`): too wide for a 32-bit integer, though a 64-bit integer or a double holds them exactly. A field with a narrower declared range may use a narrower native type that still covers it. -4. **Hold a value strictly to its declared type, with no coercion.** A value must be a valid instance of its - declared type; the layer must treat it as invalid when it fails: +4. **Hold a value strictly to its declared type, with no coercion.** This is a definition, not a behavior of + its own: the outbound (decision 5) and inbound (decision 7) decisions are what enforce it. A value must be + a valid instance of its declared type; the layer must treat it as invalid when it fails: - **structurally**: a `null` in a non-nullable field, an incorrect primitive type, a cardinality mismatch (a list where a single value is declared, or vice versa), or a non-object where a typed object is expected. A primitive matches by JSON kind, not language representation: `number` admits any JSON From 19f891f5b037da0fb046955cf139c6fef1962207 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sat, 1 Aug 2026 12:23:44 -0500 Subject: [PATCH 15/19] [docs] BiDi contract: value-based integer rule, simplify numeric range rule, clarify null-vs-omitted and error-response --- .../17786-bidi-low-level-behavioral-contract.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index f20955c8710f6..5d4c87465fe4b 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -39,17 +39,16 @@ typed payload, and each data type a typed object. A caller creates them to send 2. **Mirror the spec's command and field names.** Method and field names must follow the spec command and its wire keys, in the language's idiom. 3. **Preserve a numeric value's full range and precision.** The native type chosen for a numeric value must - cover the full range the spec declares for it, with no narrowing or lossy conversion. BiDi integers stay - within the JS safe-integer range (`js-int`/`js-uint`, `±(2^53 − 1)`): too wide for a 32-bit integer, - though a 64-bit integer or a double holds them exactly. A field with a narrower declared range may use a - narrower native type that still covers it. + cover the full range the spec declares for it, with no narrowing or lossy conversion. BiDi integers exceed + a 32-bit integer's range, so a binding must not hold them in one. A field with a narrower declared range + may use a narrower native type that still covers it. 4. **Hold a value strictly to its declared type, with no coercion.** This is a definition, not a behavior of its own: the outbound (decision 5) and inbound (decision 7) decisions are what enforce it. A value must be a valid instance of its declared type; the layer must treat it as invalid when it fails: - **structurally**: a `null` in a non-nullable field, an incorrect primitive type, a cardinality mismatch (a list where a single value is declared, or vice versa), or a non-object where a typed object is expected. A primitive matches by JSON kind, not language representation: `number` admits any JSON - number, while `integer` rejects fractional or float-encoded values (including a whole-valued `5.0`). + number, while `integer` rejects a fractional value (`5.7`) but accepts a whole one written `5` or `5.0`. - **by vocabulary**: an enum value outside its defined set, a nullable constant set to anything other than its literal or `null`, a closed-union discriminator the spec does not declare (such as an unknown `script.RemoteValue` type), or a payload that fails to select any variant (e.g., a missing @@ -85,8 +84,8 @@ older than the one the binding validates against. wire carried. It must not truncate, round, re-case, or otherwise normalize a value beyond recovery. -An error response must error for the provided reason, an unrecognized error code included, even if that -reason would otherwise fail one of the validations above. +An error response (a result the remote returns with an error code) must error for the provided reason, an +unrecognized error code included, even if that reason would otherwise fail one of the validations above. ## Considered options @@ -152,6 +151,7 @@ reason would otherwise fail one of the validations above. absent on receipt (decision 8); the layer validates what it controls and accepts what it does not. - Tolerating absence constrains the type, not just the deserializer: a static binding cannot type an inbound field non-null yet leave it *omitted* when missing. A nullable slot suffices for most fields (real data is - never `null` there, so `null` marks *omitted*); a required *nullable* field (network `context`/`navigation`, + never `null` there, so an *absent* field maps to `null` = *omitted*; a wire-level explicit `null` stays + invalid per decision 7); a required *nullable* field (network `context`/`navigation`, response sizes, log `text`, ~30 in all) instead needs *omitted* kept distinct from `null`. The trigger is schema-detectable (`required ∧ nullable`). From 3510eb36cf9ae618975445bd895b848eadb13210 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sat, 8 Aug 2026 13:13:36 -0500 Subject: [PATCH 16/19] [docs] BiDi contract: define "the spec" as all published BiDi specs plus vendor-defined extensions --- docs/decisions/17786-bidi-low-level-behavioral-contract.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 5d4c87465fe4b..0ce561ec07cc0 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -19,8 +19,10 @@ feed) are out of scope, as is any ergonomic sugar over these types that a higher ## Decision -The spec fixes the layer's baseline: spec strings reach the wire verbatim, an *omitted* field stays -distinct from an explicit `null`, and a union resolves by the rule the spec declares. What it leaves open, +In this record, "the spec" is the declared schema the layer validates against, which includes all relevant +published WebDriver BiDi specifications and any vendor-defined extensions. The spec fixes the layer's +baseline: spec strings reach the wire verbatim, an *omitted* field stays distinct from an explicit `null`, +and a union resolves by the rule the spec declares. What it leaves open, this record decides in three parts. The first is the typed **representation** the layer exposes; the other two are behavior observable at the wire boundary: what it sends **outbound**, and what it receives **inbound**. From 80553c60dd221f27e741a1ba6a8ba25fa1e3f1e6 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 21 Aug 2026 11:48:22 -0500 Subject: [PATCH 17/19] [docs] BiDi contract: reject missing required inbound field, relaxable via project schema overrides --- ...7786-bidi-low-level-behavioral-contract.md | 53 ++++++++----------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 0ce561ec07cc0..847342f89d45f 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -19,8 +19,9 @@ feed) are out of scope, as is any ergonomic sugar over these types that a higher ## Decision -In this record, "the spec" is the declared schema the layer validates against, which includes all relevant -published WebDriver BiDi specifications and any vendor-defined extensions. The spec fixes the layer's +In this record, "the spec" is the declared schema the layer validates against, which must include all +relevant published WebDriver BiDi specifications and any vendor-defined extensions it supports, and may +include project-maintained overrides where additional flexibility is needed. The spec fixes the layer's baseline: spec strings reach the wire verbatim, an *omitted* field stays distinct from an explicit `null`, and a union resolves by the rule the spec declares. What it leaves open, this record decides in three parts. The first is the typed **representation** the layer exposes; the other @@ -68,15 +69,15 @@ error instead of sending it (caught at compile time where a binding's types allo ### Inbound -An inbound payload is the remote end's responsibility; the layer must tolerate what it can accurately -represent and reject only what it cannot, providing flexibility for a remote end on a spec version newer or -older than the one the binding validates against. +An inbound payload is the remote end's responsibility; the layer must tolerate what it can faithfully +represent and reject what it cannot. It tolerates a field the spec does not declare (decision 9), so a +remote end on a newer spec version does not break the binding; it rejects a malformed value (decision 7) or +a missing required field (decision 8), neither of which yields a valid typed object. 7. **Reject an invalid value.** A value that is present but not a valid instance (decision 4) cannot yield a valid object, so the layer must raise an error. -8. **Tolerate a missing required field.** The layer must represent the missing field as *omitted* rather - than an explicit `null` or a substituted placeholder. By default it must log a warning with the - details; an optional strict mode may raise an error instead. +8. **Reject a missing required field.** A required field absent from an inbound payload is an error: the + layer must raise rather than substitute a placeholder or represent the field as *omitted*. 9. **Tolerate an undeclared field.** If the type is declared extensible, the layer must preserve the field in the type's map (decision 1). If it is not, the layer must log a warning that an undeclared field was received, and drop it. @@ -108,10 +109,6 @@ unrecognized error code included, even if that reason would otherwise fail one o - **Defer outbound validation to the server** (decision 5). Send the command and let the remote end return the error the spec defines. Rejected: a local error is clearer and cheaper than a round-trip, and a static binding gets it for free. -- **Enforce required-ness in the object's constructor** (decisions 5 and 8). Let the object reject a missing - field itself rather than at the serialization boundary. Rejected: constructor enforcement is symmetric: - it rejects an incomplete inbound payload as readily as an outbound one, making tolerated absence - impossible. A permissive object with enforcement at the boundaries is what lets the two directions differ. - **Tolerate malformed values, not only absence** (decision 7). Best-effort a wrong-typed or unmappable value rather than erroring. Rejected: unlike a missing field, a present-but-invalid value cannot yield a valid typed object, and tolerating it means a placeholder or a broken object, the failure this layer @@ -124,16 +121,14 @@ unrecognized error code included, even if that reason would otherwise fail one o still layer one on top. This covers only a carrier for a discriminator the spec does not declare; a shared carrier for a declared variant a binding has not modeled distinctly is the decision-1 representation choice, not this behavior. -- **Enforce required-ness inbound too** (decision 8). Error on a missing required field as the outbound path - does. Rejected: the remote end is not ours to control, so a browser lagging a newly-required field would - cost the caller the whole message until Selenium regenerated and shipped a fix, a hard block over a value - no caller depended on. Tolerating absence costs the caller nothing and never blocks them. -- **Keep inbound strict, relaxing reactively via a manifest** (decision 8). Type inbound required-ness as - present-or-error, and annotate the specific lagging fields in a checked-in manifest so the generator - relaxes only those. Rejected: inbound strictness has no user-facing value, and even scoped to one field it - still blocks the caller until the project notices the lag, annotates it, and ships a release, a reactive - burden Selenium cannot promise. Tolerating absence and warning preserves the same signal with nothing to - maintain. +- **Tolerate a missing required field inbound** (decision 8). Represent an absent required field as + *omitted* and warn, rather than erroring, so a remote end lagging a newly-required field does not cost the + caller the message. Rejected: a static, generated binding cannot hold a required field as *omitted* + without typing it away from its declared shape (a required-nullable field then needs an extra + omitted-vs-null state), and no clean option exists for this in Java, so the tolerance is not implementable + consistently across bindings. Erroring keeps required-ness symmetric and matches every binding, including + the webdriverbidi-net reference; a lagging field is handled by a project schema override, not by runtime + tolerance. - **Surface message-level extras in this layer** (decision 9) rather than leaving them to the transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. - **Retain extras only where they can be sent back** (decision 9), narrower than every extensible type. @@ -149,11 +144,9 @@ unrecognized error code included, even if that reason would otherwise fail one o - Outbound validity differs in cost by binding: a static binding gets it from construction, while a dynamic binding must enforce it with an explicit runtime check. The contract requires the behavior from both; where a dynamic binding does not yet check, that is a gap to close, not an exemption. -- Required-ness is asymmetric: a required field must be present to send (decision 5) but is tolerated when - absent on receipt (decision 8); the layer validates what it controls and accepts what it does not. -- Tolerating absence constrains the type, not just the deserializer: a static binding cannot type an inbound - field non-null yet leave it *omitted* when missing. A nullable slot suffices for most fields (real data is - never `null` there, so an *absent* field maps to `null` = *omitted*; a wire-level explicit `null` stays - invalid per decision 7); a required *nullable* field (network `context`/`navigation`, - response sizes, log `text`, ~30 in all) instead needs *omitted* kept distinct from `null`. The trigger is - schema-detectable (`required ∧ nullable`). +- Required-ness is symmetric: a required field must be present and valid in both directions (decisions 5 + and 8). A static binding enforces it on the type itself, typing each field by its declared shape with no + omitted-vs-null representation, which is what makes the contract implementable in a generated static + binding like Java. The cost is backward compatibility: a remote end that omits a newly-required field + errors the whole message; relaxing that field with a project override degrades it to a missing field + rather than a failed session. From 66111c8883be4eb1ed993893b74a88b71c6ac8ef Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 25 Aug 2026 10:59:07 -0500 Subject: [PATCH 18/19] [docs] BiDi contract: reject a scalar matching none of a union's literal variants (decision 4) --- docs/decisions/17786-bidi-low-level-behavioral-contract.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 847342f89d45f..946639c07685a 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -54,8 +54,8 @@ typed payload, and each data type a typed object. A caller creates them to send number, while `integer` rejects a fractional value (`5.7`) but accepts a whole one written `5` or `5.0`. - **by vocabulary**: an enum value outside its defined set, a nullable constant set to anything other than its literal or `null`, a closed-union discriminator the spec does not declare (such as an unknown - `script.RemoteValue` type), or a payload that fails to select any variant (e.g., a missing - discriminator or insufficient structural fields). + `script.RemoteValue` type), or a payload that fails to select any variant (a missing discriminator, + insufficient structural fields, or a scalar matching none of a union's literal variants). ### Outbound From 6f34324425282b000fc3fe44d4331b3cec2b9869 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 25 Aug 2026 12:54:24 -0500 Subject: [PATCH 19/19] [docs] BiDi contract: state tolerate's cost as universal type-contract corruption, not a Java limitation; own the reactive override cost --- ...7786-bidi-low-level-behavioral-contract.md | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/decisions/17786-bidi-low-level-behavioral-contract.md b/docs/decisions/17786-bidi-low-level-behavioral-contract.md index 946639c07685a..bd1734fe1cbdc 100644 --- a/docs/decisions/17786-bidi-low-level-behavioral-contract.md +++ b/docs/decisions/17786-bidi-low-level-behavioral-contract.md @@ -121,14 +121,12 @@ unrecognized error code included, even if that reason would otherwise fail one o still layer one on top. This covers only a carrier for a discriminator the spec does not declare; a shared carrier for a declared variant a binding has not modeled distinctly is the decision-1 representation choice, not this behavior. -- **Tolerate a missing required field inbound** (decision 8). Represent an absent required field as - *omitted* and warn, rather than erroring, so a remote end lagging a newly-required field does not cost the - caller the message. Rejected: a static, generated binding cannot hold a required field as *omitted* - without typing it away from its declared shape (a required-nullable field then needs an extra - omitted-vs-null state), and no clean option exists for this in Java, so the tolerance is not implementable - consistently across bindings. Erroring keeps required-ness symmetric and matches every binding, including - the webdriverbidi-net reference; a lagging field is handled by a project schema override, not by runtime - tolerance. +- **Tolerate a missing required field inbound** (decision 8). Represent it as *omitted* and warn instead of + erroring, so a lagging remote end does not cost the caller the message. Rejected: an absent required field + is then null at runtime, corrupting the typed contract in every statically-typed binding — held null under + a non-null type, or widened to nullable so every consumer must check a field the spec guarantees. Only + untyped dynamic bindings tolerate freely. Erroring keeps the guarantee and one behavior across bindings; a + lagging field is relaxed in the schema (a project override) instead. - **Surface message-level extras in this layer** (decision 9) rather than leaving them to the transport. Rejected: the envelope is the transport's; this layer governs per-type extensibility only. - **Retain extras only where they can be sent back** (decision 9), narrower than every extensible type. @@ -144,9 +142,7 @@ unrecognized error code included, even if that reason would otherwise fail one o - Outbound validity differs in cost by binding: a static binding gets it from construction, while a dynamic binding must enforce it with an explicit runtime check. The contract requires the behavior from both; where a dynamic binding does not yet check, that is a gap to close, not an exemption. -- Required-ness is symmetric: a required field must be present and valid in both directions (decisions 5 - and 8). A static binding enforces it on the type itself, typing each field by its declared shape with no - omitted-vs-null representation, which is what makes the contract implementable in a generated static - binding like Java. The cost is backward compatibility: a remote end that omits a newly-required field - errors the whole message; relaxing that field with a project override degrades it to a missing field - rather than a failed session. +- Required-ness is symmetric (decisions 5 and 8): a required field is validated, not represented as absent, + so it is never null at runtime and no binding widens it to nullable. The cost is backward compatibility — + a remote end omitting a newly-required field errors the whole message until that field is relaxed in the + schema (a project override), a reactive fix (notice, override, ship) during which the message fails.