Multi-user always on: every connection is a signed-in person — Design Spec (→ #2403)
Milestone: Sharing & Pairing — Dead-Simple UX; Engine - Sharing - Accounts, Multi-user & Libraries Manual: TBD — “Signing in on your other devices”, “Letting Claude Code or the CLI use your library”.
Design-led (Testing Constitution). Status: DRAFT rev 2, 2026-10-18 — revised after review 94 (slice order, UDS only, the launch secret, verified peer); awaiting review.
Scope (owner, 2026-10-18): on the same Mac, local clients (the app, the CLI, MCP) connect over the local socket file (UDS) ONLY, never loopback TCP. Loopback/TCP is for the network side (a paired device through Sharing) only. A Linux/Windows server, a web front end and a Mac client/server split are LATER: out of scope, not built here. Written from the owner ruling of 2026-10-18 (
agent-work/status/briefs/multiuser-always-on-2026-10-18.md) and a read of the code onm-sharing-p1. It SUPERSEDES the 2026-10-18 #3331 ruling inlibrary-sharing.md(access enforced only while Multi-user is on): there is no switch any more. Every [GAP] names its(not yet written: …)test. Companion:library-sharing.md(pairing, Sharing switch, transport),transport-http-uds.md(how the app dials its engine).
Intent
Multi-user is not a mode, it is how the engine works. Every request, from the app on the same Mac, from a paired device, from the CLI or from an MCP client, belongs to an authenticated person acting through a named client. There is no ambient owner and loopback proves nothing. The owner never types a password: their Mac holds a private key (synced to their other Apple devices by iCloud Keychain), the engine holds the public half, and signing in is a challenge the app answers silently. Everyone and everything else gets the same shape of credential: a public key registered once, a private key that never leaves its holder.
What the user observes: Settings › Sharing has ONE switch (Sharing). Opening the app on the Mac that holds the library never prompts. Opening it on a second Mac, iPhone or iPad signed into the same iCloud account never prompts either. Claude Code or the CLI can use the library only after the person has said yes once, in the app, to that named client, and Activity says “Claude Code (MCP) for Daniel on MacBook Pro”.
The rulings this rests on (owner, 2026-10-18)
- Multi-user is always on and required; the Multi-user switch is removed (supersedes #3331 and the morning’s routing; #2403 = yes). Stop any work that adds or keeps the toggle.
- No ambient owner; loopback is not the owner.
- The owner signs in passwordless with a Keychain key synced through iCloud Keychain; the engine holds only the public half; a password is the fallback for other people and for non-Apple clients.
- CLI and MCP are audited identities acting for a person, each with its own revocable credential, behind a Settings switch, approved once in the app.
- The owner’s own Mac never prompts.
Prior art (adopt, don’t invent)
- Passkeys / WebAuthn (W3C): the same shape (per-account public key, challenge-response, synced by iCloud
Keychain), but not used here: a passkey is bound to a relying-party domain proven by associated domains, and a
home engine at a
.localname or a tailnet address has none. We use a plain P-256 key with a challenge-response (CryptoKit on Apple,cryptographyin the engine), keptkSecAttrSynchronizablein iCloud Keychain, and NOT in the Secure Enclave (an Enclave key cannot sync). Passkeys are kept for the web client (web-frontend-spec.md, LATER, out of scope here). - SSH public keys (
authorized_keys, agent forwarding): the host stores public keys, one per client, each revocable, each logged. The CLI/MCP credentials follow this. - Xcode’s “Allow external agents” / macOS TCC prompts: a Settings switch plus a first-use approval naming the client; we reuse the idea, not the code.
- Tailscale node approval / Signal linked devices: a new device that lacks the key is approved from one that has it (QR or short code). This is the existing pairing flow, now approving a person’s device.
Model
- Person (
usersrow): has one or more credentials: public keys (kindsyncedordevice), and optionally a password hash (fallback). The owner is the person the library was created for. - Client (new
clientstable):id,person_id,kind(app|device|cli|mcp),name(“Claude Code (MCP)”, “fichero CLI”, “Daniel’s iPhone”),host(machine name),public_key,approved_by(person),created_at,revoked_at. The existingdevicestable becomes thedevicekind (itspaired_by_device_id,paired_routecarry over). - Session: a short-lived token minted after a verified challenge. It carries
(person_id, client_id). EveryActionContext, audit row and Activity line shows both. - Challenge:
POST /api/auth/challenge {client_id}→{nonce, expires_at, engine_id}, 60 s, single use. The client signs a domain-separated message, never the bare nonce:"fichero-sign-in-v1" 0x00 engine_id 0x00 client_id 0x00 nonce 0x00 expires_at. The engine rebuilds the message from ITS OWN id, the client id, the nonce it issued and the expiry it stored, so a signature made for another engine, another client, another purpose or another expiry does not verify (the cross-engine relay: the same synced person key is registered on every engine she belongs to, and a malicious host could otherwise relay one engine’s nonce to her client and sign in there as her; this is why WebAuthn signs the rpId).engine_idis a random id each engine makes once and keeps in its app database.POST /api/auth/sign-in {client_id, nonce, signature}→ session (the signature is base64 of the DER-encoded ECDSA-P256-SHA256 signature, which is CryptoKit’sderRepresentation; failures are counted only for a live client, outstanding challenges are capped at 5 per client). Failure is 401 with a stable code; rate-limited like login today. The reset challenge uses the same shape with the purpose string"fichero-reset-v1".
Transports and the two secrets
- UDS only for local clients. The app, the CLI and MCP reach the engine through its Unix socket (mode 0600, in the
App Group container: see
multiuser.socket-location). The engine identifies the connecting process from the socket’s audit token (LOCAL_PEERTOKEN) and runs the code-signing check on that token, never by mapping a pid to a path afterwards (pids get reused, a time-of-check race); the executable path shown is read from the same token. Loopback TCP serves only the network side (paired devices, who sign in with keys or passwords like anyone else); it carries no local-client routes and no bootstrap. - [OK] (→ #5797) The embedded app hands the secret to its engine on stdin (
EmbeddedBackendService+Spawn.swift:LaunchSecretHandoverwrites one line and closes both pipe ends on every spawn path; the engine reads it only when told to and stdin is a pipe,security/launch_secret.py). Test:fichero-server/tests/unit/security/test_launch_secret_handover_to_spec.py; SwiftLaunchSecretHandoverTests. Slice 10 (sign-in required, ambient owner removed) still waits for the builder’s build of this to be confirmed (manager rule, 2026-10-18). - The launch secret (replaces “the bootstrap token” on the sensitive routes): 32 random bytes made by the app at
each launch, handed to the engine it spawns over an inherited pipe (never an environment variable, argv or a file),
held only in the app’s memory and the engine’s memory. It authorises exactly three routes: first-run,
migrate-owner and the reset challenge. The old bearer-token file the CLI/MCP read today is refused on those
three routes, and a process that did not spawn the engine cannot obtain the secret. Dev Local: the Xcode pre-action
(
fichero-server/scripts/dev-uds-engine.sh) starts the engine on the container socket (~/Library/Containers/app.fichero.fichero/Data/tmp/fichero.sock), so the app did not spawn it. Once an owner exists the Dev Local app needs no launch secret at all: it signs in with the owner’s Keychain key over the UDS like any local client. For FIRST RUN only (no owner yet) the script passes the secret to the engine on stdin and writes it 0600 into the app’s container next to the socket; the app reads it once and deletes it. Until it is deleted any process of the same user can read it: accepted for Debug builds only, and the engine honours the file only whenFICHERO_ENV=development(which the dev script sets) and opens it withO_NOFOLLOWand anfstaton the open descriptor, where the user is also the developer. That file path is compiled out of Release, Beta and App Store builds, which only ever spawn their own engine. Tests start the engine in-process with a fixed test secret. One rule everywhere: the engine takes the secret on stdin at start-up; only Debug has a file. - Session lifetime: app and device sessions last 12 h, sliding; CLI/MCP sessions last 1 h and renew by silent re-sign-in (a challenge costs nothing); a revoked client or person ends at once.
- Keychain access group: one shared group (team id +
app.fichero.shared) holds the owner key for every bundle id (Dev, Alpha, Beta, App Store, iOS), so a person’s key is the same whichever build opens the library.
Behaviors
A. The engine (no ambient owner)
multiuser.always-on— [GAP] (→ #2403) there is no Multi-user setting.sharing.set_multiuser,GET/PUTof the flag,FICHERO_MULTIUSER,multiuserEnabledKeyand_use_multiuser_auth()are removed; the engine always enforces. Test: (not yet written:fichero-server/tests/unit/security/test_multiuser_always_on_to_spec.py::test_no_switch_and_enforcement_is_unconditional).multiuser.loopback-is-not-owner— [GAP] (→ #2403) a request from 127.0.0.1 or the UDS with the bootstrap token and no signed-in client is refused 401 everywhere except the three first-run/sign-in routes._pairing_user’s “loopback + bootstrap = owner” and_require_owner_or_bootstrapgo. Test: (not yet written:…/test_multiuser_always_on_to_spec.py::test_loopback_bootstrap_alone_reaches_nothing). Status today (2026-10-20, S2): the ambient owner is still there and ratcheted, not removed:_pairing_user(api/routes/auth/pairing.py) andaccounts.pyresolve loopback + bootstrap to the owner, and the bootstrap-owner call count is held by the ambient-owner ratchet (a new use fails the count, 44 > 43); the Set Up walk that needs the bootstrap bearer is set aside intest_embedded_set_up_end_to_end_to_spec.py(c13efa580; restore from ec193f84b at slice 10, on the manager’s OK). A PAIRED device is never the ambient owner (S1,_require_ingest_owner). Removing the ambient owner is slice 10.multiuser.sign-in-by-challenge— [GAP] (→ #2403) the challenge-response above; the nonce is single use and expires; a signature over another client’s nonce, a replayed nonce and a revoked client all fail. Test: (not yet written:…/test_challenge_sign_in_to_spec.py).multiuser.signature-is-bound-to-this-engine— [GAP] (→ #2403) the signed message carries the purpose string, the engine id, the client id, the nonce and the expiry (above). A signature made for engine B is refused by engine A; one for another client, another purpose (sign-in vs reset) or a tampered expiry is refused; an expired one is refused even if the nonce is unspent. Test: (not yet written beyond the slice 1 red tests:fichero-server/tests/unit/security/test_challenge_sign_in_to_spec.py::test_a_signature_made_for_another_engine_is_refused,::test_a_signature_over_a_different_expiry_is_refused, and the same pair for the reset challenge in its slice).multiuser.password-fallback— [PARTIAL] (→ #2403) (login exists:POST /api/auth/login, rate-limited) stays for other people and non-Apple clients; a password session is a client of kinddevicenamed by the caller. The owner can switch their own password fallback off. Test: (existingtest_auth_accounts.py; addtest_password_session_names_a_client, not yet written).multiuser.audit-names-person-and-client— [PARTIAL] (→ #5319;ChangeEvent.person/device,BackgroundJob.started_bybuilt) every audit row, change event, background job and Activity line carries person AND client; a CLI call shows “fichero CLI for Ann”. Test: (extendtest_sharing_who_did_it.py; client column not yet written).
B. First launch and the owner’s own Mac
multiuser.turning-on-needs-an-owner-password— [GAP] (#5908, owner finding 2026-10-20) turning Multi-user on leaves NO account anyone can sign in to with a password: the owner made at first launch is a key-only account whose password is a random value nobody knows (first_run), and the people routes refuse while Multi-user is off. Rule: Multi-user cannot be switched on until the owner’s account has a password the owner chose; the switch asks for it first (the owner’s own app still signs in with its Keychain key, the 2026-10-18 ruling, so the password is for signing in from other devices and for people the owner invites). Engine: the owner’s password is a recorded fact (account.owner_password_setin the app settings, set when a password is set on an owner and never for the random one);sharing.set_multiuserwithenabledtrue refuses, with 409 and in words (“Multi-user needs the owner’s account to have a password first: set one, then turn it on”), while no active owner has one; the owner (the signed-in owner’s session;multiuser.owner-session-sets-the-switch) can set the owner’s password throughPATCH /api/users/{id}while Multi-user is still off (every other people route stays refused while it is off); turning it off needs nothing. No new request or response field. App (Swift, HELD): the switch asks for the password (never from argv or logs) and sets it, then turns Multi-user on. The password rule is the engine’s one, applied everywhere a NEW account’s password is chosen or changed (user creation, invite redemption,PATCH /api/users/{id}, so the owner’s chosen password too): at least 8 characters, a 422 in words (## 757, ## 758,sharing.settings.accounts-add-sheet); sign-in is not held to it (an older, shorter password still signs in until it is changed). The app’s field for the owner’s password shows the same hint (NewPasswordRule.hint, “At least 8 characters”); that field is part of the held app step, so the hint is shared today only by the Accounts add sheet. OPEN QUESTION: the account’s username staysownerwith the Mac user’s full name as display name (a rename is a larger change). Tests:fichero-server/tests/unit/security/test_multiuser_needs_owner_password_5908.py.multiuser.owner-session-sets-the-switch— [PARTIAL] (#5908, #5914; manager ruling 2026-10-20) while Multi-user is OFF the engine honours no session, so the two steps that turn it on could be done only with the start-up credential, which the app (signing in with its Keychain key, the 2026-10-18 ruling) does not use for people-things. Rule: while Multi-user is off, the engine honours the owner’s signed-in key session for exactly two calls,PATCH /api/users/{id}(the owner’s password only, an exact path) andPUT /api/pair/multiuser, over loopback or the Unix socket; the start-up credential is REFUSED for both (404 / 403 in words), and so is another person’s session (it is not honoured at all while off). Every other route is unchanged while off (a session is refused). No new request or response field. Built; testsfichero-server/tests/unit/security/test_owner_session_sets_the_switch_5908.py(owner session allowed, start-up key refused with the guard’s own detail, another person refused, nothing else honoured, the allowlist exactly the two calls). The owner’s signed-in session of either kind passes (key or password); with two owners either may set the other’s password (owners are peers). The app’s password step (UsersStore.updateUser) therefore needs the owner’s session while Multi-user is off, which its launch sign-in gives andtest_multiuser_needs_owner_password_5908.py(unit). App half (Swift): the Multi-user migration and the switch now reach the engine as the signed-in owner; a 401 still does not spend the migration’s one try.multiuser.env-without-owner-password-starts-off— [PARTIAL] (#5908, #5910 item 5; engine built, test unrun, the app half is Swift and HELD; bugs’ report 2026-10-20) the embedded app switches Multi-user by restarting its engine withFICHERO_MULTIUSER=1, so the route gate above (sharing.set_multiuserrefuses until the owner chose a password) never runs on the app’s own path. Rule (logic in the engine, one path): the environment value is only a start-up reading of the persisted choice, never the switch itself. An engine started withFICHERO_MULTIUSERon while no owner password was ever chosen starts with Multi-user OFF, says why in words (the healthdependenciesentrymultiuser, no secrets, no paths: “off: the owner has not chosen a password yet”), and never exits (refusing to start would itself kill the engine,sharing.switching-restarts-the-engine-once); once a person chose the password, the same environment turns it on at the next start. No new request or response field (dependenciesis a free-form map). Until the app half lands (#5914), the app’s switch still shows ON while the engine is off for this reason, and the route (PUT /api/pair/multiuser) defers to the environment until the next restart; the engine’s ownmain()assigns the gate flag (an inherited value cannot turn it off). App half (Swift, HELD, #5914): the Multi-user switch calls the route (set_multiuserthrough the generated client) and the engine persists the choice; the environment variable stays only as the start-up reading. Tests:fichero-server/tests/unit/security/test_multiuser_env_without_password_starts_off_5908.py(the helper, and the lifespan reporting it in health) andfichero-server/tests/unit/api/test_engine_main_serves.py::test_main_gates_multiuser_env_without_an_owner_password_and_health_says_why(the app’s ownmain()).multiuser.switch-one-truth— [PARTIAL] (#5914, release bar #5910; fixed in b048cdc25, f8036766b, b57321d8c and engine 57e0f8c55, 634d23d58; test fichero/Tests/Unit/general/Models/MultiuserMigrationTests.swift; the spec tag stays PARTIAL until the switch and the Accounts row are proven together on the M4) the Multi-user switch and the Accounts row disagree when the engine restart fails: the switch bound a local flag and restarted the embedded engine withFICHERO_MULTIUSER, the Accounts row reads the engine, and a failed restart left the switch flipped under a row saying the opposite (only the external-engine path rolled back). Rule: one truth, the engine’s. The switch shows what the engine says (the same value as the Accounts row) and a flip goes to the engine throughPUT /api/pair/multiuserfor every engine, the app’s own included; the app no longer hands its engineFICHERO_MULTIUSER, so Multi-user is the engine’s persisted choice, the route’s owner-password rule (multiuser.turning-on-needs-an-owner-password) applies on the app’s own path, and nothing restarts the engine for it (no restart to fail). A refusal or failure leaves the switch as the engine really is, with the engine’s reason in words (shareError); the app’s local copy of the flag follows the engine after every flip, never the other way round (an install whose old local flag said ON but whose engine never persisted it now shows OFF, the engine’s truth). Swift, HELD (needs compile):ShareSettingsView+StateManagement.swift,EmbeddedBackendService+Spawn.swift,RemoteAccessConfig.launchEnvironment; test:fichero/Tests/Unit/general/Transport/RemoteAccessConfigTests.swift(the launch environment never carriesFICHERO_MULTIUSER). Upgrade (## 609): an install whose old app preference said ON and whose engine never persisted it is turned on ONCE at launch through the same route (MultiuserMigration); a refusal (no owner password chosen yet) is shown to the person once in the Sharing pane and not retried; after launch the app’s local copy of the flag (read by the sidebar and inspector) follows the engine, never the reverse. The environment gate (multiuser.env-without-owner-password-starts-off) is no longer the app’s path: it guards dev servers and any launcher that still sets the variable. Dev Local’s start script pinsFICHERO_MULTIUSER=0, so there the route refuses in words and the switch cannot be tried; the route decides, the app adds nothing. Tests:fichero/Tests/Unit/general/Models/MultiuserMigrationTests.swift. Not built: the password prompt itself (the refusal’s words are shown until it exists).multiuser.first-launch-creates-owner-and-key— [PARTIAL] (→ #2403; engine route built, pinned byfichero-server/tests/unit/security/test_first_run_owner_to_spec.py; the launch secret is set in-process for now: the pipe read at engine start-up, the Keychain key and the app side are the rest of slice 4) on first launch the app creates the owner person (name from the macOS full name, editable later) and a P-256 key in the Keychain (synchronizable,AfterFirstUnlock, no user-presence prompt), then registers the public key throughPOST /api/auth/first-run. That route works ONLY while the library has no owner, only over the UDS, and only with the launch secret (above), never the old token file; once an owner exists it returns 409 forever. No dialog is shown. In the EMBEDDED app the engine has already made a keyless owner row for the app’s first loopback request (Multi-user off:ensure_owner_account), so first-run answers 409 on a fresh install too; the app then puts its key on that row withPOST /api/auth/migrate-ownerand signs in (enginetest_embedded_first_launch_to_spec.py; SwiftAppSignInTests.bootstrapFallsBackToMigrateOwner; a relaunch or engine restart signs in again with the remembered client and the Keychain key, and a client the engine no longer knows is forgotten and registered afresh: enginetest_relaunch_keeps_owner_signed_in_to_spec.py, SwiftAppSignInTests.secondLaunchSignsInOnlyandstaleClientIsForgotten). [GAP] (→ #2403, slice 7) the Keychain still holds the key but the remembered client id is gone (UserDefaults wiped, another bundle id): first-run and migrate-owner both answer 409 and the Mac never signs in by key again; the fix is an engine lookup of the client by public key, built with the second-device registration. Test: enginetest_first_run_owner_to_spec.py; (not yet written: SwiftOwnerKeyBootstrapTests).multiuser.owner-mac-never-prompts— [GAP] (→ #2403) the app signs in as the owner with the Keychain key on every launch and every engine restart; no password sheet, no Touch ID, no “allow” dialog. Test: (not yet written: SwiftOwnerSignInTests::testSignInNeedsNoPromptagainst a fake signer; the real Keychain is the M4 checklist).multiuser.same-key-gets-its-client-back— [GAP] (→ #2403; carried from slice 6c, review 190 HIGH) the Mac can lose its client and keep its key: UserDefaults wiped, a reinstall, another bundle id on the same library, or a restored database that no longer knows the remembered client. Then the app’s path is: the challenge answers 401 → it forgets the client → first-run answers 409 (an owner exists) →POST /api/auth/migrate-ownerwith the owner’s EXISTING public key registers a new client for that key, or returns the client that already holds it (repeats are idempotent). A DIFFERENT key on an owner that already has one is still 409; that loss ismultiuser.lost-everything-on-the-host. This grants nothing new: the caller still signs a challenge with the private key, and the route keeps its UDS + launch-secret gate. Test: (not yet written:…/test_migrate_owner_same_key_to_spec.py: same key registers a client, repeats are idempotent, a different key 409, no secret 401, TCP refused).multiuser.signing-in-is-bounded— [GAP] (→ #2403; carried from slice 6c, review 190 HIGH) “Signing in…” never spins forever. The app tries the whole sign-in path within 20 s of engine-reachable time. An unreachable engine is its own state (“Engine not responding”, with its own backoff) and does not use this budget. When the budget ends, or a step ends in a refusal the app cannot fix, the windows show “Can’t sign in to this library” with the reason in words (the engine’s refusal, never a status code alone) and Try again; once the host reset exists (multiuser.lost-everything-on-the-host), the state also offers it. Activity and Settings › Sharing show the same state. Test: (not yet written: SwiftOwnerSignInStateTests: budget end, each refusal, Try again).multiuser.embedded-and-dev-engines— [GAP] (→ #2403) the embedded engine, the Dev Local engine and tests all use the same sign-in; tests get a helper that mints a test person + client (tests/conftest.py::signed_in_client), so no test relies on ambient owner. Test: (not yet written: the new guardcheck_no_ambient_owner_in_tests).
C. Same person, more devices
multiuser.second-device-by-synced-key— [GAP] (→ #2403) a Mac, iPhone or iPad signed into the same iCloud account already has the person’s key (iCloud Keychain). Pointing it at the library signs in by challenge with no password and no pairing code; the engine registers it as a newdeviceclient named by the device on first success (the key is already trusted, the client row is new). Test: (not yet written:…/test_synced_key_registers_device_client_to_spec.py).multiuser.remote-flow-no-pairing-code— [GAP] (→ #2403) the same person’s other devices (iPhone, iPad, another Mac) reach an engine over the NETWORK transport only (Tailscale, or Bonjour LAN plus the TLS pin), never the UDS. The person’s list of engines (address and TLS pin) syncs through their iCloud (an iCloud Keychain item, or the CloudKit private database), so a new device of theirs needs no pairing code: it reads the list, dials the engine, answers the challenge with the synced key, and gets a session. The engine still records that device (“Daniel’s iPhone”) as adeviceclient, for the audit and for per-device revoke. Test: (not yet written:…/test_synced_key_registers_device_client_to_spec.py; SwiftEngineListSyncTests::testNewDeviceDialsFromTheSyncedListWithoutACode).multiuser.invitee-registered-on-accept— [PARTIAL] (→ #2403; slice 8a: redeem takespublic_key_pem+clientand records the first client; the second and later devices (slice 7) and the app side are not built) an invited person (Ann) has her key registered when she accepts the invite, so all her devices then sign in the same way, each recorded as its own client. (Seemultiuser.invite-registers-public-key.) Test:fichero-server/tests/unit/security/test_invite_registers_public_key_to_spec.py.multiuser.device-without-the-key-is-approved— [PARTIAL] (→ #2403) (pairing codes and QR exist, S8 person picker) a device without the synced key shows “Ask a signed-in device”: the engine creates an approval request, a signed-in device of that person approves it by QR or 6-digit code, and the new device gets its own key registered. Test: (existing pairing tests;test_approval_request_needs_a_signed_in_device, not yet written).multiuser.password-comes-last— [GAP] (→ #2403) the sign-in screen offers, in this order: silent key, approval from another device, password. Test: (not yet written: SwiftSignInOptionsOrderTests).
D. A library shared by another person
multiuser.invite-registers-public-key— [PARTIAL] (→ #2403; slice 8a:POST /api/auth/invites/redeemaccepts a password OR a P-256 key with its client, gives a key-only person an unusable password, refuses neither/bad key/key without client with 422 and keeps the invite; scoped grants and the Swift side are not built) Daniel invites Ann (existing invite). Ann accepts once; her app sends her PERSON public key, which Daniel’s engine stores on a new person row for Ann, scoped by the invite’s grant. No password is ever set unless Ann asks for the fallback. Test:fichero-server/tests/unit/security/test_invite_registers_public_key_to_spec.py.multiuser.person-bound-invite— [PARTIAL] (built, unrun until an M4 pass; → #2403 slice 10; release bar #5910 item 6: two named people pair as themselves; owner’s rulings 2026-10-20) the host owner invites a NAMED person:POST /api/pair/codetakesusernameand the code minted is bound to that person. Library: the one the owner names (library), else the one open in the request (X-Fichero-Library-Path); only that library’s owner may grant on it (403), and a NEW person with no library at all is refused at the mint in words (422), because a role needs a library to apply to. Role: a viewer by default; the owner may pick editor (role), on the library they name (library; only that library’s owner may grant on it, 403 otherwise). The role is for a NEW person: an existing person keeps the role they have and an invitation never changes it; the answer, the owner’s list and the audit row all say the role REALLY granted (an existing person’s current role on that library, or none), never a default that was not applied. A new person redeemed this way can read the library and, as a viewer, cannot write it. Lifetime: an unused invitation lasts 7 days by default; the owner may set days per invitation (expires_in_days, 1 to 3650) ornever_expires; a lifetime on a code that names nobody is refused 422 (that code is still the 10-minute one). Revocable any time, a never-expiring one too:GET /api/pair/invites(a SIGNED-IN owner only, the same rule as the mint: the start-up key is refused 403) lists the owner’s unused invitations (person, new or existing, role, library, who minted it, expiry; never the code, which is named by an id) andDELETE /api/pair/invites/{id}ends one at once (audit rowinvite.revoke; every mint is audited too,invite.mint: the minting owner, the person, new or existing, the role, the library and the expiry, never the code). Sign-in is the link plus a password the person sets: for a NAME WITH NO ACCOUNT the redeem (POST /api/pair) makes the account and takesnew_password(at least 8 characters, else 422 in words), so the owner never sets or sees it and an owner’s session cannot set it for them; the account is made only at the redeem (409 if the name was taken meanwhile); for an EXISTING person the redeem needs their own password (401 otherwise, a wrong one spends nothing). The redeem is atomic: one redeem at a time may spend a code (a claim under a lock); a second redeem of the same code at once is answered 409 in words, and a refused redeem releases the claim and spends nothing; so a code makes at most one device and one account. A client is told what to ask next by a typed response header,X-Fichero-Pair-Next: person-required | choose-password, never by matching the words (the CLI reads only the header). Names are looked up EXACTLY and checked for being taken case-folded: sign-in and authorization resolve a name as it was made (AppDatabase.get_user_by_username), so an older ‘Carol’ and ‘carol’ side by side each sign in as themselves; whether a name is TAKEN (AppDatabase.username_taken, case-folded) is asked only when one is made: a new ‘CAROL’ beside ‘carol’ is refused 409 in words at create, at the invitation (a name differing only in case from a person who exists is not a new person and not that one) and at the redeem. The device is bound to that person and no one else (a redeem naming a different person is 403 and spends nothing); one use, then the code is gone. Only a SIGNED-IN owner may name a person (the ambient start-up key gets 403 in words; the owner may overrule); a disabled person gets 403; naming a person needs Multi-user on (422). A new name is letters, digits,. _ -, up to 64. Security: a 7-day or unlimited link is a long-lived secret, so its code is 16 symbols (80 bits, against 8 for the 10-minute code), single use, revocable, listed with its expiry, and the redeem’s audit row (device.pair_for_person) names the minting owner, the person, whether the account was new, the role, the library and whether it never expires; whoever holds the link before the person does can make the account, so it travels only to them, and the pairing attempts are rate-limited as before. A redeem after Multi-user was turned off is refused as a bad code is and spends nothing. A code that names no person behaves as today (sharing.device-chooses-person). The invitation names a project or a folder in it (#5967,sharing.share-sheet-chooses-what):folder_idon the code request shares one folder of the project instead of all of it (default: the project open when minting; no project is refused, as before); a folder that is not a folder of that project is refused with 422 in words; a folder is shared with a named person only; the person reaches that folder and everything under it, nothing beside it, with the role asked (viewer by default); a new person becomes ascopedmember of the project, an existing person keeps the role they have and gains the folder (a viewer shared a folder to edit writes there only); the folder is in the mint’s audit row, the owner’s list and the redeem’s audit. Test:fichero-server/tests/unit/transport/test_person_bound_invite_5910.py,fichero-server/tests/unit/transport/test_share_scope_5967.py. Built (app, unbuilt; ## 843 wiring): Settings › Sharing lists the unused invitations (who, the role it gives, when it ends or that it never does) with a Revoke on each, which removes that row (PendingInvitesSection,PairInvitesStore;fichero/Tests/Unit/general/Sharing/PairInvitesStoreTests.swift); a revoke is not an Edit › Undo step, because the engine keeps no code to bring a revoked link back and the owner mints a new one. Not built: the invite sheet (Swift: role, lifetime) and the CLI/MCP forms (generated at the regen; API fields on the code request and answer, the invitation list and revoke routes andnew_passwordare HELD for the regen); the person’s first-use screen that asks for the password (Swift); invitations are kept in memory as the 10-minute codes are, so an engine restart ends them, which a 7-day or unlimited invitation makes worth a store (follow-up).multiuser.every-device-of-theirs-opens-it— [GAP] (→ #2403) Ann’s other devices answer the same challenge with the same synced key, so they open Daniel’s library with no password; Daniel’s engine sees one person (Ann) and several clients. Test: (not yet written:…/test_second_device_of_invited_person_to_spec.py).multiuser.revoke-a-person-or-a-client— [PARTIAL] (revoke cascade built: #5351) revoking Ann ends every session and client of hers; revoking one client ends only that one. Test: (extendtest_revoke_cascades_to_spec.py).
E. Local MCP and CLI
multiuser.local-client-switches— [PARTIAL] (→ #2403; engine routes built in slice 6a:GET/PUT /api/auth/local-clients/switches; the Settings switches are Swift, later) Settings › Sharing gains two switches under “This Mac”: Allow local MCP clients and Allow the fichero CLI (default off). While a switch is off the engine answers a client request of that kind with 403 “not allowed on this Mac”. Test:fichero-server/tests/unit/security/test_local_client_request_to_spec.py::test_off_means_refused; (not yet written: SwiftLocalClientSwitchesTests).multiuser.first-connection-asks-the-person— [PARTIAL] (→ #2403; slice 6a built the routes under/api/auth/local-clients/…; slice 6b stampsfichero.peerfrom the socket’s peer credentials and audit token (LOCAL_PEERTOKEN) inapi/uds_peer.py, served by the embedded engine’s UDS server, and reads the executable and signing authority FROM THAT TOKEN through Security.framework, never by mapping a pid to a path; the Dev Localuvicorn --udsengine does not stamp it yet, so local-client requests are refused there; the sheet is Swift; the routes exist on the shared app and the TCP listener answers them 403 (pinned bytest_tcp_listener_refuses_client_routes); at most 3 pending requests per program, 20 in all; a peer whose executable cannot be read from the audit token is refused 403, never recorded nameless; engines started by other launchers (the Dev Localuvicorn --udsscript,fichero-serverrun by hand) do not stamp the peer, so local-client requests are refused there) a client with the switch on callsPOST /api/clients/request {kind, name, host}over the UDS and ONLY the UDS (the TCP listener refuses it with 403: loopback gives no peer credentials). The engine reads the peer from the socket, refuses a different macOS user, records the request together with what it VERIFIED about the peer (pid, executable path, and the signing identity when the binary is signed) and returns a request id. The app’s sheet shows the verified path and signer first and the self-asserted name second, flagging an unsigned binary or a bare interpreter: “python3 (/usr/bin/python3, not signed by Fichero) says it is Claude Code (MCP), for Daniel. Allow / Deny”. Nothing else works until Allow. Test: (not yet written:…/test_client_request_needs_approval_to_spec.pywithtest_tcp_listener_refuses_client_routes,test_request_records_verified_peer_not_claimed_name,test_other_macos_user_refused; SwiftClientApprovalSheetTests, verified path leads and interpreters are flagged).multiuser.client-gets-its-own-credential— [PARTIAL] (→ #2403; engine registration route built in slice 6a; the CLI/MCP side and the Keychain are later) on Allow, the client generates its OWN P-256 key, stores the private half in the login Keychain (item ACL bound to the client binary), and registers the public half against the pending request. No secret crosses the wire. It then signs in by challenge as that client. Stated limit: a Keychain ACL binds to the binary, so for a client that runs under an interpreter (python3,node) any script that interpreter runs can read the credential. The approval is therefore of “this binary on this Mac”, shown that way; the credential is per client, revocable, capped to the person’s rights (optionally read-only), and the CLI and the MCP server ship as signed single binaries so their ACL binds to Fichero’s own signature. Settings lists the client with Revoke; revoking deletes the registration and ends its sessions. Test: (not yet written:…/test_client_registers_own_public_key_to_spec.py; a CLI-sidetest_signs_in_as_client; an MCP-sidetest_signs_in_as_client).multiuser.client-acts-for-a-person— [GAP] (→ #2403) a client’s session carries(person, client); it can do what that person can, never more, and never act as another person. The owner can restrict a client to read-only (optional, slice 6). Test: (not yet written:…/test_client_cannot_exceed_its_person_to_spec.py).-
multiuser.cli-mcp-sign-in-flow— [GAP] (→ #2403) the CLI and MCP server, when they find no credential, print/return a single sentence: “Ask the app to allow this client (Settings › Sharing › This Mac)”, start the request, and wait up to 60 s for the answer. They never fall back to the bootstrap token. Test: (not yet written: a CLI-sidetest_no_credential_asks_the_app). Status today (2026-10-20, S3): the CLI and the MCP server still authenticate with the bootstrap token (which the engine resolves to the ambient owner, S2); the sign-in sentence and the 60 s wait do not exist yet. Both go with slice 10. -
multiuser.local-flow-in-four-steps— [GAP] (→ #2403) the whole local CLI/MCP flow, as ruled: (1) the client connects over the same UDS and is refused while its Settings switch is off; (2) with no credential, the engine identifies the peer from the socket itself (the audit token,LOCAL_PEERTOKEN, gives the uid and the process, and the executable path and code-signing identity are checked on that token, not on a pid), never a self-asserted name, and the app’s approval sheet names that verified program, like Xcode’s agent access; (3) on approval the engine registers the client’s own public key, the private half stays in the client’s Keychain, and Settings lists and revokes it; (4) every later call carries that credential and the audit and Activity read “for “. Test: (not yet written: …/test_local_client_flow_to_spec.py::test_flow_end_to_end_names_client_for_person). multiuser.socket-location— [GAP] (→ #2403) the socket must be reachable by the CLI and MCP without macOS’s “access data from other apps” prompt. Evaluated: (a) the app’s own container (~/Library/Containers/app.fichero.fichero/Data/tmp/, today’s Dev Local path): another process reaching into a sandboxed app’s container is exactly what raises that prompt, on every new client and after updates; (b) an App Group container (~/Library/Group Containers/<group>/, the group entitlement on the app and on the signed CLI and MCP binaries, same team): shared by design, no prompt for members, and a sandboxed app may bind a socket there; its path is long, so the engine binds a short path and the group container holds a symlink or the path is kept under the 104-bytesun_pathlimit; (c) a fixed location outside both (~/Library/Application Support/Fichero/): a sandboxed App Store build cannot create it without a temporary-exception entitlement, so it is rejected. Decision for review: (b), the App Group container, with the hand-started Dev Local engine keeping its container path until the Debug build has the group entitlement. If a prompt still appears on the M4 checklist run, the checklist says to answer it once and slice 6 reopens this line. Test: (not yet written:…/test_socket_path_fits_sun_path_to_spec.py; the prompt itself is an M4 checklist step, it cannot be unit-tested).
F. iOS
multiuser.ios-signs-in-with-the-synced-key— [GAP] (→ #2403) the iPhone/iPad app reads the same synchronizable Keychain key (shared access group where needed), signs in by challenge to a library it has already paired, and shows “Sign in with another device” only when the key has not synced yet (iCloud Keychain off, or the first minutes after enabling). Test: (not yet written: SwiftiOSSignInTestswith a fake signer).multiuser.ios-no-local-engine-no-bootstrap— [PARTIAL] (→ #2403) iOS has no local engine, so there is no bootstrap, no first-run and no local-client approval; it is always a remote device client. Test: n/a (documented here so nobody adds one).
G. Migration of existing libraries and device tokens
multiuser.migrate-owner— [PARTIAL] (→ #2403; route built in slice 5, the app-side call and real-library review remain) a library that predates this change has an owner row (or none, in single-user libraries). On first start of the new engine: if no owner exists, the app’s first-run creates one (B); if one exists with no key, the app registers a key for them through a local migration route that works only for the owner row, only over the UDS, only with the launch secret (the old token file the CLI/MCP read is refused there). The first call registers the owner’s key; after that only the same key is accepted, which registers a new client for it or returns the one holding it (multiuser.same-key-gets-its-client-back); a different key is 409. Not built yet: the same-key repeat (today the route is once-only, as its test pins;multiuser.same-key-gets-its-client-backis the [GAP]). With several owner rows it refuses (409) rather than choose one. A failing device-token migration is logged and never stops the database opening. Test:fichero-server/tests/unit/security/test_migrate_existing_owner_to_spec.py.multiuser.migrate-device-tokens— [PARTIAL] (→ #2403; the migration, the keyless client and revoke-ends-client are built in slice 5; the banner and the replace-on-key-sign-in are slice 7) every non-revokeddevicesrow becomes adeviceclient of itsuser_id(or the owner if NULL) and its token keeps working as that client’s credential for one release, with a banner on the device (“sign in again to keep this device”); the next successful key or password sign-in revokes the old token. Tokens of revoked devices stay revoked. A migration that fails never stops the app database opening;GET /api/healththen carriesdevice_token_migration_error(the error type) as a warning and stayshealthy(#5799). Test:fichero-server/tests/unit/security/test_migrate_device_tokens_to_spec.py.multiuser.migrate-the-flag— [GAP] (→ #2403) the stored multi-user flag and its UserDefaults key are read once to decide nothing and then deleted; libraries that had it OFF now enforce too (this is the point of the ruling). The CLI/MCP clients that used the bootstrap token are told, once, to ask the app. Test: (not yet written:…/test_old_flag_is_ignored_to_spec.py).multiuser.library-copy-keeps-people— [GAP] (→ #2403) copying or opening a library on another Mac never carries keys or sessions; people and their public keys travel, credentials do not. Test: (not yet written:…/test_library_move_carries_no_credentials.py).
H. Lost keys and recovery
multiuser.lost-one-device— [PARTIAL] (→ #2403) revoke the device in Settings from any signed-in device (existing). Nothing else to do. Test: (existing revoke tests).multiuser.lost-all-synced-key— [GAP] (→ #2403) Settings › Sharing offers (never at launch, never forced) a recovery code: 24 words that derive a second owner key, shown once, to be printed or saved. Typing it on a new Mac registers a fresh key and revokes the old. Test: (not yet written:…/test_recovery_code_registers_new_owner_key_to_spec.py).multiuser.lost-everything-on-the-host— [GAP] (→ #2403) on the Mac that hosts the library, the owner can reset their own key with macOS authentication. The ENGINE does not trust the app’s word: at first launch the app also creates a reset key in the Secure Enclave with a user-presence access control (Touch ID or the Mac’s password) and registers its public half; a reset is a challenge from the engine that only that key can sign, and signing makes macOS ask for Touch ID or the password. The engine verifies the signature, the launch secret and the UDS peer, so a process without the user’s presence cannot produce a reset. Old keys and sessions are revoked. Test: (not yet written: SwiftOwnerResetNeedsMacAuthTests;…/test_owner_reset_revokes_old_keys_to_spec.py).multiuser.other-person-lost-key— [GAP] (→ #2403) the owner revokes that person’s key and re-invites them; no admin password reset exists. Test: (covered by revoke + invite tests above).
The trade-off: iCloud Keychain key vs Secure Enclave key
| Synced Keychain key (the ruling) | Secure Enclave key (per device) | |
|---|---|---|
| Second device | Already there; no step | Each device makes its own key; needs approval from a signed-in device (C) |
| Extractability | Private key lives in iCloud Keychain, end-to-end encrypted by Apple, but is copied between devices; compromise of the iCloud account or any one device exposes it | Non-exportable; a stolen disk or backup yields nothing |
| Loss of a device | Revoke the device; the key itself stays valid, so rotate it if the device was compromised | Revoke exactly that device’s key; no rotation |
| Non-Apple clients | Cannot use it; password fallback | Same |
| Setup effort | None | Approval ping per device; breaks “dead simple” |
| Prompts | None | None unless user-presence is required |
Recommendation (for the owner to confirm): ship the synced key as the default for ordinary scholars — it is
the only option that satisfies “second device is easy” with no step. Offer Secure Enclave per-device keys as
an opt-in “Strict” setting (slice 9): turning it on generates Enclave keys, registers them, and revokes the
synced key. Local CLI/MCP clients use plain Keychain keys (never synced; they belong to one Mac). Open question
for review: should Strict be offered at all in the first release? (Cheap to defer; the engine already handles
both kinds because credentials.kind is synced | device.)
What this retires in library-sharing.md
- the behaviors named multiuser-mode-single-source, acl-status-when-multiuser-off and private-by-default
(a single-user paired device is no longer a thing), the “Multi-user” half of
sharing.two-toggles-wording(one switch left: Sharing),sharing.switch-from-cli’sset_multiuserhalf, and the pendingowner_onlydeclaration forsharing.set_multiuser.sharing.device-chooses-personand the S8 person picker stay and now authenticate with a key or a password. - The #3331 ruling of 2026-10-18 is superseded; its test (
test_acl_status_multiuser_off_to_spec.py) is deleted in slice 10, not left to rot.
Implementation plan in slices
Order: this spec → SPEC READY → review → red tests → code, one slice at a time, each slice reviewed for security. Rule of the order (review 94): add every way to sign in FIRST, remove the ambient owner LAST, so the app, Dev Local, the CLI and the MCP operator keep working after every slice.
- Slice 1: tables + challenge: the
clientstable (the person-levelcredentialstable arrives with the slice that registers person keys: slice 4 and 8), the challenge and sign-in routes, sessions carry(person, client). Additive; nothing removed. Contract regen. - Slice 2: audit columns: person AND client on audit rows, change events, background jobs, Activity.
- Slice 3: test-fixture migration: the
signed_in_clienthelper; every existing test moves to it and the new guardcheck_no_ambient_owner_in_testslands in warn mode. - Slice 4: App sign-in (the synchronized Keychain access group the iPhone and Mac share is slice 7’s): first-run route (UDS + launch secret), the Keychain owner key, silent sign-in, the launch-secret pipe in the embedded, Dev Local and test engines.
- Slice 5: Migration: owner key via migrate-owner, device tokens → clients, flag retirement, banner. Reviewed against a copy of Daniel’s real library, never the live one.
- Slice 6: CLI and MCP sign-in: the two switches, UDS-only client request, verified-peer approval sheet, client-owned keys, the no-credential flow, signed single-binary CLI/MCP.
- Slice 7: More devices + iOS: synced-key registration, approval request/QR, sign-in options order.
- Slice 8: Other people: invite registers a public key, revoke person/client, password fallback naming clients.
- Slice 9: Recovery + Strict: recovery code, host reset by the Secure Enclave reset key, optional Strict mode (deferrable).
- Slice 10: No ambient owner (LAST engine slice): remove the Multi-user switch and
sharing.set_multiuser, loopback/bootstrap = owner,_require_owner_or_bootstrap, the old bearer file on owner routes; the guard turns blocking. Only now can anything stop working, and everything has a replacement. - Slice 11: Docs + manual + M4 checklist: user-manual sections; M4 steps for two Macs + iPhone + Claude Code approve/revoke, including the macOS “access data from other apps” prompt the CLI/MCP may hit reaching a socket inside the app’s container (answer it once; the checklist says so); closes the tester items.
Risks to review first: (1) first-run, migrate-owner and reset are the only privileged writes: each once-only for the key (migrate-owner repeats only for the same key),
UDS-only, launch-secret-gated, replay-tested; (2) local-client routes exist only on the UDS listener, with a test
that the TCP listener refuses them; (3) the synced key means an iCloud compromise equals an owner
compromise, which Strict mitigates; (4) every route in security/authz_kinds.py keeps its declaration and no route
may use bootstrap == owner after slice 10.
Test matrix
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Pure rule (Swift) | y | sign-in option order, approval sheet text, switches | fichero/Tests/Unit/general/Sharing/ (OwnerKeyBootstrapTests, OwnerSignInTests, ClientApprovalSheetTests, LocalClientSwitchesTests, SignInOptionsOrderTests) |
| Availability (Swift) | y | iOS sign-in, Settings switches reachable | iOSSignInTests |
| Backend (pytest) | y | challenge, first-run once, no ambient owner, migration, client approval, revoke, recovery | fichero-server/tests/unit/security/, …/transport/ (names above) |
| MCP | y | signs in as a client; no credential asks the app | an MCP-side test_signs_in_as_client |
| CLI | y | same | a CLI-side test_signs_in_as_client, test_no_credential_asks_the_app.py |
| Guard | y | no test relies on ambient owner; no route uses bootstrap as owner | a new guard, check_no_ambient_owner_in_tests (not yet written) |
| Real Mac/iPhone | y | Keychain sync, Touch ID reset, approval round trip | M4 checklist (slice 11) |