# Format changelog

Append-only. Every change to a normative statement or a fixture gets an entry.

## v0, unreleased

Not frozen. No deployed implementation depends on it yet.

### The model

- Delta types are add, replace, remove.
- Deltas address `id` plus `ref`, never a path.
- `ref` is polymorphic across the three observable kinds and is discriminated by the
  encoding.
- Prior values are not carried on the wire.
- The commit is the unit that crosses every boundary, and is order-independent internally.
- Commit scope is one document.
- Coalescing rules, keyed by slot rather than by time.
- An optional per-commit integrity tag.

### The encoding, added 2026-08-31

- A byte encoding with one spelling per value: null, booleans, integers, float64, byte
  strings, text strings, arrays. No maps, no tags, no indefinite lengths. Decision 004.
- Whole numbers within plus or minus 2^53 are integers; everything else finite is a float64.
  NaN and the infinities have no encoding, and negative zero is written as zero.
- Text is UTF-8, and an unpaired surrogate has no encoding.
- A value is a primitive or a reference. References carry the kind of the observable they
  name, and nothing is inlined. Decision 007.
- Array slots are named by ordered byte strings that are non-empty and do not end in a zero
  byte. Generation is deliberately unspecified. Decision 006.
- Map slots are named by an id.
- Deltas within a commit are written in ascending order of encoded `id` then encoded `ref`,
  and any other order is refused rather than sorted.
- A commit carries at least one delta. A tag is 4 to 32 bytes.
- Ids are 12 bytes, written as sixteen base64url characters, with no timestamp component.
  Decision 005.
- 14 conformance fixtures and 30 rejection fixtures, each rejection naming the reason it must
  be refused for.

### Attach edges, added 2026-08-31

- A reference states which kind of edge it is: **attach** or **alias**. Encoded as a third
  element, `[edge, kind, id]`, costing one byte per reference. Decision 010.
- Every observable reachable from the root has exactly one attach edge. A commit that would
  give one a second attach edge is refused, and so is a delta whose target has no attach path
  from the root.
- Reachability counts the attach edges a commit adds and ignores the ones it removes, so a
  commit may write into a subtree while detaching it.
- This closes the previously open question of what an unreachable observable means: a delta
  into one is refused. What becomes of a subtree after it is detached is still open.

### The integer range, corrected 2026-09-01

The prose in 6.2 was already right and the implementation was not, so nothing normative
changed here. Recording it because two fixtures did.

- The range in 6.2 is stated as plus or minus 2^53, and its reason is exact representability.
  A double holds every integer of magnitude 2^53 or less exactly, so both endpoints are in
  the range and the range is symmetric.
- The encoder had reached for the safe integer range instead, which stops one short on the
  positive side. It wrote 2^53 as a float and refused the integer spelling of it, so a
  commit written by a second implementation reading this prose came back refused.
- Fixture `015-number-edges` had named the safe boundary `largestExact` and skipped 2^53
  entirely. It now carries 2^53 and its negative, and keeps the safe boundary as its own
  case.
- Rejection fixture `012-integer-out-of-range` had used the integer spelling of 2^53, which
  is legal. It now uses one past it.
- A decoder **MUST** judge an eight byte argument before combining its halves. Adding them
  first rounds an argument past 2^53 back down into the range, after which the check passes
  and the decoder answers with a number the bytes did not state. This was live and silent.

15 conformance fixtures and 35 rejection fixtures, replacing the counts recorded above.

### Two rules that existed only in the corpus, written down 2026-09-01

An implementer building a second reading of the format from the prose alone found both of
these by decoding fixture bytes and working backwards. Nothing normative changed; both rules
were already enforced and already had fixtures. They were not stated.

- **1.2, an observable's kind is fixed.** Nothing changes an observable's kind, and every
  mention of an id has to agree about it: the `kind` on a reference, the kind a `ref` implies,
  and the kind the document already has. A commit whose mentions disagree is refused. Fixture
  `invalid/028-kind-conflict` was the only place this rule appeared.
- **The apply-stage refusal vocabulary, in section 7.** `kind-conflict`, `multiple-attach`,
  `slot-exists`, `slot-missing` and `unreachable`, each pointing at the rule it enforces.
  Section 7 already made the reason part of conformance without saying what any reason was.
  A test now holds the table and the corpus to each other, so neither can move alone.

Open: the integrity tag algorithm, and the fate of a detached subtree.

## 2026-09-01

- Section 6.1 states the nesting bound the decoder always enforced: the format needs four
  levels, a decoder refuses past eight. The reference decoder was admitting nine; it now
  admits exactly eight.
- Section 7 lists the decode-stage refusal vocabulary in full, the same way the apply stage
  was already listed. Three reasons were reachable from wire bytes with no fixture naming
  them; `invalid/036-malformed-head`, `invalid/037-delta-not-an-array` and
  `invalid/038-ref-not-an-array` close that.
- New fixture `016-astral-keys`: two object keys above the basic plane on one id, the case
  where byte order and code-unit order disagree.
- Fixture provenance: a fixture's `deltas` are now stated from the authored commit, ordered
  by a comparison independent of the encoder's sort, so the bytes are checked against a
  statement the encoder did not produce. The regenerated corpus is byte-identical, which is
  the point: the change is to what a regeneration would do with a broken encoder.

### The replication protocol, added 2026-09-02

[`spec/replication.md`](/spec/replication) v0 and the fixture corpus in [`spec/frames/`](https://github.com/torrinworx/aweft/tree/9a5bb24770dc7555257f8a307d79917efc58df70/spec/frames). It builds on the commit
format and changes nothing about it.

- A link carries frames in order, and delivery is never synchronous with the send: a commit
  applied from inside another document's delivery breaks the ordering the client rests on.
- A link carries many documents. Each is a topic, named by a string on the join frame and
  numbered by an integer on every frame after it. A string topic on every frame measured
  +29.5% over the commit bytes it carries, which is why it is a number. Decision 042.
- Two sequence counters per topic: the client numbers what it sends, the host numbers what it
  publishes. An accept states both, because a commit is never sent back to whoever made it and
  the sender would otherwise have a hole in its count of the topic.
- A frame carries one or more commits. That is batching and never rate limiting: every commit
  goes, in order, in the tick it was made. Frames are not compressed one at a time, because
  gzip per frame measured larger than the frames themselves.
- Seven frame kinds, written as arrays with the kind first, encoded by section 6 of the state
  format. A frame that does not decode ends the link; a commit that is refused never does.
- A `joined` frame says whether it describes the whole document, as a field of its own rather
  than by carrying a reset. A host whose document is empty has no commit to send and still has
  to be able to say so, or a replica holding stale content keeps it forever.
- A reset is the whole document as one commit of adds, and a receiver of a whole frame applies
  the difference rather than replacing the document. Decision 044.
- A host sends the accept for a commit before anything else it sends that client afterwards,
  so a host that writes in answer to a commit does not put the sender out of step.
- A client that has lost its place leaves the topic and joins it again presenting no session,
  rather than joining with a count of zero while still a member.
- One document is served under one authority; a second join resolving it differently is
  refused with `policy-mismatch`, and one link syncing it twice is refused with
  `document-in-use`. Neither ends the link, and neither does any other reason a join is
  turned away.
- A client that has asked for the whole document several times in a row without taking a
  commit from the host stops asking and reports it. Only a commit from the host counts as
  getting somewhere: a client that is writing collects accepts whatever else is wrong.
- 9 frame fixtures and 10 rejections, each rejection naming the reason it must be refused for.
- Not specified, on purpose: application messages, forwarding between hosts (so no frame
  carries an originating actor), read filtering, and how long a host remembers.

### The replication protocol, rewritten as v1 2026-09-03

v0 had a host and a client. v1 has two equal ends and replaces v0 whole. Decision 053.

- Nothing in the protocol says which end is which. Both run the same rules.
- Each end numbers the topics it opens, and a frame names a topic by the sender's number, so
  a name still rides on no commit frame. `open` carries the name, the sender's root id and
  kind, and whether it wants the other end's state.
- A topic is live at an end once it has shared the name and received the other end's `open`.
  Different root ids under one name are refused with `root-mismatch`.
- Six frame kinds: `open`, `state`, `commits`, `refused`, `leave`, `fault`. `join`, `joined`,
  `accept` and `refuse` are gone: they were verdicts, and a verdict needs an authority.
- One sequence counter per topic per direction, used only so a `refused` can name a commit.
  Loss is not detected: a link delivers in order or ends.
- A commit an end cannot apply is reported to that end's application and answered with
  `refused`, so the sender's application hears it too. The protocol never chooses which end
  yields; an end asks for the other's state by sending `open` again with `want`. Decision 054.
- A commit that arrived over a link is never sent back over it, and is delivered to every
  other consumer of the document as a commit made at this end. Decision 055.
- Nothing resumes. Sessions, replay windows and `policy-mismatch` are gone with the host.
- An `open` may state no root, meaning the sender holds nothing and wants the other end's
  state; two ends with nothing fault each other with `no-document` rather than waiting.
  Topic and sequence numbers below 1 are `bad-frame`. Added 2026-09-03 from the check-in.
- The frame fixtures in [`spec/frames/`](https://github.com/torrinworx/aweft/tree/9a5bb24770dc7555257f8a307d79917efc58df70/spec/frames) are regenerated for v1.

### Text messages beside the link, 2026-09-04

Section 6 now says what a text message on a WebSocket is: not a frame, and not this
protocol's. Nothing on the wire changed; the sentence records what the socket adapter already
did, so that a request channel sharing the socket (decision 073) is stated rather than
assumed.

### A detached observable leaves the document, 2026-09-05

Section 1.3 is new and section 8 loses its last open item. Nothing on the wire changes; what
changes is the rule both ends follow.

- An observable whose last attach edge a commit removes, with no delta in that commit giving
  it another, is no longer part of the document. An implementation must not answer for it.
- A later commit may attach it again by id, and describes its contents in full, exactly as it
  would for an observable the document has never held. A receiver with no observable by that
  id materializes one and applies those deltas.
- No fixture changes: the rule is about what an implementation holds between commits, not about
  bytes. Decision 084.

