Bitspark constellation
accepted source ↗

ADR 0024 — adstrate writes go through the fact interface too: the caller binds, the occupant confirms custody

  • Status: accepted
  • Date: 2026-06-14
  • Refines: 0021 ("access goes through the fact interface" — this extends it from reads to writes), 0022 (space-partitioned storage — the bytes a write places)
  • Builds on: 0017 (the adstrate — interface in facts, payload off-record; implementations are untrusted occupants)
  • Relates: 0016 (the CAS port)

A write to an adstrate is two facts by two parties, not one. The caller asserts the binding (content) — its provenance and authority, self-proved to the record like any fact. The occupant asserts custody (stored) — that it holds and serves the bytes, the one thing only the holder can truthfully say. And the byte upload itself needs no separate authorization: the binding's existence is the authority proof (the record only holds bindings an authorized caller could assert), and content-addressing is the integrity proof (wrong bytes cannot match the digest). So the occupant never vouches for a binding and holds no privileged write path — 0017's untrusted occupant property, finally true for writes as 0021 already made it for reads.

Context

0021 routed every read through the binding's space projection — "access goes through the fact interface, never around it." 0022 made the storage the binding governs space-partitioned and attributable. But the write path was never brought through the same interface. In the first corpus implementation, a PUT stored the bytes and then asserted the content binding with corpus's own identity: corpus was the binding-writer, holding a write grant and vouching for content on a caller's behalf.

That leaves two coupled problems, both contradicting the adstrate's own principles:

  • Lost provenance; a trusted occupant. The record showed corpus as the asserter of every binding, not whoever actually introduced the content. corpus had to be trusted to write, and an unauthenticated PUT meant any client reaching the service could cause a write into any space corpus was granted on. The space's write ACL was not enforced against the caller at all — exactly the asymmetry 0021 removed for reads, left standing for writes. This also sits badly with 0017: an occupant is supposed to hold no privileged path into the record, yet corpus held a content-binding write grant.
  • An apparent need for a new substrate primitive. Gating the caller's write looked like it needed a MayWriteWithGrants — a third-party assert-authority check mirroring the read side's read.MayReadWithGrants. That would be real new authority surface on the substrate.

The resolution dissolves both rather than building the primitive: bring the write through the fact interface, and let each party assert only what it is authoritative for.

Decision

1. A write is two facts, two asserters.

  • content(space, digest, size, mediaType) is asserted by the caller. It is the binding — "I introduce this content into this space." It carries authority and provenance: it needs the caller's space write grant, the caller signs it, and the record shows who bound it. It is self-proved to the record like any other fact (the caller's own assert proof chains to root).
  • stored(space, digest, by) is asserted by the occupant. It is the custody attestation — "I hold and serve these bytes." Only the holder can truthfully make it, the occupant signs it with its own identity, and a false one is self-defeating (caught on the next fetch + rehash).

These are not a co-signature on one fact; they are two orthogonal truths, and neither party can forge the other's: a caller cannot claim custody (cannot sign stored as the occupant), and an occupant cannot manufacture authority (asserting content would need its grant — the very trusted-writer posture we are removing).

2. The byte upload needs no separate authorization. An occupant accepts bytes iff a content binding for the digest is visible in the space (the same projection read the read gate already does). It never authenticates or authorizes the uploader, because it cannot be tricked:

  • wrong bytes for a bound digest → do not hash to the digest → rejected by re-hash;
  • correct bytes for a bound digest → are the right bytes backing a legitimate binding → harmless;
  • bytes for an unbound digest → refused; nothing authorizes that content into the space.

So binding existence is the authority proof and the digest is the integrity proof — together they make the upload safe regardless of who performs it.

3. The occupant holds no privileged write path. It asserts only its own stored custody, never content. 0017's untrusted-occupant property now holds for writes as 0021 made it hold for reads.

4. No new substrate authority primitive. Because the caller self-proves content to the record, there is no third-party write-authority decision for the occupant to make — read.MayReadWithGrants needs no MayWriteWithGrants twin on this path. The write gate is the read gate's existing machinery asking one question: is this digest bound in this space?

5. stored(space, digest, by) doubles as the served-by signal. The occupant-holds-this fact is also the routing/discovery hint for distribution: which occupant serves a space's content. One fact carries custody confirmation and the served-by table; the rehash discipline keeps it honest.

6. This is the general adstrate rule. As with 0021/0022 it is not a corpus special case: the producer of state (tabula) or flow (cursus) asserts the binding; the occupant confirms custody or availability. corpus is the worked first instance.

Consequences

  • The fetch SDK stays dependency-free; introducing content uses the substrate SDK. Asserting content is a fact assertion — a producer act — so it legitimately uses stele's SDK. The broad consumer/fetch surface stays stdlib-only. corpus's "an occupant that just stores and fetches does not drag the substrate in" promise is scoped to reads, which is the honest scoping: the prior design only kept it by having corpus pretend a write was not a fact assertion.
  • A write is two steps across two systems — bind to the record (stele), then upload to the occupant (corpus) — rather than one PUT. More steps, but the honest decomposition; a convenience helper may wrap both where a stele client is already in hand.
  • corpus is reworked. PUT gates on binding-existence, stores at (space, digest), and asserts stored(by=corpus); it no longer asserts content. Grants shift accordingly: the caller needs assert(content) on the space; the occupant needs read (to see bindings) + assert(stored); corpus no longer needs assert(content). (corpus PR.)
  • Dangling bindings and unbacked content are legal and meaningful. A content binding with no stored attestation (or no bytes yet) is "claimed but not yet held" — a read 404s until an occupant holds it; stored tells you who does. This cleanly separates naming content from hosting it.
  • A residual upload-DoS, not an authority hole. An unauthenticated uploader can spam uploads for unbound digests (cheaply refused) or re-upload correct bytes for bound digests (idempotent). This is a rate-limiting concern, not a way to place wrong or unauthorized content. Noted, not gated, in v0.
  • No repo or topology change. This refines the adstrate definition; it mints nothing and moves no layer. corpus/tabula/cursus remain draft adstrate members.

The Bitspark constellation — how the systems are built and relate.

GitHub