carlos / contra Public

doc: PRODUCT.md — the durable product record

Captured via /impeccable init. Product truth only: users, purpose,
positioning, operating context, capabilities, constraints, evidence,
principles, accessibility. No visual direction — AGENTS.md keeps the settled
decisions, DESIGN.md the protocol, PLAN.md the built/not-built state.

Most of it was already written down across those three and README.md, so this
consolidates rather than invents. Three things were genuinely unrecorded and
were confirmed rather than guessed:

- **Two primary users, weighted equally**: a small team moving off a shared
  manager (Eleven, with shared vaults needed on day one) AND a person leaving
  1Password with their own vault. Neither may be traded off for the other,
  which is a real constraint — it is why sharing and import are both v1.
- **Platform: adaptive.** The native Mac app and iOS are the product rather
  than ports, and the trust story differs by platform in a way design must not
  blur: the at-rest promise holds everywhere, but "compromise the running
  server and still get nothing" is only fully true where the code is signed.
- **WCAG 2.2 AA, binding.** With two places it is a product constraint rather
  than a checklist: the printed kit has to survive real paper and a phone
  camera, and recovery must not assume a mouse, a camera or a steady hand.

Evidence records what does NOT exist as firmly as what does — pre-alpha, no
customers, no testimonials, no press, no benchmarks — so later work cannot
fabricate any of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev bbe28f36aa1b0ffd45fb95376df5ae3fa3313726 parent 84f891e
1 file changed, +178 −0
  • PRODUCT.md +178 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	github.com/carlosframework/contra/cmd/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra/auth	[no test files]
ok  	github.com/carlosframework/contra/internal/contra/crypto	0.414s
?   	github.com/carlosframework/contra/internal/contra/icons	[no test files]
?   	github.com/carlosframework/contra/internal/contra/mail	[no test files]
?   	github.com/carlosframework/contra/internal/contra/model	[no test files]
?   	github.com/carlosframework/contra/internal/contra/store	[no test files]
?   	github.com/carlosframework/contra/internal/contra/vault	[no test files]
ok  	github.com/carlosframework/contra/internal/contratest	16.412s
=== js-test ===
node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \
	test/sharing.test.mjs
TAP version 13
# Subtest: HKDF: every contra/v1 label
ok 1 - HKDF: every contra/v1 label
  ---
  duration_ms: 10.033901
  type: 'test'
  ...
# Subtest: KCV
ok 2 - KCV
  ---
  duration_ms: 2.433452
  type: 'test'
  ...
# Subtest: P-256 from a seed, including the rejection branch
ok 3 - P-256 from a seed, including the rejection branch
  ---
  duration_ms: 12.516997
  type: 'test'
  ...
# Subtest: signed challenge round-trips against the derived public key
ok 4 - signed challenge round-trips against the derived public key
  ---
  duration_ms: 5.034003
  type: 'test'
  ...
# Subtest: Crockford: alphabet, encoding, normalisation, strict decoding
ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding
  ---
  duration_ms: 1.00373
  type: 'test'
  ...
# Subtest: the printed code, and the O/I/L confusions it has to survive
ok 6 - the printed code, and the O/I/L confusions it has to survive
  ---
  duration_ms: 2.148747
  type: 'test'
  ...
# Subtest: repair recovers one substituted character
ok 7 - repair recovers one substituted character
  ---
  duration_ms: 48.184215
  type: 'test'
  ...
# Subtest: AEAD: the AAD binding and every wrap
ok 8 - AEAD: the AAD binding and every wrap
  ---
  duration_ms: 2.570004
  type: 'test'
  ...
# Subtest: AEAD: the item, scoped to its vault
ok 9 - AEAD: the item, scoped to its vault
  ---
  duration_ms: 1.083074
  type: 'test'
  ...
# Subtest: a transplanted ciphertext does not open
ok 10 - a transplanted ciphertext does not open
  ---
  duration_ms: 1.906517
  type: 'test'
  ...
# Subtest: padding quantises into 256-byte buckets and round-trips
ok 11 - padding quantises into 256-byte buckets and round-trips
  ---
  duration_ms: 1.508627
  type: 'test'
  ...
# Subtest: the KDF parameter floor refuses what a malicious server would offer
ok 12 - the KDF parameter floor refuses what a malicious server would offer
  ---
  duration_ms: 0.618224
  type: 'test'
  ...
# Subtest: Argon2id matches the published PHC reference vector
ok 13 - Argon2id matches the published PHC reference vector
  ---
  duration_ms: 1821.430334
  type: 'test'
  ...
# Subtest: Argon2id at the corners the 64 MiB case does not reach
ok 14 - Argon2id at the corners the 64 MiB case does not reach
  ---
  duration_ms: 211.859307
  type: 'test'
  ...
# Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins
ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins
  ---
  duration_ms: 2676.837088
  type: 'test'
  ...
# Subtest: the account chain, end to end
ok 16 - the account chain, end to end
  ---
  duration_ms: 2565.348415
  type: 'test'
  ...
# Subtest: handoff: both keypairs derive from their published seeds
ok 17 - handoff: both keypairs derive from their published seeds
  ---
  duration_ms: 16.793481
  type: 'test'
  ...
# Subtest: handoff: ECDH agrees from both sides
ok 18 - handoff: ECDH agrees from both sides
  ---
  duration_ms: 8.19898
  type: 'test'
  ...
# Subtest: handoff: the derived key, the AAD and the sealed bytes
ok 19 - handoff: the derived key, the AAD and the sealed bytes
  ---
  duration_ms: 2.406611
  type: 'test'
  ...
# Subtest: handoff: an envelope opens for its device and for nobody else
ok 20 - handoff: an envelope opens for its device and for nobody else
  ---
  duration_ms: 17.457114
  type: 'test'
  ...
# Subtest: handoff: the Go and JS halves seal for each other
ok 21 - handoff: the Go and JS halves seal for each other
  ---
  duration_ms: 8.417826
  type: 'test'
  ...
# Subtest: handoff: a truncated envelope is refused before any key work
ok 22 - handoff: a truncated envelope is refused before any key work
  ---
  duration_ms: 0.559359
  type: 'test'
  ...
# Subtest: an item round-trips through create, list and update
ok 23 - an item round-trips through create, list and update
  ---
  duration_ms: 10.016432
  type: 'test'
  ...
# Subtest: nothing readable reaches the network
ok 24 - nothing readable reaches the network
  ---
  duration_ms: 2.514757
  type: 'test'
  ...
# Subtest: item ciphertexts are quantised into 256-byte buckets
ok 25 - item ciphertexts are quantised into 256-byte buckets
  ---
  duration_ms: 1.629206
  type: 'test'
  ...
# Subtest: an item does not open in another slot
ok 26 - an item does not open in another slot
  ---
  duration_ms: 2.76327
  type: 'test'
  ...
# Subtest: every field is present whether or not it was filled in
ok 27 - every field is present whether or not it was filled in
  ---
  duration_ms: 1.19698
  type: 'test'
  ...
# Subtest: the Account Key cache is storage handed in, not reached for
ok 28 - the Account Key cache is storage handed in, not reached for
  ---
  duration_ms: 0.324877
  type: 'test'
  ...
# Subtest: an owner seals a keyset that a different account can open
ok 29 - an owner seals a keyset that a different account can open
  ---
  duration_ms: 17.62246
  type: 'test'
  ...
# Subtest: nobody else can open a member's wrap
ok 30 - nobody else can open a member's wrap
  ---
  duration_ms: 15.32979
  type: 'test'
  ...
# Subtest: a member wrap carries the vault key and not the drop box
ok 31 - a member wrap carries the vault key and not the drop box
  ---
  duration_ms: 6.023185
  type: 'test'
  ...
# Subtest: a member cannot share or rotate
ok 32 - a member cannot share or rotate
  ---
  duration_ms: 1.668605
  type: 'test'
  ...
# Subtest: rotation re-seals every item under the new key
ok 33 - rotation re-seals every item under the new key
  ---
  duration_ms: 7.668751
  type: 'test'
  ...
1..33
# tests 33
# suites 0
# pass 33
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7432.84584
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..2d3c615
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,178 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+🤖 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.
+
+## Platform
+
+adaptive
+
+The web console is what exists today. `AGENTS.md` decision 6 fixes the build
+order: rastrillo instance and client-shape web console first, then a native
+Mac app as the *second* implementation of a settled contract, then the macOS
+AutoFill Credential Provider extension, then iOS. Recorded as `adaptive`
+because those native surfaces are the product, not ports of it — and because
+the trust story differs by platform in a way design must not blur: the
+at-rest promise holds everywhere, but "compromise the running server and
+still get nothing" is only fully true where the code is signed and not
+fetched per visit.
+
+## Users
+
+Two primaries, weighted equally. Neither may be traded off for the other.
+
+- **A small team moving off a shared password manager.** Eleven is the first,
+ and its secrets move in as soon as CONTRA is ready — with shared vaults
+ working on day one, not as a later capability. Their situation: credentials
+ that several people genuinely need, on infrastructure they control, without
+ a multi-tenant operator holding anything that opens them.
+- **A person leaving 1Password with their own vault.** Their job is getting
+ everything across without a support article, and then trusting a thing whose
+ recovery story is stricter than what they left. The `.1pux` file is opened,
+ parsed and sealed in their browser; it never reaches the server.
+
+An AI agent is a third, narrow actor rather than an audience: it may list a
+vault, use a secret without receiving it, and add new secrets. It may not read
+a value into its own context, and there is no setting that turns that on
+(`AGENTS.md` decision 5).
+
+## Product Purpose
+
+A password manager where the server knows nothing about you. Not "we promise
+not to look" and not "we encrypt at rest": the instance holding a vault cannot
+open it, cannot tell one account from another by content, and does not know
+the account's email address.
+
+Success is that a stolen instance yields a pile of ciphertext and the times of
+day somebody connected — and that a person who reads the trade-offs first
+still chooses it.
+
+## Positioning
+
+Built to **CARLOS A1, host-blind** — the strictest class the family
+publishes. What a neighbouring product cannot truthfully copy without
+rebuilding on the same terms:
+
+- **No content-derived identifiers at rest.** Item names, URLs, usernames,
+ folder structure and vault names seal exactly like passwords, because a name
+ is a secret too. All object identifiers are random, never content hashes:
+ a content-derived identifier is both an A1 violation and an equality oracle.
+- **There is no way back in that we control.** No reset link, no support
+ override, no identity verification that restores a vault — because a server
+ that could give your vault back could give it to someone else. Every
+ competitor that can email you a way in has, by definition, a way in.
+- **Sign-in is a signature, not a stored verifier.** The server holds only a
+ public key and authenticates by signed challenge, so a fully compromised
+ server holds something that cannot decrypt and cannot be replayed.
+- **The honest trade-offs are on the front page**, not in a support article —
+ including the ones competitors omit: that removing somebody from a shared
+ vault cannot un-download what they already have, that the server learns
+ transport shape, and that a browser trusts the server to send honest code.
+
+## Operating Context
+
+- **One instance per account**, on the owner's own deployment. A shared vault
+ lives on its owner's instance and other members are identities there. That
+ is what keeps membership an access-control list — transport shape — rather
+ than a content-derived identifier, and the distinction is load-bearing.
+- **The Emergency Kit is paper.** Two printed documents, generated in the
+ browser at signup and never sent to the server: an **Account Key card** (a
+ new device asks for it once; useless without the passphrase) and a
+ **Recovery sheet** (the way back if the passphrase is forgotten; opens the
+ vault on its own, and nobody can reissue it). They are stored differently on
+ purpose — a drawer versus a safe — and the copy must keep saying so.
+- **Getting onto a new device** is a three-rung ladder that must never
+ dead-end: a handoff from an already-enrolled device, scanning the card's QR,
+ or typing its code. Once a device holds the Account Key, the passphrase
+ alone unlocks it — the everyday case.
+- **Migration is a file, not an integration.** 1Password `.1pux`, opened
+ client-side. Passkeys stored in 1Password cannot be exported by anyone, so
+ those are re-created by hand and the product must show which ones.
+
+## Capabilities and Constraints
+
+- **Crypto is byte-identical across implementations**, pinned by shared golden
+ vectors generated by neither of them. Changing a pinned value is changing
+ the protocol.
+- **Sealing happens where the keys are**, which makes a server-rendered vault
+ impossible rather than merely discouraged: the server has never seen a title
+ and could not render one.
+- **A new storage surface lands with its canary or it does not land.** Canary
+ strings are pushed through every write path and the raw database bytes
+ searched for them.
+- **Recovery, stated plainly:** lose the kit *and* the passphrase and the data
+ is gone. Lose the kit while a device is still enrolled and it reprints — the
+ Account Key card exactly, the recovery sheet only by regenerating, which
+ invalidates the old one.
+- **Accepted residue**, published rather than hidden: the sharing graph (which
+ accounts share a vault, never what is in it), item count, per-item write
+ timing, and sign-in timing.
+- **Money:** a genuine free rung, then €5/year. Cheap on purpose — a password
+ manager people cannot afford to keep is one that loses their data. Integer
+ cents; a float never touches an amount.
+- **Terminology that must stay stable:** Account Key, recovery code, Emergency
+ Kit, vault, item, member, drop box, instance, handoff.
+- **Explicitly undecided / not built:** Bitwarden and Apple Passwords
+ importers, passkey migration out of 1Password, cross-instance sharing
+ (federation — never in v1), breach monitoring, one-off sharing links, agent
+ mutation of an existing item. Each has a written trigger in `AGENTS.md`.
+
+## Brand Commitments
+
+- **Name:** CONTRA. A CARLOS app.
+- **Voice:** plain, specific, and unapologetic about consequences. It states
+ the cost of a decision in the same breath as the decision, and does not
+ soften a limitation into a feature. "That is the product, not a gap in it."
+- **AI authorship is disclosed.** Machine-authored text carries 🤖 where it
+ surfaces; a person marker (👨/👤/🧑) certifies human text an LLM must not
+ rewrite, rephrase, condense or delete. AI features are opt-in, always
+ disclosed, and every path works with AI switched off.
+- **No dark patterns around loss.** The product nags toward a second device
+ and a reprinted kit because both are real protection — never to manufacture
+ urgency.
+
+## Evidence on Hand
+
+- **Pre-alpha. Nothing to sign up for yet.** There are no customers, no
+ testimonials, no press, no benchmarks and no pricing page in market. None
+ may be invented.
+- 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
+ `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".
+
+## Product Principles
+
+1. **Say the cost where the promise is made.** Trade-offs belong on the front
+ page and beside the button, not in a support article. The recovery rule,
+ the sharing residue and the browser-trust boundary are stated in the copy
+ that precedes the action.
+2. **Never dead-end a person who still has a way through.** Every screen that
+ needs a key offers every rung that can work, and the last one always can.
+3. **The server is a hostile witness.** Design as though the instance is
+ already stolen: nothing it holds may name, rank, group or identify content.
+4. **Sealed by default, everywhere a name could hide.** If a person typed it,
+ it is a secret — titles, URLs, usernames, vault names included.
+5. **Paper is a feature, and it is asked for honestly.** The kit exists so
+ that a chosen passphrase cannot cap how safe a vault can be; the product
+ explains that rather than assuming compliance.
+
+## Accessibility & Inclusion
+
+**WCAG 2.2 AA**, binding.
+
+Two places where that is more than a checklist and design must treat it as a
+product constraint:
+
+- **The printed kit is an accessibility surface.** Contrast, type size and the
+ QR's quiet zone have to survive real paper and a phone camera, at the worst
+ moment somebody will ever use this product.
+- **Recovery paths must not assume a mouse, a camera, or a steady hand.** The
+ device ladder's typing rung is the one that always works, and it self-heals
+ a single mistyped character rather than only rejecting it.