carlos / contra Public

doc: DESIGN.md is now PROTOCOL.md

It was never a design document in the visual sense. DESIGN.md is the
conventional home for a project's visual system — tokens, type, colour,
component behaviour — and having the wire format sitting on that name
means the visual system either has nowhere to go or lands on top of the
crypto spec. Renaming now, before there is a second file to collide with,
is a rename; renaming later is a merge.

Nothing in it changed. Every reference moved with it, including the
comments in the crypto path, which name the file constantly because that
is where the binding decisions are written down. AGENTS.md records the old
name so a discussion or commit written before today still reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQr7efkFL3ZHN3WEYhddyR
Paul Campbell pushed by paul@keymail.dev d333920ecc90a9e6bc67d548f4f3f1a9a7ccf72d parent bbe28f3
39 files changed, +81 −70
  • AGENTS.md +9 −4
  • PLAN.md +1 −1
  • PRODUCT.md +3 −2
  • DESIGN.md => PROTOCOL.md +6 −1
  • cmd/contra/main.go +1 −1
  • internal/contra/app.go +3 −3
  • internal/contra/auth/auth.go +2 −2
  • internal/contra/crypto/aead.go +1 −1
  • internal/contra/crypto/kdf.go +2 −2
  • internal/contra/crypto/vectors_test.go +3 −3
  • internal/contra/handlers.go +1 −1
  • internal/contra/model/model.go +4 −4
  • internal/contra/static/crypto/argon2id.js +1 −1
  • internal/contra/static/crypto/kdf.js +2 −2
  • internal/contra/static/crypto/kit.js +2 −2
  • internal/contra/static/crypto/link.js +3 −3
  • internal/contra/static/crypto/sharing.js +1 −1
  • internal/contra/static/crypto/vaultkeys.js +2 −2
  • internal/contra/static/pages/device.js +1 −1
  • internal/contra/static/pages/devicescan.js +1 −1
  • internal/contra/static/pages/kit.js +1 −1
  • internal/contra/static/pages/vaultshare.js +1 −1
  • internal/contra/store/links.go +1 −1
  • internal/contra/store/recovery.go +2 −2
  • internal/contra/store/sharing.go +1 −1
  • internal/contra/store/store.go +1 −1
  • internal/contra/templates/device.html +1 −1
  • internal/contra/templates/kit.html +1 −1
  • internal/contra/templates/vault.html +2 −2
  • internal/contra/vault/links.go +1 −1
  • internal/contra/vault/sharing.go +1 −1
  • internal/contratest/greps_test.go +1 −1
  • internal/contratest/recovery_test.go +1 −1
  • test/crypto.test.mjs +2 −2
  • test/webkit.test.mjs +1 −1
  • vectors/README.md +4 −4
  • vectors/contra-v1.json +3 −3
  • vectors/gen/gen.c +6 −6
  • vectors/gen/seedsearch.c +1 −1
diff --git a/AGENTS.md b/AGENTS.md
index 401d6d3..1b085b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,9 +4,14 @@
is the canonical instructions file; tool-specific files (`CLAUDE.md`) are
pointers to it, so keep it current *here*.
-What CONTRA *is* lives in `README.md`; the protocol in `DESIGN.md`; the
-roadmap and its reasoning in `PLAN.md`. This file is how we build and the
-decisions we have committed to.
+What CONTRA *is* lives in `README.md`; who it is for in `PRODUCT.md`; the
+cryptographic protocol in `PROTOCOL.md`; the visual system in `DESIGN.md`;
+the roadmap and its reasoning in `PLAN.md`. This file is how we build and
+the decisions we have committed to.
+
+`PROTOCOL.md` was called `DESIGN.md` until 2026-09-04. If you are reading a
+discussion, a commit message or a code comment older than that, the two
+names mean the same file.
CONTRA is a member of the CARLOS app family (`carlosframework/platform`);
see that family's `carlos:building-carlos-apps` skill for the shape it
@@ -120,7 +125,7 @@ have different blast radii. The Account Key alone is nearly harmless (one
input to the passphrase branch); the recovery code alone is full
takeover. One is a setup aid to keep handy, the other a bearer credential
to archive, and the risk we are managing is *storage* — people save PDFs,
-and one file puts both in the same folder forever. `DESIGN.md` carries
+and one file puts both in the same folder forever. `PROTOCOL.md` carries
both documents, the ladder, the reprint semantics and the loss table.
### 3. Email exists only as a blind index (Paul, 2026-09-01)
diff --git a/PLAN.md b/PLAN.md
index 590855b..906e3e4 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,6 +1,6 @@
# 🤖 PLAN — why CONTRA is shaped this way, and what lands when
-`DESIGN.md` is the spec. This is the reasoning and the order.
+`PROTOCOL.md` is the spec. This is the reasoning and the order.
## Why build this at all
diff --git a/PRODUCT.md b/PRODUCT.md
index 2d3c615..ef8fd00 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -4,7 +4,8 @@
🤖 Product record for CONTRA. Confirmed with Paul, 2026-09-03. Everything
here is product truth, not visual direction — `AGENTS.md` holds the settled
-decisions, `DESIGN.md` the protocol, `PLAN.md` what is built.
+decisions, `PROTOCOL.md` the cryptographic protocol, `DESIGN.md` the visual
+system, `PLAN.md` what is built.
## Platform
@@ -142,7 +143,7 @@ rebuilding on the same terms:
- Real artefacts that do exist: the deployed canary
(https://contra.bdj.oncarlos.com), the golden vectors
(`vectors/contra-v1.json`), `README.md`'s trade-offs section, `AGENTS.md`'s
- nine settled decisions, `DESIGN.md`'s protocol and compromise matrix, and
+ nine settled decisions, `PROTOCOL.md`'s protocol and compromise matrix, and
`PLAN.md`'s built/not-built state with a trigger per deferral.
- The two kit documents are generated by the product itself
(`internal/contra/static/crypto/kit.js`) and are the only real "assets".
diff --git a/DESIGN.md b/PROTOCOL.md
similarity index 98%
rename from DESIGN.md
rename to PROTOCOL.md
index cf000fa..613f55d 100644
--- a/DESIGN.md
+++ b/PROTOCOL.md
@@ -1,4 +1,9 @@
-# 🤖 DESIGN — CONTRA's protocol and key hierarchy
+# 🤖 PROTOCOL — CONTRA's cryptographic protocol and key hierarchy
+
+Renamed from `DESIGN.md` on 2026-09-04. It was never a design document in
+the visual sense, and the name was in the way: `DESIGN.md` is the
+conventional home for a project's visual system, and this file is the wire
+format. They are now separate files with separate jobs.
Status: **approved design — not yet built.** Reviewed 2026-09-01; the
Account Key decision settled the same day. Everything here is binding: if
diff --git a/cmd/contra/main.go b/cmd/contra/main.go
index 000f1ee..c09935b 100644
--- a/cmd/contra/main.go
+++ b/cmd/contra/main.go
@@ -74,7 +74,7 @@ func main() {
// would change every account's blind index on the next restart and make
// every one of them unreachable — data loss wearing a default's clothes.
//
- // DESIGN.md wants this off the instance entirely, at the routing layer or
+ // PROTOCOL.md wants this off the instance entirely, at the routing layer or
// in a KMS, so that a database-plus-bucket leak alone cannot unblind an
// address. Reading it from the environment is the interim, and the
// interim is worth naming out loud rather than forgetting.
diff --git a/internal/contra/app.go b/internal/contra/app.go
index ec869e3..84a29fe 100644
--- a/internal/contra/app.go
+++ b/internal/contra/app.go
@@ -33,7 +33,7 @@ type Config struct {
Mailer mail.Sender
// Pepper blinds the email index. It proves identity and decrypts
- // nothing; DESIGN.md wants it moved off the instance to the routing
+ // nothing; PROTOCOL.md wants it moved off the instance to the routing
// layer or a KMS, so that a database-plus-bucket leak alone cannot
// unblind an address.
Pepper []byte
@@ -132,13 +132,13 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
// The printed kit: reprint the Account Key card exactly, or reissue
// the recovery code — which regenerates rather than reprints, because
- // only its wrap is stored. DESIGN.md wants this obvious rather than
+ // only its wrap is stored. PROTOCOL.md wants this obvious rather than
// buried: it is the mitigation for the one loss mode the Account Key
// introduced.
r.Get("/kit", a.kit)
r.Post("/api/recovery", items.RegenerateRecovery)
- // The device handoff, DESIGN.md's rung 1. Both ends are signed in as
+ // The device handoff, PROTOCOL.md's rung 1. Both ends are signed in as
// the same account — a fresh browser signs in with the passphrase
// alone and only then finds it has no Account Key — so every one of
// these is scoped to the session, which is what makes a code short
diff --git a/internal/contra/auth/auth.go b/internal/contra/auth/auth.go
index ca52461..9fb25e7 100644
--- a/internal/contra/auth/auth.go
+++ b/internal/contra/auth/auth.go
@@ -44,7 +44,7 @@ import (
"github.com/carlosframework/contra/internal/contra/store"
)
-// The KDF floor, mirroring the client's. DESIGN.md puts the real defence in
+// The KDF floor, mirroring the client's. PROTOCOL.md puts the real defence in
// the browser — a malicious server is the threat, so a server-side check
// cannot protect anyone from us — but a client bug must not be able to
// persist parameters that a later, honest client would then trust.
@@ -93,7 +93,7 @@ type Auth struct {
//
// An ephemeral pepper would be worse than no pepper: every account's blind
// index would change on the next restart, and every one of them would become
-// unreachable — data loss dressed as a default. DESIGN.md wants this moved
+// unreachable — data loss dressed as a default. PROTOCOL.md wants this moved
// off the instance entirely, to the routing layer or a KMS, so that a
// database-plus-bucket leak alone cannot unblind addresses.
func New(db *gorm.DB, sess *sessions.Sessions, pepper []byte, mailer mail.Sender,
diff --git a/internal/contra/crypto/aead.go b/internal/contra/crypto/aead.go
index 35b9812..5b02e98 100644
--- a/internal/contra/crypto/aead.go
+++ b/internal/contra/crypto/aead.go
@@ -21,7 +21,7 @@ const (
// users.
//
// The scope id is the account id for account-scoped objects and the vault id
-// for vault-scoped ones — which is how DESIGN.md's two spellings of the
+// for vault-scoped ones — which is how PROTOCOL.md's two spellings of the
// binding, "(account_id, object_type, object_id, version)" and "(vault_id,
// item_id, version)", turn out to be one rule.
//
diff --git a/internal/contra/crypto/kdf.go b/internal/contra/crypto/kdf.go
index 9d360c2..c96ef1d 100644
--- a/internal/contra/crypto/kdf.go
+++ b/internal/contra/crypto/kdf.go
@@ -1,4 +1,4 @@
-// Package crypto implements CONTRA's key hierarchy exactly as DESIGN.md
+// Package crypto implements CONTRA's key hierarchy exactly as PROTOCOL.md
// specifies it, and nothing else.
//
// Every derivation here is pinned by vectors/contra-v1.json. That file was
@@ -33,7 +33,7 @@ const (
LabelECIES = "contra/v1/ecies"
)
-// Argon2id parameters, from DESIGN.md.
+// Argon2id parameters, from PROTOCOL.md.
//
// 64 MiB is the largest allocation that reliably succeeds in iOS Safari's WASM
// heap under memory pressure. Lanes is 1 because Argon2id's output depends on
diff --git a/internal/contra/crypto/vectors_test.go b/internal/contra/crypto/vectors_test.go
index 3af8e8a..905cd1e 100644
--- a/internal/contra/crypto/vectors_test.go
+++ b/internal/contra/crypto/vectors_test.go
@@ -1,7 +1,7 @@
// Tests against vectors/contra-v1.json — the cross-language crypto contract.
//
// The rule this file exists to enforce: Go, JavaScript and Swift must produce
-// identical bytes for every derivation in DESIGN.md, and the vector file is
+// identical bytes for every derivation in PROTOCOL.md, and the vector file is
// what pins them. It is the reference; this package is not. When a value here
// disagrees with the file, this package is wrong until somebody argues
// otherwise on the record.
@@ -260,7 +260,7 @@ func TestHKDFLabels(t *testing.T) {
})
seen[c.Info] = true
}
- // DESIGN.md names five derivation labels. A label that loses its vector
+ // PROTOCOL.md names five derivation labels. A label that loses its vector
// is a label nothing pins.
for _, l := range []string{LabelLogin, LabelKEK, LabelRCLogin, LabelRCKEK, LabelPRFKEK} {
if !seen[l] {
@@ -630,7 +630,7 @@ func TestAADIsInjective(t *testing.T) {
}
}
-// The whole of DESIGN.md's "The shape", re-derived from the printed inputs.
+// The whole of PROTOCOL.md's "The shape", re-derived from the printed inputs.
// It is what a new implementation bisects against when it diverges somewhere
// in the middle.
func TestAccountChain(t *testing.T) {
diff --git a/internal/contra/handlers.go b/internal/contra/handlers.go
index 4f29e46..efe7df5 100644
--- a/internal/contra/handlers.go
+++ b/internal/contra/handlers.go
@@ -105,7 +105,7 @@ func (a *app) device(w http.ResponseWriter, r *http.Request) {
render(w, "device", a.pageData(r))
}
-// kit is the reprint flow. DESIGN.md: if either document is lost while a
+// kit is the reprint flow. PROTOCOL.md: if either document is lost while a
// device is still enrolled, producing a new one must be obvious rather than
// buried — it is the mitigation for the loss mode the Account Key introduced.
func (a *app) kit(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/contra/model/model.go b/internal/contra/model/model.go
index 609a054..73c5ece 100644
--- a/internal/contra/model/model.go
+++ b/internal/contra/model/model.go
@@ -89,7 +89,7 @@ type LoginKey struct {
// code, passkey PRF. Peers, not a chain — each is independently sufficient
// to reach RK and none can be derived from another.
//
-// DESIGN.md requires every wrap row to carry a format version, the KDF
+// PROTOCOL.md requires every wrap row to carry a format version, the KDF
// parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the
// ciphertext. All six are here, and the KCV is load-bearing twice over:
// AES-GCM is not key-committing, so it is the commitment check, and it is
@@ -127,7 +127,7 @@ type Wrap struct {
// passkey, to this product, IS a wrap of RK — there is nothing else to
// know about one. Several rows per credential are expected and harmless:
// a provider that returns a different PRF for the same credential gets
- // another wrap rather than an error, which is how DESIGN.md says to
+ // another wrap rather than an error, which is how PROTOCOL.md says to
// tolerate the 1Password-class mismatch.
CredentialID []byte `gorm:"index;type:blob"`
@@ -182,7 +182,7 @@ type Vault struct {
// key-generation column, and that column tells the operator which items
// changed since a removal.
//
- // Starts at 1. DESIGN.md, "Rotation — and what does not heal".
+ // Starts at 1. PROTOCOL.md, "Rotation — and what does not heal".
Epoch uint64 `gorm:"not null;default:1"`
CreatedAt time.Time
@@ -286,7 +286,7 @@ type SignupSession struct {
}
// DeviceLink is one live handoff: a fresh device waiting for an enrolled one
-// to seal it a copy of the Account Key. DESIGN.md's rung 1, following
+// to seal it a copy of the Account Key. PROTOCOL.md's rung 1, following
// slopbox's `link` shape rather than inventing another.
//
// The row holds no secret. CodeHash is SHA-256 of the normalised code, so a
diff --git a/internal/contra/static/crypto/argon2id.js b/internal/contra/static/crypto/argon2id.js
index e332d0b..b5b80d6 100644
--- a/internal/contra/static/crypto/argon2id.js
+++ b/internal/contra/static/crypto/argon2id.js
@@ -185,7 +185,7 @@ function mulHi32(a, b) {
// refuses p != 1 before we ever get here; this is the belt.
export function argon2id(password, salt, params, tagLen = 32) {
const { m_kib: mKiB, t, p } = params;
- if (p !== 1) throw new Error("contra: argon2id is single-lane here (see DESIGN.md)");
+ if (p !== 1) throw new Error("contra: argon2id is single-lane here (see PROTOCOL.md)");
if (t < 1) throw new Error("contra: argon2id needs at least one pass");
const blocks = 4 * Math.floor(mKiB / 4);
diff --git a/internal/contra/static/crypto/kdf.js b/internal/contra/static/crypto/kdf.js
index 69bdfc5..1e39f97 100644
--- a/internal/contra/static/crypto/kdf.js
+++ b/internal/contra/static/crypto/kdf.js
@@ -20,7 +20,7 @@ export const LABEL_KCV = "contra/v1/kcv";
export const LABEL_EC_P256 = "contra/v1/ec-p256";
export const LABEL_AAD = "contra/v1/aad";
-// The client's hard floor on KDF parameters, from DESIGN.md.
+// The client's hard floor on KDF parameters, from PROTOCOL.md.
//
// Without it a malicious server serves m=8KiB, t=1, harvests one challenge
// signature, and cracks the passphrase cheaply offline. The floor is the
@@ -99,7 +99,7 @@ export async function hmacSha256(key, message) {
// distinguishes "wrong key" from "corrupted blob", which is what lets a
// passkey provider returning a mismatched PRF be treated as *fast unlock
// unavailable* rather than reported as an authentication failure — the
-// 1Password-class failure DESIGN.md requires us to tolerate.
+// 1Password-class failure PROTOCOL.md requires us to tolerate.
export async function kcv(kek) {
return (await hmacSha256(kek, enc.encode(LABEL_KCV))).slice(0, 16);
}
diff --git a/internal/contra/static/crypto/kit.js b/internal/contra/static/crypto/kit.js
index 7feb369..e537fde 100644
--- a/internal/contra/static/crypto/kit.js
+++ b/internal/contra/static/crypto/kit.js
@@ -7,7 +7,7 @@
// save PDFs, and one file puts both in the same folder forever; two files can
// go to two places.
//
-// The wording is DESIGN.md's, close to verbatim, and it does not lean on the
+// The wording is PROTOCOL.md's, close to verbatim, and it does not lean on the
// adjectives. "Store securely" and "store somewhere safe" are near-synonyms
// in ordinary English and nobody derives two standards of care from them, so
// the concrete places and behaviours do the work.
@@ -30,7 +30,7 @@ const COLUMN = A4.width - MARGIN * 2;
// handle identifies which account a sheet belongs to, without being useful to
// anyone who finds it.
//
-// DESIGN.md says the "blind-index handle", but the blind index is an HMAC
+// PROTOCOL.md says the "blind-index handle", but the blind index is an HMAC
// under a pepper the server holds and the browser deliberately never sees —
// so the browser cannot compute one. The account id is already a random UUID
// that identifies nothing by itself, so its first eight characters do the
diff --git a/internal/contra/static/crypto/link.js b/internal/contra/static/crypto/link.js
index 58a078b..6304cd5 100644
--- a/internal/contra/static/crypto/link.js
+++ b/internal/contra/static/crypto/link.js
@@ -1,4 +1,4 @@
-// The device handoff, from the browser's side. DESIGN.md's rung 1.
+// The device handoff, from the browser's side. PROTOCOL.md's rung 1.
//
// Two roles, one module. The waiting device mints an ephemeral keypair and a
// short code, shows the code, and polls; an enrolled device takes the code the
@@ -17,10 +17,10 @@
// # What it does not defend against
//
// A server that serves malicious JS. It could swap the waiting device's
-// public key for its own and read the Account Key. DESIGN.md already scopes
+// public key for its own and read the Account Key. PROTOCOL.md already scopes
// browser clients to at-rest and honest-but-curious "because the server
// serves the JS", and no arrangement of this module changes that — the fix is
-// the separate integrity-protected origin DESIGN.md has adopted and we have
+// the separate integrity-protected origin PROTOCOL.md has adopted and we have
// not built.
import { fromHex, randomBytes, toHex } from "./bytes.js";
diff --git a/internal/contra/static/crypto/sharing.js b/internal/contra/static/crypto/sharing.js
index fdd9f47..c8811bb 100644
--- a/internal/contra/static/crypto/sharing.js
+++ b/internal/contra/static/crypto/sharing.js
@@ -97,7 +97,7 @@ export async function openSharedKeyset(dAcct, acctPublic, accountId, vaultId, ro
// removed member may already hold K_vault.
//
// What this does NOT do, and no honest implementation can: un-download what
-// somebody already has. DESIGN.md says so and the copy must not soften it.
+// somebody already has. PROTOCOL.md says so and the copy must not soften it.
export async function rotate(net, opened, vault, items, keep, newKVault) {
if (!vault.owned) {
throw new NotTheOwnerError(
diff --git a/internal/contra/static/crypto/vaultkeys.js b/internal/contra/static/crypto/vaultkeys.js
index e1ffd2e..6cf7387 100644
--- a/internal/contra/static/crypto/vaultkeys.js
+++ b/internal/contra/static/crypto/vaultkeys.js
@@ -5,7 +5,7 @@
// opening a wrap the server cannot. A session proves who you are. It does
// not, and must never, hand you a key.
//
-// The chain, from DESIGN.md's "The shape":
+// The chain, from PROTOCOL.md's "The shape":
//
// passphrase --Argon2id(salt_pw)--> S_pw
// S_pw --HKDF(salt=account_key, "contra/v1/kek")--> KEK_pw
@@ -34,7 +34,7 @@ export class NoAccountKeyError extends LockedError {}
// Account Key's cache is one named thing rather than localStorage calls
// scattered through the app.
//
-// DESIGN.md: once a device holds the Account Key it is cached locally, and
+// PROTOCOL.md: once a device holds the Account Key it is cached locally, and
// from then on the passphrase alone unlocks that device. This is that cache,
// and it is per-device by construction — nothing syncs it.
export function accountKeys(storage) {
diff --git a/internal/contra/static/pages/device.js b/internal/contra/static/pages/device.js
index 288d411..88cf751 100644
--- a/internal/contra/static/pages/device.js
+++ b/internal/contra/static/pages/device.js
@@ -1,4 +1,4 @@
-// The fresh-device ladder, DESIGN.md's rung 1, 2 and 3.
+// The fresh-device ladder, PROTOCOL.md's rung 1, 2 and 3.
//
// This page has two jobs and shows both, because a person arriving here knows
// which one they are doing and the app does not: a browser that needs the
diff --git a/internal/contra/static/pages/devicescan.js b/internal/contra/static/pages/devicescan.js
index ca575dd..1f9ebc4 100644
--- a/internal/contra/static/pages/devicescan.js
+++ b/internal/contra/static/pages/devicescan.js
@@ -11,7 +11,7 @@
// nothing, and shipping a decoder inside the module graph that holds the keys
// is a great deal of code for a convenience.
//
-// DESIGN.md's rule is that every screen offers all AVAILABLE rungs and never
+// PROTOCOL.md's rule is that every screen offers all AVAILABLE rungs and never
// dead-ends. Hiding this one costs a shortcut, not a way in: typing the same
// code off the same card is the rung below, and it always works.
//
diff --git a/internal/contra/static/pages/kit.js b/internal/contra/static/pages/kit.js
index caae9af..9c519f3 100644
--- a/internal/contra/static/pages/kit.js
+++ b/internal/contra/static/pages/kit.js
@@ -51,7 +51,7 @@ async function main() {
wireCard(accountIdHex, short, accountKey);
wireReissue(bootstrap, accountIdHex, short, accountKey);
- // The nag, DESIGN.md's row six. Shown when this browser holds the key and
+ // The nag, PROTOCOL.md's row six. Shown when this browser holds the key and
// has no way of knowing another one does — which is the honest state, since
// nothing about other devices is stored anywhere we could ask.
if (accountKey) $("[data-nag]").hidden = false;
diff --git a/internal/contra/static/pages/vaultshare.js b/internal/contra/static/pages/vaultshare.js
index bf80043..e99e400 100644
--- a/internal/contra/static/pages/vaultshare.js
+++ b/internal/contra/static/pages/vaultshare.js
@@ -6,7 +6,7 @@
//
// # The copy rule this file exists to hold
//
-// DESIGN.md's revocation answer has three parts and the screen must not
+// PROTOCOL.md's revocation answer has three parts and the screen must not
// soften any of them: access dies immediately, the key rotates forward, and
// **nothing already downloaded is un-downloaded**. The third is the one every
// product in this category quietly omits. It is said here, before the button,
diff --git a/internal/contra/store/links.go b/internal/contra/store/links.go
index 2f957d6..73c4953 100644
--- a/internal/contra/store/links.go
+++ b/internal/contra/store/links.go
@@ -13,7 +13,7 @@ import (
"github.com/carlosframework/contra/internal/contra/model"
)
-// LinkTTL is how long a handoff code lives. Ten minutes is DESIGN.md's, and
+// LinkTTL is how long a handoff code lives. Ten minutes is PROTOCOL.md's, and
// it is the number doing most of the work here: the code is short enough to
// read aloud, so its safety comes from the window and from both ends being
// signed in, not from its length.
diff --git a/internal/contra/store/recovery.go b/internal/contra/store/recovery.go
index f10dba8..c21712b 100644
--- a/internal/contra/store/recovery.go
+++ b/internal/contra/store/recovery.go
@@ -15,7 +15,7 @@ import (
//
// # Why this one deletes
//
-// Everything else in this package is additive. DESIGN.md says the recovery
+// Everything else in this package is additive. PROTOCOL.md says the recovery
// code is the exception, and says why: only its wrap is stored, never the
// value, so it cannot be *reprinted* — it can only be *regenerated*. A new
// sheet means a new code, and the old sheet has to stop working, or "reprint"
@@ -28,7 +28,7 @@ import (
//
// # What it does not do
//
-// It never touches the passphrase wrap. DESIGN.md's rule is that W_pw and
+// It never touches the passphrase wrap. PROTOCOL.md's rule is that W_pw and
// W_rc are never removed — losing a passkey or a device must not strand
// anybody — so a failure part-way through this must never be able to leave an
// account with no way in. One transaction, and the passphrase branch is not
diff --git a/internal/contra/store/sharing.go b/internal/contra/store/sharing.go
index 6029581..8d7712f 100644
--- a/internal/contra/store/sharing.go
+++ b/internal/contra/store/sharing.go
@@ -191,7 +191,7 @@ type Rotation struct {
//
// In a seal-to-public-key model revocation IS rotation: a removed member may
// have cached K_vault, so taking their row away without changing the key
-// changes nothing about what they can decrypt. DESIGN.md's three-part answer,
+// changes nothing about what they can decrypt. PROTOCOL.md's three-part answer,
// none of which may be softened in the copy: access dies immediately, the key
// rotates forward, and **nothing already downloaded is un-downloaded**.
//
diff --git a/internal/contra/store/store.go b/internal/contra/store/store.go
index b1412cb..e0da296 100644
--- a/internal/contra/store/store.go
+++ b/internal/contra/store/store.go
@@ -374,7 +374,7 @@ func (s *Store) PutItem(ctx context.Context, accountID, vaultID, itemID []byte,
// v3 as v7. It has never stopped rollback, and does not claim to: serving the
// whole older (version, nonce, ciphertext) triple consistently yields an item
// that opens, because it is a genuinely older authentic item. That is in
-// DESIGN.md's residue section, not papered over here.
+// PROTOCOL.md's residue section, not papered over here.
//
// This is Eleven's putBlob contract applied per item rather than per user,
// which is the shape that lets a conflict scope to one item instead of a
diff --git a/internal/contra/templates/device.html b/internal/contra/templates/device.html
index 25987ce..aa41d08 100644
--- a/internal/contra/templates/device.html
+++ b/internal/contra/templates/device.html
@@ -10,7 +10,7 @@
being asked to hand it over. Which of those they are is obvious to them
and not to us, so both are offered rather than guessed at.
- DESIGN.md: every screen that needs an Account Key offers all available
+ PROTOCOL.md: every screen that needs an Account Key offers all available
rungs and never dead-ends. */}}
<section data-need>
diff --git a/internal/contra/templates/kit.html b/internal/contra/templates/kit.html
index 8e6dd47..201e9f1 100644
--- a/internal/contra/templates/kit.html
+++ b/internal/contra/templates/kit.html
@@ -71,7 +71,7 @@ oversight — it is what "nobody can open your vault for you" costs.</p>
<section data-nag hidden>
<h2>Only one device</h2>
- {{/* DESIGN.md's loss table, row six: passphrase known, kit lost, every
+ {{/* PROTOCOL.md's loss table, row six: passphrase known, kit lost, every
device gone. The obligation is not to soften the rule — it is to make
sure nobody arrives there by accident. This is that. */}}
<p>This is the only browser holding your Account Key. If you lose it and the
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html
index f186d6b..c93100e 100644
--- a/internal/contra/templates/vault.html
+++ b/internal/contra/templates/vault.html
@@ -54,7 +54,7 @@
<h3>Members</h3>
<ul data-members></ul>
- {{/* DESIGN.md's revocation answer, all three parts, before the button
+ {{/* PROTOCOL.md's revocation answer, all three parts, before the button
rather than after it. The third is the one every product in this
category quietly omits. */}}
<p data-kind="warning">Removing somebody does three things, and only two of
@@ -74,7 +74,7 @@
<p data-share-status aria-live="polite"></p>
</section>
-{{/* DESIGN.md: the reprint flow must be obvious rather than buried, and
+{{/* PROTOCOL.md: the reprint flow must be obvious rather than buried, and
the nag to enrol a second device lives beside it. Both are the
mitigation for the one loss mode the Account Key introduced. */}}
<p><a href="/kit">Your printed kit</a> — reprint the Account Key card, or
diff --git a/internal/contra/vault/links.go b/internal/contra/vault/links.go
index 29f5ece..bccb0b1 100644
--- a/internal/contra/vault/links.go
+++ b/internal/contra/vault/links.go
@@ -1,4 +1,4 @@
-// The device handoff: DESIGN.md's rung 1, and the only route in CONTRA that
+// The device handoff: PROTOCOL.md's rung 1, and the only route in CONTRA that
// carries a secret from one device to another.
//
// It carries it sealed. The waiting device mints an ephemeral P-256 keypair
diff --git a/internal/contra/vault/sharing.go b/internal/contra/vault/sharing.go
index 344acff..f03ae4b 100644
--- a/internal/contra/vault/sharing.go
+++ b/internal/contra/vault/sharing.go
@@ -148,7 +148,7 @@ func (v *Vault) AddMember(w http.ResponseWriter, r *http.Request) {
// which is why removal has no endpoint of its own — it IS this call with a
// shorter list.
//
-// DESIGN.md's three-part answer, none of which the copy may soften: access
+// PROTOCOL.md's three-part answer, none of which the copy may soften: access
// dies immediately, the key rotates forward, and nothing already downloaded
// is un-downloaded.
func (v *Vault) Rotate(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/contratest/greps_test.go b/internal/contratest/greps_test.go
index 58d982f..0f78dde 100644
--- a/internal/contratest/greps_test.go
+++ b/internal/contratest/greps_test.go
@@ -64,7 +64,7 @@ import (
// continuous replication is — so grepping them is grepping the bucket, and
// the copy step below is written out rather than assumed so the claim is
// visible instead of implied. What a test cannot reach is the *history*: the
-// bucket keeps superseded object versions, which is why DESIGN.md pins a
+// bucket keeps superseded object versions, which is why PROTOCOL.md pins a
// lifecycle policy expiring them. A leak here would already be in versions
// no test can delete.
diff --git a/internal/contratest/recovery_test.go b/internal/contratest/recovery_test.go
index 80d788f..5fa1867 100644
--- a/internal/contratest/recovery_test.go
+++ b/internal/contratest/recovery_test.go
@@ -140,7 +140,7 @@ func TestReissuingRecoveryReplacesTheBranch(t *testing.T) {
}
// The passphrase branch is not in the transaction and must not be touched.
-// DESIGN.md: W_pw and W_rc are never removed, so nothing here may be able to
+// PROTOCOL.md: W_pw and W_rc are never removed, so nothing here may be able to
// strand an account.
func TestReissuingRecoveryLeavesThePassphraseBranchAlone(t *testing.T) {
h, gdb := newAppWithDB(t)
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs
index 6a3bfaf..3769db8 100644
--- a/test/crypto.test.mjs
+++ b/test/crypto.test.mjs
@@ -244,7 +244,7 @@ test("Argon2id at the corners the 64 MiB case does not reach", async () => {
}
});
-test("Argon2id matches the golden vector at the parameters DESIGN.md pins", async () => {
+test("Argon2id matches the golden vector at the parameters PROTOCOL.md pins", async () => {
const c = V.argon2id.cases[0];
const got = await argon2id(
new TextEncoder().encode(c.passphrase_utf8),
@@ -255,7 +255,7 @@ test("Argon2id matches the golden vector at the parameters DESIGN.md pins", asyn
assert.equal(toHex(got), c.out_hex, c.name);
});
-// The whole of DESIGN.md's "The shape", browser side, so a divergence can be
+// The whole of PROTOCOL.md's "The shape", browser side, so a divergence can be
// bisected rather than stared at.
test("the account chain, end to end", async () => {
const c = V.account_chain;
diff --git a/test/webkit.test.mjs b/test/webkit.test.mjs
index ae2b624..5a8ec9a 100644
--- a/test/webkit.test.mjs
+++ b/test/webkit.test.mjs
@@ -1,7 +1,7 @@
// The browser crypto, in WebKit.
//
// Safari is the engine this product can least afford to be wrong about —
-// DESIGN.md pins 64 MiB of Argon2id memory specifically for iOS Safari's WASM
+// PROTOCOL.md pins 64 MiB of Argon2id memory specifically for iOS Safari's WASM
// heap — and it is the one Chromium-based CI never touches. This suite exists
// because a signup that worked in Chromium, in node, and in Playwright's
// WebKit still failed in a real Safari, on an operation none of those three
diff --git a/vectors/README.md b/vectors/README.md
index 986ce0b..a08c678 100644
--- a/vectors/README.md
+++ b/vectors/README.md
@@ -1,6 +1,6 @@
# 🤖 Golden vectors — the cross-language crypto contract
-`contra-v1.json` is the reference for every derivation in `DESIGN.md`.
+`contra-v1.json` is the reference for every derivation in `PROTOCOL.md`.
CONTRA implements the same crypto three times: Go on the instance, JavaScript
in the console, Swift on the Mac. They must produce identical bytes. **All
@@ -20,9 +20,9 @@ checksum and a single-substitution repair; AES-256-GCM with its AAD binding.
`account_chain` is the whole hierarchy end to end, so a new implementation can
bisect where it diverges rather than staring at one wrong key.
-The wire formats `DESIGN.md` leaves open — HKDF mode, the AAD layout, Crockford
-packing, the rejection loop — are pinned in **discussion 19**. Read that before
-implementing against this file.
+The wire formats `PROTOCOL.md` leaves open — HKDF mode, the AAD layout,
+Crockford packing, the rejection loop — are pinned in **discussion 19**. Read
+that before implementing against this file.
## Where the numbers come from
diff --git a/vectors/contra-v1.json b/vectors/contra-v1.json
index 10e69c9..9341a17 100644
--- a/vectors/contra-v1.json
+++ b/vectors/contra-v1.json
@@ -1,7 +1,7 @@
{
"format": "contra-golden-vectors",
"version": 1,
- "spec": "DESIGN.md; wire formats pinned in discussion 19",
+ "spec": "PROTOCOL.md; wire formats pinned in discussion 19",
"generator": "vectors/gen/gen.c against OpenSSL 3.5.6 7 Apr 2026",
"note": "Every input here is generated: fixture(label) = SHA-256(\"contra/v1/vectors/\" || label). Nothing in this file is a real secret, and nothing in it may be reused as one.",
"binding": "The Go, JS and Swift crypto suites must all pass this file. Changing a value here is changing the protocol.",
@@ -211,7 +211,7 @@
}
},
"handoff": {
- "note": "DESIGN.md rung 1: an enrolled device seals the Account Key to a fresh device's ephemeral public key, and the server relays a blob it cannot open",
+ "note": "PROTOCOL.md rung 1: an enrolled device seals the Account Key to a fresh device's ephemeral public key, and the server relays a blob it cannot open",
"shared_secret": "X(d*P), big-endian at the field width; Y discarded, per SEC 1 3.3.1",
"key": "HKDF-SHA-256(ikm=shared_secret, salt=ephemeral_public || recipient_public, info=\"contra/v1/ecies\", L=32)",
"envelope": "ephemeral_public(65) || nonce(12) || ciphertext || tag(16)",
@@ -233,7 +233,7 @@
"tag_hex": "e3855f79299f4270d9f5fb0572171be8"
},
"account_chain": {
- "note": "the whole of DESIGN.md's 'The shape', end to end, so a new implementation can bisect where it diverges",
+ "note": "the whole of PROTOCOL.md's 'The shape', end to end, so a new implementation can bisect where it diverges",
"passphrase_utf8": "contra-golden-vector-passphrase-do-not-use-8f3a1c",
"salt_pw_hex": "962c1e2ca2b4c0602e6ec706e76e3eac",
"account_key_hex": "f60fb281e8c72f6725add4cbd40e7b08",
diff --git a/vectors/gen/gen.c b/vectors/gen/gen.c
index 537475f..54ccb4a 100644
--- a/vectors/gen/gen.c
+++ b/vectors/gen/gen.c
@@ -1,5 +1,5 @@
/*
- * gen — produce vectors/contra-v1.json from DESIGN.md, using OpenSSL.
+ * gen — produce vectors/contra-v1.json from PROTOCOL.md, using OpenSSL.
*
* Why this is C and not Go: discussion #6 forbids generating the vectors
* from an implementation written in the same session and then testing that
@@ -299,7 +299,7 @@ static void ecies_key(const unsigned char eph_pub[65],
* domain-tagged so it cannot be replayed into another protocol.
*
* The scope id is the account id for account-scoped objects and the vault id
- * for vault-scoped ones, which is how DESIGN.md's two spellings of the
+ * for vault-scoped ones, which is how PROTOCOL.md's two spellings of the
* binding — "(account_id, object_type, object_id, version)" and "(vault_id,
* item_id, version)" — turn out to be the same rule.
*/
@@ -559,7 +559,7 @@ static void check_anchors(void) {
expect("PHC Argon2id t=2 m=65536 p=1", out, 32,
"09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7");
- /* Same, at t=3 — the parameters DESIGN.md actually pins. */
+ /* Same, at t=3 — the parameters PROTOCOL.md actually pins. */
argon2id((const unsigned char *)"password", 8,
(const unsigned char *)"somesalt", 8, 65536, 3, 1, out, 32);
@@ -793,7 +793,7 @@ int main(int argc, char **argv) {
printf("{\n");
printf(" \"format\": \"contra-golden-vectors\",\n");
printf(" \"version\": 1,\n");
- printf(" \"spec\": \"DESIGN.md; wire formats pinned in discussion 19\",\n");
+ printf(" \"spec\": \"PROTOCOL.md; wire formats pinned in discussion 19\",\n");
printf(" \"generator\": \"vectors/gen/gen.c against %s\",\n",
OPENSSL_VERSION_TEXT);
printf(" \"note\": \"Every input here is generated: fixture(label) = "
@@ -1082,7 +1082,7 @@ int main(int argc, char **argv) {
printf("\n }\n },\n");
printf(" \"handoff\": {\n");
- printf(" \"note\": \"DESIGN.md rung 1: an enrolled device seals the "
+ printf(" \"note\": \"PROTOCOL.md rung 1: an enrolled device seals the "
"Account Key to a fresh device's ephemeral public key, and the "
"server relays a blob it cannot open\",\n");
printf(" \"shared_secret\": \"X(d*P), big-endian at the field width; Y "
@@ -1113,7 +1113,7 @@ int main(int argc, char **argv) {
printf("\n },\n");
printf(" \"account_chain\": {\n");
- printf(" \"note\": \"the whole of DESIGN.md's 'The shape', end to end, "
+ printf(" \"note\": \"the whole of PROTOCOL.md's 'The shape', end to end, "
"so a new implementation can bisect where it diverges\",\n");
need_comma = 0;
indent = " ";
diff --git a/vectors/gen/seedsearch.c b/vectors/gen/seedsearch.c
index d476a88..09f52ba 100644
--- a/vectors/gen/seedsearch.c
+++ b/vectors/gen/seedsearch.c
@@ -1,7 +1,7 @@
/*
* seedsearch — find a seed whose first P-256 candidate is out of range.
*
- * DESIGN.md requires rejection sampling mod n when deriving a P-256 scalar
+ * PROTOCOL.md requires rejection sampling mod n when deriving a P-256 scalar
* from a seed, and discussion #6 requires a vector that actually exercises
* the rejection branch: "pin a seed that requires a rejection, or the branch
* goes untested". A rejection happens with probability about 2^-32, so no