doc: decision 11 — CONTRA joins the Oficina suite
Recorded as a settled decision because it constrains the code: the hostname shape, what the suite may own, and four things this app refuses permanently. The refusals are the load-bearing half — an operator reset, a provisioned account, feeding the suite's memory, or an activity pull would each undo a promise on the front page, and the time to write that down is before the home is real rather than when somebody asks for one. The suite turned out to agree on the three points that could have blocked it: its directory is a blind index with no address column, there is no person lookup and a testing gate asserts it, and assertions carry an opaque person ref rather than an address. None of that was arranged in advance. The mismatch is that the suite rejected full end-to-end encryption and concedes that a compromised credential origin can sign in as anyone. For every other app in the suite that means read everything; here it means a pile of ciphertext, because signing in was never what opens a vault. That is the argument for joining rather than against. One published claim narrows, and the decision says so rather than letting it erode quietly: sign-in stops proving knowledge of the passphrase and starts meaning the home vouches for you. The key layer is untouched — the vault key still comes from the passphrase and the Account Key, and the home never sees either. README.md and PRODUCT.md have to be corrected before this ships. Also the hostname: it is <team>.contra.oficina.works, not contra.oficina.works. A single host would mean one multi-tenant deployment, which is what decision 1 exists to refuse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+74
−0
AGENTS.md+74 −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.436s ? 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 17.804s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ test/sharing.test.mjs test/onepux.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 10.961528 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.474255 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.870159 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.343874 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 1.076938 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.331836 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 52.760518 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 2.918166 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.258266 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 2.070161 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.015666 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.698437 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1811.440218 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: 214.08045 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters PROTOCOL.md pins ok 15 - Argon2id matches the golden vector at the parameters PROTOCOL.md pins --- duration_ms: 2576.403206 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2582.979399 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 17.262006 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.43727 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.777632 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.645061 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.80151 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.608987 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: 11.053198 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.721822 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.743756 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 3.040665 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.090774 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.414593 type: 'test' ... # Subtest: the ZIP reader finds every entry and both compression methods ok 29 - the ZIP reader finds every entry and both compression methods --- duration_ms: 3.965313 type: 'test' ... # Subtest: a file that is not a ZIP is refused with an explanation ok 30 - a file that is not a ZIP is refused with an explanation --- duration_ms: 0.547957 type: 'test' ... # Subtest: a ZIP without export.data is refused rather than half-imported ok 31 - a ZIP without export.data is refused rather than half-imported --- duration_ms: 0.455377 type: 'test' ... # Subtest: a truncated deflate entry is caught by the declared size ok 32 - a truncated deflate entry is caught by the declared size --- duration_ms: 33.992304 type: 'test' ... # Subtest: every item comes across, including the shape that breaks other importers ok 33 - every item comes across, including the shape that breaks other importers --- duration_ms: 4.514446 type: 'test' ... # Subtest: section fields survive, labelled, with concealment preserved ok 34 - section fields survive, labelled, with concealment preserved --- duration_ms: 2.86626 type: 'test' ... # Subtest: an unknown category still imports, and is named in the report ok 35 - an unknown category still imports, and is named in the report --- duration_ms: 3.771207 type: 'test' ... # Subtest: what does not come across is counted, not hidden ok 36 - what does not come across is counted, not hidden --- duration_ms: 2.932291 type: 'test' ... # Subtest: no value in the export goes missing without being reported ok 37 - no value in the export goes missing without being reported --- duration_ms: 1.0859 type: 'test' ... # Subtest: an owner seals a keyset that a different account can open ok 38 - an owner seals a keyset that a different account can open --- duration_ms: 18.688113 type: 'test' ... # Subtest: nobody else can open a member's wrap ok 39 - nobody else can open a member's wrap --- duration_ms: 17.035086 type: 'test' ... # Subtest: a member wrap carries the vault key and not the drop box ok 40 - a member wrap carries the vault key and not the drop box --- duration_ms: 6.3206 type: 'test' ... # Subtest: a member cannot share or rotate ok 41 - a member cannot share or rotate --- duration_ms: 1.828598 type: 'test' ... # Subtest: rotation re-seals every item under the new key ok 42 - rotation re-seals every item under the new key --- duration_ms: 9.886662 type: 'test' ... 1..42 # tests 42 # suites 0 # pass 42 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7354.390576 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md| index d787848..8dff0b8 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -311,6 +311,80 @@ Cheap on purpose — a password manager people cannot afford to keep is a |
| password manager that loses their data. Integer cents; a float never |
| touches an amount. |
| +### 11. CONTRA joins the Oficina suite (Paul, 2026-09-07) |
| + |
| +A password manager is part of running a business, so CONTRA becomes a |
| +suite app: **`<team>.contra.oficina.works`**, one instance per team, the |
| +same convention every other Oficina app follows. Not |
| +`contra.oficina.works` — a single host would mean one multi-tenant |
| +deployment, which is the thing decision 1 exists to refuse. |
| + |
| +The suite's design turned out to agree with this one on the three points |
| +that could have blocked it, none of which was arranged in advance: |
| + |
| +- Its directory is **a blind index over the address**, and the home has |
| + no address column. That is decision 3, arrived at separately. |
| +- **There is no person lookup.** No search, no contact list, and a |
| + testing gate asserting one person's directory never contains another's |
| + rows — 404, never 403. Our own refusal to build an address-to-account |
| + endpoint survives contact with the suite intact. |
| +- Sign-in assertions carry **an opaque person ref and never an address**, |
| + so joining the suite teaches this instance nothing about anybody. |
| + |
| +**The mismatch, and why it argues for joining rather than against.** The |
| +suite chose sealed-at-rest with the key in instance memory and |
| +*explicitly rejected* full end-to-end encryption. Its doctrine then |
| +concedes that a compromised credential origin can sign in as anyone, |
| +owners included, and calls that bound absent rather than dressing it up. |
| +For every other app in the suite that means read everything. Here it |
| +means a pile of ciphertext, because signing in was never what opens a |
| +vault. CONTRA is the one app that degrades gracefully under the failure |
| +the suite admits it cannot bound, and that is the argument for putting it |
| +there. |
| + |
| +**What the suite gets to do.** Own the session — proving who you are to |
| +this instance — and provision the instance itself. The human-drained |
| +queue that creates a per-team instance with its own database and |
| +hostname is already this app's deployment model. |
| + |
| +**What the roster buys.** Sharing today means handing somebody 32 |
| +hexadecimal characters, because we will not build an address lookup. The |
| +team roster, scoped to your own team, lets the share dialog name actual |
| +teammates while the grant stays a keyset sealed to their public key. |
| +Better to use, and no new leak: the sharing graph was already published |
| +residue. |
| + |
| +**What CONTRA refuses, permanently, and states before it joins rather |
| +than when somebody asks:** |
| + |
| +- **No operator reset.** The suite lists "operator reset for a fully |
| + locked-out person" as an open question. Here it is closed and the |
| + answer is no. An operator who could restore a vault is the support |
| + override this product's whole argument rests on not existing. |
| +- **No provisioned accounts.** Provisioning creates an instance, never an |
| + account. Keys are born in the browser from a passphrase and a printed |
| + Account Key; nobody can mint one on somebody's behalf. |
| +- **Nothing feeds the suite's memory.** There is nothing to index — the |
| + server has never seen a title — and an instance that could hand content |
| + to a sibling would not be host-blind. |
| +- **No activity pull.** Same reason. What this instance could truthfully |
| + report about activity is exactly the residue we already publish, and |
| + sending it somewhere else widens it. |
| + |
| +**The claim that has to change wording.** Sign-in stops proving knowledge |
| +of the passphrase and starts meaning the home says you are you. What is |
| +protected does not move — the vault key still comes from the passphrase |
| +and the Account Key, and the home never sees either — but "sign-in is a |
| +signature, not a stored verifier" becomes true of the key layer only. |
| +`README.md` and `PRODUCT.md` say so plainly before this ships, because a |
| +front-page claim that quietly narrows is worse than one that was never |
| +made. |
| + |
| +**Not available yet.** The Oficina home is a deployable skeleton and no |
| +app implements suite sign-in. This decision sets the direction; the lone |
| +shape — this app signing people in itself — stays correct until the home |
| +is real, and must keep working afterwards for anyone self-hosting. |
| + |
| ## Build and test |
| - Go, one static binary, `CGO_ENABLED=0`, thin `main.go` dispatch. |