carlos / contra Public

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>
Paul Campbell pushed by paul@keymail.dev 51585d2e063a87cd28aaf6ae4fd2a34404c5cb80 parent 7885fa4
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.