Skip to content
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a1edae7
[docs] add ADR for binding-neutral BiDi low-level behavioral contract
titusfortner Jul 14, 2026
51bf66e
[docs] number the low-level BiDi behavioral-contract ADR as 17786
titusfortner Jul 15, 2026
29bc002
[docs] align ADR intro with the Compliance/Decision split; fix grammar
titusfortner Jul 15, 2026
db0fb5d
[docs] drop envelope/body item from BiDi contract; renumber to 12 beh…
titusfortner Jul 15, 2026
dda19aa
[docs] extend BiDi contract item 3 to reject wrong const values outbound
titusfortner Jul 15, 2026
6eeeb06
[docs] attribute unknown-property drop to item 8, not item 7, in BiDi…
titusfortner Jul 16, 2026
4692662
[docs] drop shared-error-category requirement from BiDi contract; req…
titusfortner Jul 16, 2026
6ecc072
[docs] revise BiDi behavioral contract per review: corruption/absence…
titusfortner Jul 24, 2026
e9d2791
[docs] BiDi contract: three behavioral decisions β€” outbound validatio…
titusfortner Jul 29, 2026
aee5bd4
[docs] BiDi contract: four-decision restructure + review fixes (type …
titusfortner Jul 29, 2026
bd68e1c
[docs] BiDi contract: restructure decisions into representation, outb…
titusfortner Jul 30, 2026
30817c6
[docs] BiDi contract: allow a shared union carrier, define unresolvab…
titusfortner Jul 31, 2026
c7476ab
[docs] BiDi contract: clarify fidelity is of the value, not its byte-…
titusfortner Jul 31, 2026
07a20bf
[docs] BiDi contract: mark decision 4 as a definition enforced by dec…
titusfortner Aug 1, 2026
19f891f
[docs] BiDi contract: value-based integer rule, simplify numeric rang…
titusfortner Aug 1, 2026
3510eb3
[docs] BiDi contract: define "the spec" as all published BiDi specs p…
titusfortner Aug 8, 2026
80553c6
[docs] BiDi contract: reject missing required inbound field, relaxabl…
titusfortner Aug 21, 2026
66111c8
[docs] BiDi contract: reject a scalar matching none of a union's lite…
titusfortner Aug 25, 2026
6f34324
[docs] BiDi contract: state tolerate's cost as universal type-contrac…
titusfortner Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions docs/decisions/17786-bidi-low-level-behavioral-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 17786. Behavioral contract for the low-level WebDriver BiDi layer

- Status: Proposed
- Discussion: https://github.com/SeleniumHQ/selenium/pull/17786

## 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 by id) and below the orchestration and high-level
API that build on it. *This record is about the low-level layer.*

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, as is any ergonomic sugar over these types that a higher public layer adds.

## Decision

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,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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, 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
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 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 (a missing discriminator,
insufficient structural fields, or a scalar matching none of a union's literal variants).

### 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 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember a case where Chromium sent float instead of integer:

return DateTimeOffset.FromUnixTimeMilliseconds((long)reader.GetDouble()); // still might get a double

One more case to be relaxed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I am providing real cases just to challenge. Is it show-stopper for end users?

I still believe we want to send/receive messages to/from remote end as is. At very higher level we might validate messages.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could understand that Jim's implementation is north star for you, but current Selenium.NET is the best, I can explain any line in bidi namespace.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A whole number sent as a float (5.0) is already accepted under decision 4. If the browser sends a fractional value, we don't want to change it to a different number just to fit our typed representation, that's the opposite of receiving it "as is".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser is doing it.. it sends 123.321 while in spec it is integer.

cannot yield a valid object, so the layer must raise an error.
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.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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
Comment on lines +84 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Fidelity exceeds layer boundary 🐞 Bug ≑ Correctness

Decision 10 requires reproducing every received number without rounding, but each binding decodes
JSON before typed BiDi mapping and several decoders irreversibly narrow decimals to binary
floating-point values. Because the ADR excludes transport from scope, the contracted layer cannot
recover the original numeric value and therefore cannot satisfy this requirement for valid
high-precision JSON numbers.
Agent Prompt
## Issue description
Decision 10 requires lossless reproduction of inbound numeric values, while Decision 4 admits any JSON number. Existing WebSocket/JSON boundaries parse and sometimes round those values before the typed low-level layer receives them, even though transport behavior is declared out of scope.

## Issue Context
Clarify the ownership of lossless JSON decoding. Either include decoding in the behavioral boundary and require lossless numeric parsing before typed mapping, or constrain `number` to a precisely declared representable range/precision and define fidelity against that domain.

## Fix Focus Areas
- docs/decisions/17786-bidi-low-level-behavioral-contract.md[16-18]
- docs/decisions/17786-bidi-low-level-behavioral-contract.md[44-54]
- docs/decisions/17786-bidi-low-level-behavioral-contract.md[84-88]

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

recovery.

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

- **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, 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.
- **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. 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 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.
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, 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 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 β€”
Comment on lines +145 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Required fields may be null 🐞 Bug ≑ Correctness

The consequence says validating requiredness means a field is never null, but requiredness controls
presence while nullability independently permits an explicitly present null. This contradiction
can lead binding implementations to reject valid required-and-nullable fields or erase the ADR’s
required distinction between omission and explicit null.
Agent Prompt
## Issue description
The consequences conflate a required field being present with it being non-null. Required nullable fields may legitimately contain explicit `null`; only omission must be rejected.

## Issue Context
The ADR already distinguishes omission from explicit `null`, and the projected schema models `required` independently from `nullable`. Reword the consequence so it guarantees that required fields are never represented as omitted or converted to null merely because they were absent.

## Fix Focus Areas
- docs/decisions/17786-bidi-low-level-behavioral-contract.md[145-148]

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.