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]>
18 KiB
WAI Extension: Deployment
Status: Draft. A deployment is a project's built tree:
dist/aswai buildwrites 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 isdeployment.openapi.json. The reference implementation is the moduledeploymentofwai-rs(featureed25519), the targetwai_deploy_targetand the operations ofwai_cli/dx/deploy.rs;wai-rs/tests/cli_deploy.rsholds 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, asschema/README.mdstates 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
- the RFC 8785 form of what it says, byte for byte;
- an object with exactly the members
$schema(https://wai.transaction.science/schema/wai-build.json/1),wai(1),project(sha256, andnamewhere it has one),lock(sha256alone),toolchain(an object) andfiles; - a
fileslist of objects of exactlypath,sha256(64 lowercase hexadecimal digits) andbytes(a whole number of at most 2^53 − 1), sorted by path as bytes, each path once; - with every path 1 to 1024 bytes of
/-joined segments, none empty,.or.., none holding a backslash or a control character, nonewai-build.json, and no path a directory of another (abesidea/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.