What travels on the wire, and what happens when it changes?
Most of the time nobody reads this: the generated codecs, DwAppServer and the client agree by
construction, because both sides use the declarations in dartway_core_shared
(packages/dartway_core_shared/lib/src/protocol/). It matters when something else speaks to the
server — a tool, a test, a proxy — and when the framework or the app changes under a build already
installed on someone's phone.
HTTP paths
DwHttpContract declares them once for both sides:
POST /dw/<WireName> a request or a command; the body is the DTO's JSON
GET /dw/live the WebSocket of live updates
GET /health liveness and database reachability
GET /dw/health the same check through an app host
Health uses GET or HEAD; POST /dw/health remains a DTO call if a project
registers the wire name health.
/dw/ and /health are reserved: a project route under them fails the server's startup
(../4-server/routes.md).
A call
- POST only. A request is a read, but its parameters are a DTO body, and one method keeps one code path. Any other method is a malformed call (400).
- The body is the DTO's own JSON — no envelope, no type tag: the path names the type.
Content-Typeisapplication/jsonin UTF-8; anything else is a malformed call. - The body limit is
DwServerSettings.maxBodyBytes, 1 MiB by default; a handler may set its ownmaxBodyBytes. A larger body is a malformed call. - A header sent twice is a malformed call: which of two tokens or two keys was meant cannot be guessed.
| Header | |
|---|---|
Authorization: Bearer <token> | absent for an anonymous call |
Dw-Protocol: <dwProtocolVersion> | required on every call |
Dw-App-Version: <semver>+<build> | the app build (DwAppVersion), e.g. 1.4.2+87 |
Dw-Idempotency-Key | required on a command, forbidden on a request; at most 128 characters |
Dw-Live-Connection | optional: the id of the caller's live socket (channels-and-realtime.md) |
Dw-Utc-Offset | optional: the device's UTC offset at the call, whole minutes east of UTC (180, -300, 345), within 18 hours; read by the handler as ctx.callerUtcOffset (handlers). The client reads it from the device on every call; an app that sends none gets null there, one that sends something else a 400 |
Retry-After | on a 429 answer, whole seconds |
Query parameters exist only for the kinds whose position is not part of their key:
| Kind | Parameters |
|---|---|
DwPageRequest | offset, pageSize |
DwWindowRequest | at most one of anchor, before, after (cursors), and pageSize |
| every other kind, every command | none — a table's page and pageSize are fields in the body |
A parameter the kind does not take, a repeated one or a count not written canonically (07, +7) is
a malformed call.
The answer
The body is always one of five shapes, and the HTTP status is a function of it (refusals-and-statuses.md):
{"status":"ok","result":<encoded by the call's class>,"updates":{"bookings:7":{"SessionBooking":[…]}}}
{"status":"refused","refusal":{"code":"messageTooLong","params":{"max":"4000"},"field":"text"}}
{"status":"unauthenticated"}
{"status":"failed","incidentId":"…"}
{"status":"incompatible","refusal":{"code":"dw.updateRequired"}}
resultis untagged — its type is the call's — and omitted whennull.updatesis what the command published, grouped by channel and then by wire name, and omitted when empty. It is the only place a type name stands next to objects; a deletion travels in theDwDeletedObjectgroup as{"type": "SessionBooking", "id": 9}."replayed": truemarks an answer repeated for an idempotency key.- A failed answer adds
"failure": "malformedCall"or"unknownCall"for the 400 and 404 cases. - A refusal's
paramsandfieldare omitted when empty.
The live socket
The client opens GET /dw/live?protocol=<version>&app=<semver+build> — query parameters, because a
browser cannot set headers on a WebSocket upgrade. Calls never travel here; the socket carries only
what HTTP cannot:
← {"k":"hello","connection":"<id>"} on open
→ {"k":"auth","token":"…"} / {"k":"auth"} first, and after sign-in and sign-out
← {"k":"authed","account":7} / {"k":"authed","rejected":true} / {"k":"authed"}
→ {"k":"sub","ch":"bookings:7"} / {"k":"unsub","ch":"…"}
← {"k":"subok","ch":"…"} / {"k":"subno","ch":"…", …}
← {"k":"upd","ch":"schedule","updates":{"ClubSession":[…]}}
← {"k":"closed","ch":"…"}
A subno carries a refusal (r), an incident id (x), or neither — not authenticated. The server
closes the socket with a DwCloseCode:
| Code | Why | The client |
|---|---|---|
| 1001 | server stopping | reconnects |
| 1011 | server failure; reason dw.failed:<incident> | reconnects |
| 4008 | slow consumer: its outbound queue passed outboundLimitBytes | reconnects with growing backoff |
| 1003, 1009, 4000 | a binary frame, a message over maxLiveMessageBytes, a message that does not parse | reports it and backs off |
| 4026 | incompatible; reason dw.updateRequired or dw.protocolUnsupported | stops for good |
The server pings every socket every 20 seconds (pingInterval) and drops a peer that has not
answered by the next ping.
Versions
Two versions decide whether a build may talk to a server, and both are checked before anything else about a call — an incompatible client learns that first, whatever else it got wrong.
dwProtocolVersion is the version of the framework's wire. The client sends it as Dw-Protocol
(and ?protocol= on the socket). A missing header is a malformed call; another version is answered
426 with dw.protocolUnsupported, and the socket closes with 4026. It means a framework version skew
between app and server, which the operator resolves.
Dw-Contract-Version is the version of the project's contract the app was compiled with — the
shared package's version:, written by dart run dartway_cli:dartway generate into the protocol both sides are built
with (DwWireProtocol.contractVersion), and sent as ?contract= on the socket. Semantic versioning
decides compatibility: a change that removes or renames anything an installed app sends or reads
raises the breaking line — the major version, or the minor one below 1.0 — and codec-compatible additions
does not. A client of an older line than the server's, or one that sends none, is answered 426
with dw.updateRequired. That is the lever for a project's own incompatible change: a renamed field
of a project DTO is not a protocol change, and old builds are turned away by the version raised in the
same pull request — nothing to remember in an environment at deploy time (#296). A client of a newer
line is not refused; the server is the one behind.
Dw-App-Version is the app's own build, from DwFlutterConfig.appVersion. It labels the session
key a sign-in makes, and decides nothing.
On the client an incompatibility is terminal. The first 426 (or close 4026) sets
dw.incompatibility; from then on every call is answered locally with that refusal, the socket stays
closed, and DwFlutterConfig.updateRequiredScreen replaces the app — see
../3-flutter/update-required.md.
Origins
Calls are same-origin by deployment, and the server sends no CORS headers — also in development
(D-039). In production the web app's host proxies only /dw/ to the server
(../5-tooling/deploy.md); locally dart run dartway_cli:dartway dev does the same
(../5-tooling/cli.md). A cross-origin browser call cannot get through anyway:
a JSON body needs a preflight, and the server never answers one. Native apps send no Origin and are
unaffected.
The live socket checks Origin itself, since browsers do not preflight an upgrade. It is accepted when
its host and port are the ones the upgrade was sent to (Host carries no scheme, so the scheme is
not compared), or when it is listed in DwServerSettings.allowedOrigins — full origins such as
https://app.example.com, compared by scheme, host and port. The server refuses to start with an
entry that is not a full origin: a bare host would silently allow every port and scheme of it. An
upgrade without Origin (a native app) is allowed; an Origin that does not parse, such as null
from a sandboxed page, is refused.
A wire change is a protocol change (D-052)
An app build on a phone keeps speaking the wire it was compiled with. If the framework changes how a
call, an ApiResponse, an update transport, a live message or framework-generated DTO JSON looks on the wire,
that build gets no compile error — it gets a body it cannot decode, and its user a broken screen. The
protocol version is what turns that into an honest "update the app".
So any such change bumps dwProtocolVersion, in the same change. The rule is enforced by
packages/dartway_core_shared/test/wire_golden_test.dart: it encodes every shape as the framework
does, compares it with the encodings recorded in test/goldens/wire_golden.dart together with the
protocol version they were taken at, and decodes each recorded encoding again, so a reader that stops
accepting an encoding it accepted before is a wire change too. When an encoding differs while the version is
unchanged, the test fails with the instruction: bump dwProtocolVersion and refresh the golden with
DW_UPDATE_GOLDENS=1 dart test test/wire_golden_test.dart. A refresh refuses to overwrite a recorded
encoding while the version is still the one it was recorded at — it follows a bump, it does not
replace one.
What an installed app may simply not send is not such a change. Dw-Utc-Offset was added without a
bump (D-110): an older build sends no header and its caller's offset is unknown, a newer build's
header is ignored by an older server — neither side meets a body it cannot read. The header is
recorded as a shape of its own (http.utcOffset), not by changing an existing one.
The generated project contract gate
The generator emits sorted lib/generated/dw_contract.json (descriptor format 2, codec format 1;
format 1 baselines are also accepted) from the same resolved models as the codecs. generate --check and check compare the current
source-derived shape with a fixed committed Git baseline. Pass --contract-base <revision>; it is
resolved once to a commit SHA and reported. Normal project check uses the configured base branch
merge-base (including main/master); CI supplies a trusted base SHA explicitly. Working JSON edits,
regeneration and feature commits do not replace that baseline. A project with a releaseBranch
raises the version at the release instead: tasks generate at the current version against the release
branch, and generate --release raises it once (D-158, CLI).
Field/wire-name removals and renames, recursive type/nullability/patch/default changes, resolved
request/result-kind changes, strict-enum additions/removals and enum openness changes are breaking.
A new data object is additive, but the shared package's version: must be greater than the trusted
base's version (a patch bump is enough). Its descriptor and generated protocol entry record since
as that version. Existing objects retain the trusted base's since, or none for objects already
known to every client of the line. The server filters response and live updates, including deletions,
against the client's full contract version; it drops empty channels and sends no empty live update.
since is metadata, never a shape change. The client still rejects unknown groups as a server bug.
The descriptor moves to format 2 automatically on the next generation; no project code edit or
framework wire version bump is needed.
New calls and nullable/defaulted/patch fields are codec-compatible: the new decoder accepts missing keys and the old decoder ignores extra keys. Dart required on a nullable field does
not make it wire-required. An already-open enum may gain values while retaining all old names and
unknown. A changed default is breaking even when both codecs omit the same JSON key.
For example, changing CustomerCard({required this.id}) to
CustomerCard({required this.id, required this.title}) rejects the old { "id": 1 } payload.
Regenerating at shared version 0.7.2 or 0.7.3 remains red; change it to 0.8.0 and regenerate.
The existing runtime then refuses an older client line with 426/dw.updateRequired. A nullable
String? title addition accepts the old payload and can stay on 0.7.
First descriptor adoption
When the trusted base has no descriptor, first adoption requires unchanged
hand-written shared package files and the union of shared's own in-repo path-dependency
closure at the trusted base and at head. Starting from shared's dependencies, the generator
follows each in-repo path dependency's pubspec transitively; dev dependencies and packages used
only by the server or app are excluded. The resolved lock identifies dependency sources, including
overrides; workspace members supply their own pubspec edges. Base pubspecs and locks are read from
Git objects at the trusted SHA. The nearest-lock walk stops at the Git root. A dependency that
leaves this closure blocks adoption and is named even if its old files remain unchanged in the
repository, so repointing it to Git or an external path cannot hide a contract change.
Framework git-subpath dependencies and dependencies outside the Git root at both revisions stay
outside the comparison.
For each compared package, the generator compares the union of base-tracked and current files,
excluding that package's pubspec.yaml and pubspec.lock. In shared, only the exact paths emitted
by the current generator and .dw.dart parts bearing its generated header are excluded; existing
parts are judged from base bytes, so adding a header cannot hide a source edit. Hand-written files
under lib/generated/ are still compared. Regular files are hashed in one
git hash-object --stdin-paths call with worktree-relative paths, applying Git clean/eol filters
so a CRLF checkout of LF source passes; symlinks are hashed individually by their verbatim targets.
A framework pin move may regenerate codecs and establish the descriptor; the result
names the base SHA and adoption proof.
If source differs, the gate names the files and asks to split the change: land the
pin move and descriptor first, then edit the contract in a following PR. A committed
descriptor always wins. No historical dependency fetch or codec reproduction is
used. Unsupported formats, custom codecs, custom enum name encoders and
default-equality overrides remain blocking contract not verified diagnostics.
Coverage is generated project codecs/registry, excluding handler/domain semantics
and manually composed external modules. The descriptor is tooling metadata; it adds
no runtime compatibility negotiation and does not change dwProtocolVersion.