open-standards/wai/extensions/deployment.md
Transaction Science 09e4fb9a1b
All checks were successful
wai-app and Create / wai-app · does this change touch it (push) Successful in 12s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 41s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 47s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 34s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 19s
wai-audit collector and player integration / does this change touch it (push) Successful in 25s
wai-fleet service / does this change touch it (push) Successful in 16s
wai-quantum for JavaScript / does this change touch it (push) Successful in 19s
WAI operation catalogue / does this change touch it (push) Successful in 26s
wai-key-proxy / does this change touch it (push) Successful in 43s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 15s
wai-quantum studio and desktop / does this change touch it (push) Successful in 29s
wai-ledger service / does this change touch it (push) Successful in 29s
wai-sink-android native sink library / does this change touch it (push) Successful in 32s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 1m0s
WAI Studio / does this change touch it (push) Successful in 18s
wai-sink-swift native sink package / does this change touch it (push) Successful in 1m11s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m41s
wai-audit collector and player integration / collector · linux (push) Successful in 2m9s
wai-key-proxy / proxy · linux (push) Successful in 2m2s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 3m45s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m1s
wai-fleet service / service · linux (push) Successful in 4m15s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 6m41s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 7m22s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 4m18s
wai-ledger service / service · linux (push) Successful in 3m55s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m29s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 6m10s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 7m48s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 47s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 8m31s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m56s
wai-quantum studio and desktop / studio · browser (push) Successful in 6m44s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 8m14s
wai-fleet service / service · macos (push) Successful in 1m1s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 10m58s
wai-ledger service / service · macos (push) Successful in 1m1s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 8m9s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 13m54s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 3m39s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 14m4s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 7m31s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 7m59s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 8m53s
WAI Studio / Studio in WebKit · linux (push) Successful in 16m57s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 17m59s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 11m32s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m30s
wai deploy: a project's built tree on any deployment target, by its digest, with the target's signed receipts
What a deployment is (wai/extensions/deployment.md §3): dist/ as wai build
writes it, its build record and every file the record lists, named by the
SHA-256 of the record's bytes. A client before it sends a tree, and a target
before it holds one, hold the record to §3.3 and each file to its SHA-256 and
size.

The deployment API (§4, wai/extensions/deployment.openapi.json), which names
no host: GET /v1/deployments, PUT /v1/deployments/files/{sha256},
POST /v1/deployments, POST /v1/deployments/promote and
POST /v1/deployments/rollback, twelve refusal codes, and for each list,
create, promotion and rollback a wai.deployment-receipt/1 signed by the
target's Ed25519 key over its RFC 8785 form (§5): the operation, the
request's nonce, the target's count of changes, the deployment named, the
live deployment after and before, and the digest of what it holds.
wai::deployment (feature ed25519) reads records and signs and verifies
receipts for both sides.

The reference target is wai_deploy_target, a small service of its own in
wai-rs (feature quickstart), not a /_wai/deploy surface of wai dev: wai dev
cannot start without the relay and room service of other crates, and its
/_wai/ routes keep Studio's rules for a page. A target that serves nothing
else is tested alone, in this repository's CI. It keeps files by SHA-256,
written whole, its state written whole in place of the one before and held
to every record and file again on start, and serves the live deployment.

wai deploy (deployment.create), wai deployment list, promote <digest> and
rollback: placed client, preview, in a build with quickstart. wai deploy
builds, or with --prebuilt takes dist/ as it is, holds the tree to its
record before anything is sent, sends only what the target lacks, and with
--promote makes it live. Each takes --target, waits for --yes before it
builds, writes or sends anything, sends nothing with --dry-run, verifies
every receipt and holds it to its request, and keeps the receipts of each
change in .wai/deployments/<target>/ with its own operation receipt naming
them. wai --help's start list gains deploy, before verify. ops.json and
Studio's actions are regenerated (each operation bound to the command line);
mcp.json is unchanged, as no deployment operation is an MCP tool.

tests/cli_deploy.rs, in wai-catalogue.yml, runs the four against the
reference target and plants the faults the text refuses, each refused beside
a control that is not: a file of dist/ changed, added and removed after the
build; bytes put under another SHA-256; a record not in its canonical form;
a promotion of a digest the target does not hold, and of a file's SHA-256;
a rollback with none before; a replayed answer, an edited receipt, a list
that is not its receipt's state; and a kept receipt changed, refused by wai
verify. A rollback makes live exactly the deployment live before (history of
three); --dry-run leaves the target as it was, byte for byte; the OpenAPI
document's routes, operations, codes and receipt members are the
implementation's. tests/cli_ops.rs gains the three --dry-run samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 12:43:01 -05:00

18 KiB
Raw Permalink Blame History

WAI Extension: Deployment

Status: Draft. A deployment is a project's built tree: dist/ as wai build writes it, its files and its build record, wai-build.json, named by its digest, the SHA-256 of the build record's bytes. A deployment target is a host that holds deployments by their digests and keeps one of them live. This extension states what a deployment is and what a target serves of it (§3), the API every target serves (§4), the receipt a target signs for each answer (§5), how a target holds its deployments (§6), the reference target (§7) and the toolchain's operations (§8). It names no host: any host that serves §4 under an address is a target. The API's OpenAPI document is deployment.openapi.json. The reference implementation is the module deployment of wai-rs (feature ed25519), the target wai_deploy_target and the operations of wai_cli/dx/deploy.rs; wai-rs/tests/cli_deploy.rs holds the three, and the OpenAPI document, to each other. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts read for this revision: the WAI texts this one cites; RFC 8032; RFC 8259; RFC 8785; RFC 9110; FIPS 180-4; OpenAPI 3.1.0.

1. Scope

In scope: the tree a deployment is, its digest, the rules it is held to and what a target serves of it (§3); the routes of the API, their requests, answers and refusals (§4); the receipt, its members, its signature and what a client holds it to (§5); how a target holds files, deployments and its live one (§6); the reference target (§7); and the toolchain's operations (§8).

Out of scope: who may change a target, which is decided in front of it (a request carries a key as every request of the toolchain does, wai auth login); names, certificates and caches in front of a target; a build on the target, which holds what was built and builds nothing; and removing a deployment, which this revision does not define.

2. Terms

  • Tree: the files of dist/ and its build record.
  • Build record: dist/wai-build.json, as schema/README.md states it: the project's and the lock's digests, the toolchain, and every other file of the tree with its path, SHA-256 and size.
  • Digest: the SHA-256 of the build record's bytes, 64 lowercase hexadecimal digits. Two builds of one project and lock give one record, so one digest.
  • Held: a file is held by a target when it keeps its bytes under their SHA-256; a deployment is held when its build record and every file the record lists are held, and the target has taken it (§4.3).
  • Live: the deployment a target serves. History: every deployment made live and not rolled back, oldest first; its last is the live one.
  • Change: a create that holds a deployment the target did not hold, a promotion that changes the live deployment, or a rollback. A target counts its changes, seq.

3. A deployment

3.1 The tree

A deployment is the build record and every file it lists, each at its path, nothing else. A tree with a file the record does not list is not the record's tree, and a client MUST NOT send it (§8.1); a name of a file or directory that starts with a dot is not part of a tree, as wai build writes none and wai verify reads none.

3.2 The digest

The digest is the SHA-256 of the build record's bytes as wai build wrote them: RFC 8785 over one object, on one line, with no line ending. The record names every file by its SHA-256, so the digest names every byte of the tree.

3.3 The rules a tree is held to

A client before it sends a tree, and a target before it holds one, MUST refuse a build record that is not

  1. the RFC 8785 form of what it says, byte for byte;
  2. an object with exactly the members $schema (https://wai.transaction.science/schema/wai-build.json/1), wai (1), project (sha256, and name where it has one), lock (sha256 alone), toolchain (an object) and files;
  3. a files list of objects of exactly path, sha256 (64 lowercase hexadecimal digits) and bytes (a whole number of at most 2^53 − 1), sorted by path as bytes, each path once;
  4. with every path 1 to 1024 bytes of /-joined segments, none empty, . or .., none holding a backslash or a control character, none wai-build.json, and no path a directory of another (a beside a/b).

A client MUST also hold each file of dist/ to the record: its SHA-256 and size those the record states. A target MUST hold each file it is sent to the SHA-256 it is put at (§4.2), and a file a record lists to the size the record states (§4.3).

3.4 What a target serves

A target that serves its live deployment serves each file of the tree at its path under the path its list names as serves (§4.1), byte for byte, and the build record at wai-build.json, so that anyone reading it can hold the tree to it. A request for that path itself, or for a path that ends with /, is the index.html there; a path that names no file of the tree, but whose <path>/index.html is one, is that file; any other path is 404. Each file's media type is read from its name's extension. A target MAY hold deployments and serve none, and then lists serves as null.

4. The API

Every path below is under the target's address, which may itself have a path. A request and an answer with a body are JSON (RFC 8259), but a file's bytes (§4.2). A request names a nonce, 32 to 128 lowercase hexadecimal digits, fresh for each request; the receipt repeats it (§5). A body is sent with Content-Length.

Request Operation Does
GET /v1/deployments?nonce=<n> deployment.list what the target holds, signed (§4.1)
PUT /v1/deployments/files/{sha256} deployment_file.create a file held by its SHA-256 (§4.2)
POST /v1/deployments deployment.create a deployment held by its digest (§4.3)
POST /v1/deployments/promote deployment.promote a deployment held made live (§4.4)
POST /v1/deployments/rollback deployment.rollback the one live before the live one made live again (§4.5)

4.1 List

GET /v1/deployments?nonce=<n> answers 200 with format (wai.deployments/1); live, the live deployment's digest or null; history; held, every deployment held, sorted by digest, each with its digest, its project (sha256, and name where its record names one), how many files and how many bytes; serves (§3.4); limits.file_bytes, the largest file it takes; and receipt (§5), whose state is the digest of the held and history listed. It changes nothing.

4.2 A file

PUT /v1/deployments/files/{sha256} with a file's bytes (application/octet-stream) holds them under {sha256}. Bytes that do not hash to it are refused, digest-mismatch (422), and nothing is kept. It answers 201 when the file is new and 200 when it was held, each with sha256, bytes and held: true. A file once held is never changed. A record is put the same way, under its digest.

4.3 Create

POST /v1/deployments with {"digest", "nonce"}. The target reads the file it holds under the digest as a build record (§3.3) and checks that it holds every file the record lists, by SHA-256 and size. When it does, it holds the deployment and answers 201 with created: true and the receipt. A deployment already held is the same deployment: 200, created: false, and nothing changes. When it lacks files, 409 files-missing with missing: each file it lacks, by path, sha256 and bytes, the record itself by the path wai-build.json and bytes: null. A record that does not hold to §3.3, or a file whose size is not the one the record states, is refused, tree-refused (422). A create never changes which deployment is live.

4.4 Promote

POST /v1/deployments/promote with {"digest", "nonce"}, and expect_live where the client read the live deployment first. A digest the target does not hold is refused, deployment-not-held (404), and nothing changes; an expect_live that is not the live deployment (null: none is live) is refused, live-changed (409). Otherwise the deployment is appended to the history and is live: 200, changed: true, and the receipt. Promoting the live deployment changes nothing: 200, changed: false.

4.5 Rollback

POST /v1/deployments/rollback with {"nonce"}, and expect_live as for a promotion. The last deployment of the history is removed from it, and the one before it is live: exactly that one, whatever was promoted since. A history of fewer than two deployments is refused, nothing-to-roll-back (409), and nothing changes. 200, changed: true, and the receipt, whose digest and live are the deployment made live and whose previous is the one rolled back.

4.6 Refusals

A refusal answers {"error": <code>, "message": <text>}, and the members its code names:

Code Status Raised by
invalid-request 400 a body, a nonce, a digest or a SHA-256 that is not one
not-found 404 a path under /v1/deployments/ that is no route
deployment-not-held 404 a promotion of a digest the target does not hold
method-not-allowed 405 a route asked with another method
files-missing 409 a create whose record or files are not all held; missing
live-changed 409 an expect_live that is not the live deployment; live
nothing-to-roll-back 409 a rollback with no deployment before the live one
length-required 411 a body without Content-Length
too-large 413 a body larger than the target takes; limit
digest-mismatch 422 a file whose bytes are not the SHA-256 it is put at; sha256, theirs
tree-refused 422 a record that does not hold to §3.3, or a size it states that a held file does not have
internal 500 a target that could not keep what it was sent; nothing changed

5. The receipt

Every answer to a list, a create, a promotion and a rollback carries receipt, an object of exactly these members:

Member Holds
format wai.deployment-receipt/1
op the operation it answers: deployment.list, deployment.create, deployment.promote or deployment.rollback
key the target's Ed25519 public key (RFC 8032), 64 lowercase hexadecimal digits
nonce the request's nonce
seq the target's count of changes, this one included
digest the deployment the request named; for a rollback, the one made live; null for a list
live the live deployment after the request, or null
previous the live deployment before it, or null; live itself where the request did not change it
state the SHA-256 of the RFC 8785 form of {"held": [...], "history": [...]} after the request, every digest held sorted, and the history oldest first
time the target's clock, YYYY-MM-DDTHH:MM:SSZ
signature the Ed25519 signature by key over the RFC 8785 form of every other member, 128 lowercase hexadecimal digits

A client MUST refuse an answer whose receipt does not verify, whose op is not the request's, whose nonce is not the one it sent, or that is signed by another key than the target's other answers in the same operation. It MUST also hold what the receipt states to the request: a list's held and history to its state, and its live to the history's last; a create's digest to the deployment sent, and its live to its previous; a promotion's digest and live to the deployment named, and its previous to the live deployment the client read; and a rollback's live and digest to the deployment before the live one in the history the client read, and its previous to that live one. A refused receipt means the client does not know what the target did, and says so.

6. Holding

6.1 Files and deployments

A target keeps each file by its SHA-256 and never changes it, so a file several deployments list is held once. It holds a deployment from its create until this revision's end: no request removes one.

6.2 The live deployment and its history

A promotion appends to the history, a rollback removes its last, and nothing else changes it. Each change is whole: a target keeps its state so that a crash leaves the state before the change or the state after it, and answers only after the state after it is kept. A request that is refused, or that changes nothing, leaves seq as it was.

6.3 Serving

A target that serves its live deployment serves it as §3.4 states, from the files it holds, so the bytes served are the bytes the record names.

7. The reference target

wai_deploy_target --dir <D> [--port P] [--bind ADDR] [--key <seed file>] [--max-file-bytes N] serves §4 over the directory D, which it owns: files/<sha256> each written whole and renamed into place, and state.json, the deployments held, the history and seq, written whole in place of the one before. Its key is the Ed25519 seed --key names (64 hexadecimal digits), else D/target.key, made on its first start, readable by its owner alone. On start it reads its state back and holds every deployment it names to its record and files again, by SHA-256, and does not start when one does not hold. It listens on 127.0.0.1 unless --bind names another address, on --port (0, the default, picks a free one), prints one JSON line when it is ready (ready, url, key, dir), answers one request at a time, serves its live deployment at / (serves: "/"), and takes files of at most --max-file-bytes (256 MiB). It has no accounts: who may reach it is decided in front of it.

8. The operations

The toolchain's operations, each named by its rule (an operation noun.verb, its command line the words in order), each in a build with the feature quickstart:

Operation Command line Requests Effect
deployment.create wai deploy [<dir>] [--target <name|url>] [--prebuilt] [--promote] [--key <name>] list; create; each file it lacks; create; with --promote, promote the target
deployment.list wai deployment list [--target <name|url>] list reads
deployment.promote wai deployment promote <digest> [--target <name|url>] [--key <name>] list; promote the target
deployment.rollback wai deployment rollback [--target <name|url>] [--key <name>] list; rollback the target

8.1 wai deploy

wai deploy builds the project in <dir> (.) as wai build does, its checks and its receipt, and refuses as it refuses; with --prebuilt it takes dist/ as it is. Either way it holds the tree to its record (§3.3) before it sends anything: a file the record lists that is not there, or whose SHA-256 or size is another, or a file there the record does not list, is refused (exit 1, dist-changed), and nothing is sent. It then reads the target's list and asks the target to hold the deployment by its digest; for each answer files-missing it puts what the target names, from dist/, each held to its SHA-256 again, and asks again (the record first, where the target lacks it, then the files the record lists). With --promote it then promotes the deployment, naming as expect_live the live one its create's receipt states. A file the target already holds is not sent.

8.2 Every operation

--target names the target as every operation that acts on a service names one: a target's name (wai target add) or the target's address; each takes the target in use (wai target use) when none is named. A read-only target refuses each that changes it. A request carries the key wai auth login kept for the target's origin, or WAI_TOKEN's, and only over https:// or to this machine.

Each that changes the target waits for --yes (--confirm, deprecated, is the same) before it builds, writes or sends anything, and without it exits 4 with the proposed change and the command that confirms it. --dry-run sends nothing, not even a list, and writes nothing: it names each request it would send and each file it would write. --json writes the operation's JSON envelope.

Each verifies every receipt it is answered with and holds it to its request (§5); a receipt that does not hold is refused (exit 1, receipt-refused). Each that changes the target writes each receipt of a change to .wai/deployments/<target>/<verb>-<nonce>.json under <dir> (for a promotion and a rollback, the current directory), <target> the first 16 hexadecimal digits of the SHA-256 of the target's address and <nonce> the first 16 of the request's, and then its own operation receipt (schema/README.md, Operation receipts), naming each as written and, for wai deploy, the build record as read; --key signs it. wai verify holds the receipts' files to it.

A refusal by the target is the operation's, by its code: deployment-not-held exits 5; live-changed, nothing-to-roll-back, digest-mismatch, tree-refused and files-missing exit 1; a target that cannot be reached exits 6.

9. Conformance

A target conforms when it serves §4 and signs §5 as stated, holds as §6 states, and refuses with the codes of §4.6. A client conforms when it holds a tree to §3.3 before sending it and every receipt to §5. wai-rs/tests/cli_deploy.rs holds the reference target and the toolchain's operations to this text against each other, plants the faults this text refuses (a file of dist/ changed after its build, bytes put under another SHA-256, a promotion of a deployment the target does not hold, a rollback with none before the live one, a receipt answering another request) and requires each to be refused, requires a rollback to make live exactly the deployment live before, requires --dry-run to leave the target as it was, and holds the OpenAPI document's routes, operations, codes and receipt members to the implementation's.

10. Security considerations

A digest names every byte of a tree, so a target cannot serve, under a deployment's digest, bytes the record does not name, and a client that holds its tree to its record sends no byte the build did not write. A receipt binds a target's key to what it did for one request, by its nonce, so an answer to another request, or a receipt kept from before, is refused; it does not say who may hold that key, which is the target's operator's to publish. A target is reached as every service of the toolchain is (--target), and a key goes only over https:// or to this machine. The reference target listens on loopback and has no accounts: a target reached from elsewhere is put behind what decides who may reach it.