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>
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. |