Library Sharing between Macs — Design Spec (→ #5049)
Milestone: Sharing & Pairing — Dead-Simple UX; Engine - Sharing - Accounts, Multi-user & Libraries Manual: TBD — “Working on one library from two Macs”.
Design-led (Testing Constitution). Status: DRAFT, 2026-10-17 (every [GAP] names its
(not yet written: …)test, socheck_specs_have_testsguards it; APPROVED waits for the manual section, becauseManual: TBDblocks it). Written from the rulings in memory (two toggles; accounts are people; loopback +tailscale serve; device pairing; loopback is owner; transport invariants) and a read of the code on integration at 5293d084f. Tags: [OK] built and seen working · [PARTIAL] built, unproven or incomplete · [GAP] not built. “Read, not run” marks what is known only from the code: each becomes OK, or BROKEN with its own issue, on the two-machine test (the Air and the MBP) that follows this draft (#5049). Companion:transport-http-uds.md(how the app dials its engine).
Intent
A scholar with two Macs works on one library from both. One Mac holds the library and runs its engine; the other opens that library over the network and edits it, and each sees the other’s edits as they land. Turning it on is two switches and one pairing step, never a configuration screen. Off the local network it works through Tailscale without exposing anything to the internet. A second PERSON is a separate question (multi-user), answered by accounts, not devices.
The rulings this rests on
- Two toggles, independent (ratified): Sharing (is the engine reachable from another device) and Multi-user (are there accounts for other people). Nothing else in Settings.
- Accounts are for people, not devices. Local use logs in as owner; a remote device always authenticates. Roles: owner, editor, viewer. Libraries are private by default and shared explicitly.
- Transport: HTTPS always, with a self-signed certificate pinned by its public key (SPKI) on
the client. The host reaches its own engine on loopback. Off-network reach is
tailscale serve(neverfunnel); Tailscale is connectivity, not identity. - Loopback with the bootstrap token is always owner.
- Pairing: the host shows a QR / one-time code; the device sends it to
POST /api/pairover the pinned connection and receives its own token, revocable per device. - 2026-10-17: shared editing (a deletion on one Mac is a deletion on the other); no open lookup of libraries; Multi-user sits beside Sharing;
.ts.netis system trust,.localis the SPKI pin; the owner account owns every library. - 2026-10-18 (later, SUPERSEDES the next line): multi-user is always on, no switch, no ambient owner — see
multiuser-always-on.md. - 2026-10-18 (#3331), SUPERSEDED the same day: this entry said access is enforced only while Multi-user is on. The owner then ruled multi-user always on, with no switch (
multiuser-always-on.md). What follows about the switch describes TODAY’s code only, until slice 10 of that plan removes it; nothing new is built on it. - 2026-10-04 (#5351): revoking cascades. Revoking a paired device also revokes the devices it
paired; the revoke sheet lists them first (
sharing.revoke-cascades). - 2026-10-04 (#5319): an audit row names person and device. With Multi-user off both are the
owner, told apart by device; the host’s own edit is never
system(sharing.who-did-it).
Behaviours
A. Turning sharing on (the host)
sharing.toggle-restarts-engine— [PARTIAL] (→ #5049) Settings › Sharing’s switch saves the choice, turns Bonjour on, fills ahttps://<host>.local:8765address, restarts the bundled engine with an HTTPS listener, and lists paired devices (ShareSettingsView+StateManagement.swift). Never seen on two machines (#5049).sharing.listener— [PARTIAL] (→ #5897; owner ruling 2026-10-20: Sharing on listens on this network, by itself) the Mac’s own app always dials its engine over the Unix socket. Sharing off: loopback only, no network listener. Sharing on: the engine listens on this network, with no separate “This network” switch: when the app names the host’s.localaddress (FICHERO_LAN_HOST, set only while Sharing is on at one) the engine binds it beside the loopback port, TLS and a pairing token required, never0.0.0.0(remote_access_tls._bind_host_for_public_host), whatever devices are paired. Tailscale reaches the engine throughtailscale serveto the loopback port (.ts.netis publicly trusted, no pin), so it needs no further listener; when Tailscale is up the sharing sheet offers its address. Built:__main__._listener_hostsbinds the LAN address whenever it is named, the old gate (FICHERO_THIS_NETWORK,_this_network_on,has_lan_paired_device) is deleted; pinned byfichero-server/tests/unit/transport/test_share_on_listeners_to_spec.py(unrun). Built (app, 0519a8379; uncompiled and unrun until the builder): the app no longer passesFICHERO_THIS_NETWORK, and the key,thisNetworkChoiceand the ‘off’ note are gone;ShareOnChoiceTestsscans the app’s Swift for any of them coming back and pins that only a.localaddress names the listener. Still open: seen live on two Macs.sharing.link-names-a-listener— [PARTIAL] (owner ruling 2026-10-20,briefs/pairing-proper-2026-10-20.md; → #5897) a pairing link never names an address nothing listens on. Seen live (M4, build 329fcfb28): Sharing on, the engine started with the Remote Access certificate and bound only127.0.0.1:8765, while the link namedmacbook-air-m4.local:8765(the old “This network” gate had closed the.locallistener,sharing.listener). Sharing on now binds that listener by itself; this is the guard that stays: the engine records the addresses it really bound; minting a pairing code (POST /api/pair/code) for a sharing address that needs a network listener (any address but.ts.net, which Tailscale serves to the loopback port) refuses, in words, with 409, while no non-loopback listener is up (for example the bind failed: “Sharing is on at, but this Mac is not listening there yet…”); a .ts.netlink, and an engine that did not record its binding (a dev or test engine started another way), are not refused. Built (engine): the recording and the refusal, pinned byfichero-server/tests/unit/security/test_link_names_a_listener.py(unrun).sharing.switching-restarts-the-engine-once— [GAP] (#5909; UNPROVEN link to the 2026-10-20 engine death on Sharing + Multi-user) the Sharing and Multi-user switches each stop and start the app’s own engine (ShareSettingsView.applySharing:backendService.stop()thenstart()), while the heartbeat can start one too after seeing it down (EngineLifecycleController.handleSupervisedBackendDropped);EmbeddedBackendService.start()returns silently when a start is already in flight (#3108). A switch can therefore stop the engine another start is waiting on and its own start does nothing: no engine is left, andapplySharinggoes on to the health check as if one were. Rule: a switch restarts the engine once; a start in flight is awaited, never skipped (EmbeddedBackendService.startDisposition); the switch ends only with an engine answering, or says why it has none. Test (red until the fix compiles): held, Swift, 0519a8379 (the start-in-flight tests). Fix is Swift, HELD; the engine log of the failing start (Fichero > Show Log) is wanted to confirm the death.sharing.share-on-choice— [PARTIAL: superseded bysharing.listener; the switch is gone from the app (0519a8379), not yet seen live] (→ #5897, #5736; owner ruling 2026-10-20) there is no “Share on: This network / Tailscale” sub-choice and no separate “This network” switch: Sharing on listens on this network and, when present, Tailscale (sharing.listener). What stays is the safety of the pairing link (sharing.link-names-a-listener) and the tailnet offer on the share sheet. The unset-until-chosen key and the engine gate built for it are removed.sharing.synced-device-registers— [GAP] (#5898, S5, 2026-10-20) a second device that arrives by sync (its library copied, not paired by the link) cannot register itself with the engine, so it never appears among the paired devices and cannot act as its user. Neither the pairing link nor the “Share on” choice (sharing.share-on-choice) fixes it. Test: (not written: a synced device registers once, with the user it belongs to, and never as the owner by fallback). Proposed answer:sharing.synced-device-pairs-by-host-code(option A, awaiting the owner).sharing.synced-device-pairs-by-host-code— [GAP] (#5898; option A of the design inagent-work/status/bugs-ready.md, GO from the manager 2026-10-20; the host half is Swift (HELD for the Pro build), the guest half is r1’s; the engine needs no change for the owner’s OWN other Mac; for ANOTHER person’s Mac the engine must change, marked [GAP, engine] below, and waits for the manager/owner OK: nothing is built for it) a library that arrives by sync (copied, not paired by the link) joins the host the same way any other device does: the HOST issues the existing one-time pairing link, and the guest redeems it. The guest takes the LINK only, never a bare code: a bare code has no pin and no TLS rule, only the copy’s own address, which is untrusted (a library travels through folders other people can read), whereas the link carries its trust with it (sharing.pairing.copy-link-says-why, #5891: a.localhost is pinned, a.ts.nethost is validated by system TLS, plain http gets no link and is refused). No new credential exists and none is ever stored in a library. What the copy carries: only where the host is (its address, the same non-secret fact the pairing link already carries) and which person the library was shared with. Never a token, a secret or a pin that stands in for a credential: a library travels through folders other people can read (sharing.certificate-pinnedpins the certificate, it does not authenticate a person). Guest ask (built, Swift, HELD; r1): when the engine reportssynced_hostfor the open project and this Mac holds no device token for that host, a sheet says once per project “This project is hosted on. Ask for a pairing link.” with one LINK field (a bare code or a link for another Mac is refused in words). Connect redeems via POST /api/pairand stores token + pin + expiry (RemoteClientPairing.persistHostCredentials, the same callpersistPairedHostmakes) without switching this Mac’s engine; wrong, used, expired, person-required, non-https and rate-limited answers each say so in words. Not Now dismisses it for that project. Nothing enforces read-only before connecting, and the text does not claim it. Tests:fichero/Tests/Unit/general/Sharing/SyncedHostAskTests.swift(needs compile). Guest, when: a synced library is opened on a Mac that has no device token for its host. Guest, what it asks: one plain sentence and one field: “This project is hosted on. Ask for a pairing link.” The field takes a pairing link (the same text the host copies), nothing else: a bare code is refused with “Paste the pairing link, not just the code”, and “Connect” redeems the link as POST /api/pairunder the link’s own pin or system-TLS rule. Until it succeeds the library stays as it is on this Mac (nothing here makes it read-only: nothing enforces that, and the entry does not promise it); it never prompts again on every launch (once per library until the person dismisses it or connects). A wrong, used or expired link says which of the three, in words, and offers the field again. Host, when and where: in Settings > Sharing > Devices only (the pairing card that already shows the link for a phone or iPad), worded for the person’s OWN other Mac (“Your other Mac already has a copy of this project? …”), with the link and its expiry (10 minutes,PAIRING_CODE_TTL). The code is single-use. Today it is bound to whoever MINTS it (pairing.pycreate_pairing_code/pair_device), so a link minted on an owner-signed-in host pairs the other Mac AS THE OWNER: right for the owner’s own Mac, wrong for anyone else’s, which is why the share sheet (where the host shares a library with ANOTHER person) shows no such hint. For that other person: with Multi-user on, the guest signs in as a person when it redeems (the existingusername/passwordpath), and a device is never the owner by fallback (sharing.device-acts-as-its-binding). [GAP, engine; needs an owner/manager OK before code] minting a code already bound to ANOTHER person (the one the library was shared with), so the host need not hand that person’s password to anyone, does not exist:POST /api/pair/codemints for its caller only, so this is a new engine capability, and until it is approved the guest signs in as the person at redeem. The new device then appears under Settings > Sharing > Devices, named as the guest named it, and can be removed there. Built (host half, Swift, needs compile): the pairing link on the Settings card (not the share sheet) says, under it, “Your other Mac already has a copy of this project? Paste this link there when it asks to connect.” (PairingLinkCopy.ownOtherMacHint;PairingLinkCopyTestspins the wording and that the share sheet’s own notes (ShareLibrarySheet.linkNotes) never carry it); the link and code are the existing one-time ones. Not in this entry (the owner’s call, option B; if the owner says yes, revoke + expiry + list ship WITH the route that issues the secret): a persistent enrollment secret for the owner’s OWN other Macs, carried by iCloud Keychain, which needs mint, list, revoke and an expiry first (POST /api/pair/enrollexists; nothing mints its secret). Test (to write once approved): a bare code is refused by the guest field; a guest with a synced copy redeems the host’s link and acts as the person it is bound to, never as the owner (engine:test_sharing_probe_2026_10_01.py-style); the guest sheet appears once per library (app: needs compile).sharing.synced-project-names-its-host— [PARTIAL] (#5898, ## 539/542/544; engine writer built, unrun; the guest engine reads and validates it, r1,security/synced_host.py) THE FILE CONTRACT (one text): a project that is shared with a person carries, in its project root (the.ficheropackage directory itself) beside the library, a small NON-SECRET filesharing-host.jsonwith exactly FOUR keys:{"hostURL": "https://<host>:<port>", "hostName": "<the name the person sees for this Mac>", "person": "<the host's person>", "hostEngineId": "<the host engine's stable id>"}.hostURLis anhttpsORIGIN only (scheme, host, port: no path, query, user or password), the same address the pairing link carries; with nohttpspublic address there is no file.hostNameis the Mac’s name as System Settings shows it (scutil --get ComputerName, else the host-name label), CLIPPED to what the guest’s reader accepts (control characters dropped, nothing link-like, 1-200 characters;personis clipped the same way).personis the HOST’s person, the one the guest is told to ask (“Ask Daniel for a pairing link”): the person who shared (else the owner), NEVER the person an invite is for (a project is shared with 2-10 people, and an invitee’s name must not travel in a file).hostEngineIdis the engine id the sign-in challenge already reports asengine_id(accounts._engine_id), not a secret. The file carries NEVER a token, code, pin, secret or account id. Who owns it, and how that is known (## 542/544): ownership is by ENGINE ID, never address: the FIRST engine to share a project records itself (it writes the file when none exists), and from then on only that engine rewrites it (synced_host.is_this_engine(hostEngineId)). The first engine to share owns the file from then on. If two engines DO both write it before a sync has delivered the other’s (two Macs sharing the same project at once), the sync service keeps one under the exact namesharing-host.jsonand sets the other aside as its own conflict copy (a renamed file, for example “sharing-host 2.json”): the reader reads only the exact name, so the conflict copy is ignored and harmless, the file that kept the name decides the owner, the other engine now finds another engine’s id in it and never rewrites it, and the owner can delete the stray copy. A guest’s own engine, which holds the synced copy and may have Sharing on, never writes its own address over the host’s; a file that names another engine, or that this engine cannot read as its own (corrupt, three keys from before engine ids, extra keys), is LEFT as it is, and deleting it is the owner’s call. The guest’s own check uses the same helper. Who writes it, and when (built): the HOST engine, in the share action (POST /api/authz/share,_name_the_host_in_the_project), after the role is granted: whenever a library is shared with a person it writes the file into the library root (fichero_server/sharing_host_file.py), if it owns the project. It writes to a UNIQUELY named temp file in that directory (so two Macs syncing the folder cannot collide on one temp name) and replaces atomically, so a sync client never sees half a file, and it writes only when the text would change, so a second share leaves the file byte-identical. A failure (nohttpsaddress, an unwritable directory) is logged and never raised: the share still succeeds and the guest just has no host to ask. The app is not involved; a guest cannot write it. Guest side (built, Swift part unbuilt; r1): the GUEST engine reads<package path>/sharing-host.json(security/synced_host.py), validates it (exactly the FOUR string keys;hostURLan https origin only; names 1-200 chars and not link-like; 2 KB cap; anything else is logged and ignored) and reports it assynced_hostonGET /api/registry/open, one per open project; the app reads only that field and never the project root. The engine OMITSsynced_hostwhen the file names THIS engine (identity by ENGINE ID, never address:is_this_engine(hostEngineId)/host_to_ask(); Sharing off or a changed address still asks nobody), so the host’s own copy asks nobody. What it costs: the file travels wherever the project is synced, so whoever can read the synced folder learns the host’s name, address and engine id; the address is the same fact the pairing link carries, the engine id is already reported by the sign-in challenge, and none of it grants anything (a connection still needs the one-time link,sharing.synced-device-pairs-by-host-code). Not built: removing the file when sharing is turned off or the last person is unshared (the file then names a host that no longer lets anyone in, which the guest’s ask handles by failing in words), and leaving it out of a published export (an export lists its files; the file is not part of any). Tests:fichero-server/tests/unit/security/test_sharing_host_file_5898.py(unrun): exactly four keys and no secret, and the guest’s own validator accepts what is written; anhttpsorigin only; a rewrite with nothing changed leaves the file alone; the owning engine rewrites on a change; a GUEST engine never overwrites another engine’s file; a file this engine cannot read as its own is left alone; the first engine to share records itself; nohttpsaddress writes nothing; the temp name is unique and none is left; an unwritable root logs and does not raise; the host name is the name the person sees; a long, link-like or control-character name is clipped so the guest’s validator still accepts the file; the share helper names the sharer. Guest side:fichero-server/tests/unit/security/test_synced_host_file_5898.py.sharing.person-bound-invite— [PROPOSED] (#2403 slice 10 brought forward; #5902 case 2, release-blocking: a shared server on the owner’s tailnet used by 2-10 people, each with their own account and library access; #5898; owner via manager 2026-10-20; SPEC ONLY, no code, and the engine change needs the manager/owner OK before any is written; it adds routes and response fields, so an OpenAPI regen follows, HELD) the owner can give a NEW person access to ONE project in a single step that cannot make a device the owner by mistake. This is the engine halfsharing.synced-device-pairs-by-host-codemarks[GAP, engine]. Who it is for (ruled 2026-10-20): a person who has NO account yet. The route makes a NEW account only and REFUSES an existing username, in words (“Maya already has an account: share the project with her instead”). A person who already has an account gets a library through the existing share (POST /api/authz/share, a role grant) and pairs their own Mac as themselves (sharing.device-chooses-person); no invite link ever binds a device to an account that already exists, because a device is bound to an ACCOUNT, not a library (_authenticate_devicereadsdevice.user_id), so it would act as that person in every library where they hold a role. Flow: in the share sheet the owner NAMES the new person (and picks a role). In that one step the engine (1) makes the account, (2) grants the role on that ONE library, (3) writes the invite row (below) and (4) the sheet shows the pairing LINK (the guest takes the link only:sharing.synced-device-pairs-by-host-code; trust comes from the link, #5891:.localpinned,.ts.netsystem TLS, plain http refused). The person opens the link on their Mac; the device that redeems it is bound to THEM and acts only as them (sharing.device-acts-as-its-binding, S1). Refused at MINT, not only at redeem: a role ofowner(VALID_ROLESallows it; an invite does not), and any name that is an owner or admin account, including the owner’s own (is_owner), so no invite can ever produce an owner device. The refusal says which of the two it is. The invite row (app DB, not memory): a person-bound invite lives for hours or days and must survive an engine restart and be listed and revoked, so it is a row in the app database, NOT the in-memory_PAIRING_CODESdict (that stays for the 10-minute own-Mac pairing code only,PAIRING_CODE_TTL). The row holds a HASH of the code, never the code (a database read must not leak a live bearer; the code is shown once, in the link), the invited account, the one library, the role, who made it, created and expiry times, and a state:pending->used|revoked|expired. Every change of state is ONE compare-and-set (UPDATE … SET state = <new> WHERE id = ? AND state = 'pending', and only the caller whose update changed a row proceeds), so a redeem and a revoke cannot both win: whichever commits first decides, the other gets nothing and does nothing. Redeem pairs the device only after it winspending -> used; revoke removes the made account only after it winspending -> revoked. The compare-and-set, the account’s activation and the device row are ONE database transaction (all three or none), so a crash between them leaves a pending invite, never a used invite with no device or an active account with no invite. The redeem request carries only the code and the device name;username/passwordonPOST /api/pairare IGNORED for an invite code, and the role is fixed when the invite is made, never read from the redeem. Access to any OTHER library is separate: the account has a role only where the owner granted one (library_roles), so scoping to one project is the grant, not the token. The made account cannot sign in until the invite is redeemed: it is created with NO password (or an unusable hash) and inactive for every sign-in path: the username/password login, a session, and the redeem’susernamepath. A pending invite therefore leaves a guessable username with no credential behind it. It becomes active only when the redeem winspending -> used, in the same step as the device it pairs. (The account then has no password and only that Mac’s device token, so a lost Mac is a revoke, not a password reset; whether the person may set a password is the owner’s open question 3.) Expiry, list, revoke (all ship WITH the route that issues the invite, never after): an invite expires (a longer life than the own-Mac code’s 10 minutes is proposed, because the link is sent by message: open question 2). Expiry is swept ON READ ONLY (every list and every redeem checksexpires_atand movespending -> expiredby the same compare-and-set), with no timer and no background sweep: an expired invite nobody reads stayspendingin its row until the next list or redeem, and its made account stays (inactive, no credential) until then. That costs nothing, because every path that could use the invite goes through the sweep first. The owner lists invites in Settings > Sharing > People: pending (person, role, expires), used (which device, when), expired, revoked. One guard removes the made account whenever an invite ends unused (revoked OR expired): the account is removed only if this invite made it, it is still unredeemed, and it has no other grant and no device. Revoke after use removes the person’s role on that library and revokes the devices that invite made (the existing device revoke), so their Mac stops being let in; it says so in words (“Maya’s Mac can no longer connect”). An invite never leaves an account behind. Threat notes (what someone holding the link can and cannot do):- CAN, once, before it expires or is revoked: redeem it and become THAT new person with THAT role on THAT library. The link is a bearer: an attacker who reads it before the person does gets their access. Mitigations built into the shape: one use (one compare-and-set, so two simultaneous redeems yield one device and one refusal), a limited life, the list shows “used by
at - CANNOT: pick another person or the owner (the redeem names neither, and the account was new); raise the role (fixed at mint,
ownerrefused); reach another library (no grant there, and the account is new); act as the owner by fallback (S1: an owner session needs the Keychain key, a paired device never has it); replay it; outlive expiry or revoke (the state is checked and changed in one step); learn anything from the link beyond the host’s address and the pin the link already carries (the invited person’s name is not in the link). - One answer to a caller: a used, expired, revoked and unknown code all answer the same 401 as today (“invalid or expired pairing code”), so a guesser learns nothing. Only the OWNER’S list says which (“already used”, “revoked”, “expired”).
- Brute force: a code is 8 characters from 32 symbols (about 40 bits) and
POST /api/pairallows 5 tries a minute per rate-limit scope (PAIRING_RATE_LIMIT,PAIRING_RATE_WINDOW); that is far out of reach for a guesser even over a week. The scope (_rate_limit_scope_from_request) is the device, session or user when there is one, else, behindtailscale servewhere every caller arrives from loopback, the proxy’s Tailscale identity header (trusted only on loopback, #5320, #5350), so each person on the tailnet has their own bucket and one person cannot lock pairing out for the others; a caller with no identity at all on loopback shares one bucket. A link is pasted, not typed, so a longer code for a multi-day invite is a free choice (open question 4). - The owner is never minted: no code, token or session this flow makes is the owner’s, and no step reads the host’s signed-in person as the invitee. The own-Mac link (
create_pairing_code, which binds to whoever mints it and so pairs the owner’s OWN Mac as the owner) stays on the Settings card for the owner’s own devices only. - Audit (
one-audited-action-layer): mint, revoke and the removal of the made account or role are the OWNER’s actions (the owner is the actor); the REDEEM is the new device’s own action (its actor is the invited account and the device, with the invite’s id), so Activity can say who invited whom and who accepted. OPEN QUESTIONS for the owner: (1) the DEFAULT role when he names a person:editor(can read, correct and add) orviewer(read only); the sheet always shows the role and lets him change it, but the default is his to set. (2) The invite’s life: a day, a week? (3) Does the invited person set a password on first use, or has the account none and only that Mac’s device token? (Recommended: none; a lost Mac is a revoke.) (4) A longer code (for example 12 characters) for multi-day invites? Tests to write once approved (engine, red first; #5902 asks for a test per case with FAKE devices: case 1, one person’s Macs and iPhones tied to one account, and case 2, 2-10 people on one tailnet server, each with their own account and only the libraries they were given; a live check with two real people on the tailnet comes before release): an invite for an existing username is refused; an invite for the owner or an admin account, or for the roleowner, is refused at mint; the made, unredeemed account cannot sign in by password, session or the redeem’susernamepath; an invite redeemed once pairs a device bound to the invited account with exactly the granted role on that one library; a second redeem, an expired invite and a revoked invite all answer the same 401;username/passwordon the redeem are ignored; a redeem and a revoke raced against each other produce exactly one winner, a device bound to a live account or a removed account with no device, never a device bound to a deleted account; the row holds a hash and the list never returns the code; expiry on read movespending -> expiredand removes the made account only by the one guard; revoke after use removes the role and the device; a non-owner cannot mint, list or revoke; the redeemed device cannot read another library (test_device_acts_as_its_binding-style); two people behind onetailscale servedo not share a rate-limit bucket. App (Swift, needs compile): the sheet names the person and role, shows the link, and lists pending and used invites. sharing.certificate-pinned— [PARTIAL] (→ #5049) a self-signed certificate is made once (--prepare-remote-access), names the host, 127.0.0.1 and localhost, and the app records its pin per host. Fixed by #5041, unproven across machines (#5049).sharing.bonjour-advertises— [OK] (→ #5316; pinned byfichero-server/tests/unit/security/test_discovery.py::test_sharing_advertises_the_lan_listener_never_loopback) the engine advertises_fichero._tcpwith the public URL in its record and no pin (presence, not trust). The record’s address is the one the LAN listener binds, under the.localname the invite uses; with no LAN listener (loopback only, e.g. Tailscale) it carries no address. Run 2026-10-01 on the MBP: it advertised 127.0.0.1 underUNB-C02F45GAQ05P.localbeforeedd6c4938, and131.202.228.23undermacbook-pro-m1.localafter. Where mDNS does not cross between machines (the campus network) nobody sees it: that is the network.sharing.tailscale-serve— [PARTIAL] (→ #2603, #5311, #5320; pinned bytests/unit/security/test_tailscale_serve.py,tests/unit/security/test_remote_access_tls.py::test_a_tailscale_address_binds_loopback_and_its_certificate_names_it) with a.ts.netsharing address the engine binds loopback only and makestailscale serve --https=<port> https+insecure://127.0.0.1:<port>at start and removes it at stop (only the forward it made; a port serving something else is refused). Tailscale terminates TLS with its own public certificate, so the client trusts the system certificate for.ts.net(Tailscale identity plus the pairing token protect it) and keeps the SPKI pin only for.localhosts (ruled 2026-10-17). Proxied requests carry Tailscale headers and are never loopback (api/auth.py). Pairing codes carry the address astailnet_url. #5752/#5753 fixed: no forward for a socket-only engine; a.ts.netinvite carries no pin (test_tailnet_invite_has_no_pin_to_spec.py). Still open: an app pairing over the tailnet (#5320, needs the M4); the sandboxed app cannot run the Tailscale CLI, so it shows one command to paste with a copy button (#5317); the address is offered in the share sheet, not typed (#5318).sharing.invite— [PARTIAL] (→ #5049) the pairing card’s QR / link carries the address, a one-time code, the pin and ONE library path (the host’s current library).sharing.pairing.copy-link-says-why— [PARTIAL] (#5891; owner, 2026-10-20 07:40: “Copy Pairing Link” did nothing on the host) the pairing card never shows a code with neither a pairing link nor a reason. When the link cannot be built (no pin derived yet for this address, an address that does not work) the card says why in words, with the cure where the app can do it (the blocker’s own headline and detail: “Preparing the security certificate …”), instead of “use the pairing link below” over nothing; the blocker used to be hidden whenever a one-time code existed. Copy Pairing Link reads the clipboard back and says “Fichero couldn’t copy the link to the clipboard” when it did not take. The link carries the pin of an address that serves its own certificate (.local); an address a public CA terminates (.ts.netbehindtailscale serve) gets a link with NO pin, which the guest checks with normal TLS validation (system trust + hostname match) and the pairing code still grants trust; plain http gets no link at all, with the reason in words: never a link with no pin and no TLS check. A missing pin is made by the button “Make a Pairing Link” on the blocker, which says it restarts sharing (and so stops work in flight); never automatically when the pane opens; on an engine this app did not start (Dev Local) there is no button, and the blocker says in words that a restart there cannot mint a pin. Pinned byfichero/Tests/Unit/general/Sharing/PairingLinkCopyTests.swift(unrun: [PARTIAL] until compiled and tried on the owner’s Pro).
B. Connecting from the second Mac
sharing.mac-finds-host— [PARTIAL] (→ #5734, #5892; owner’s two-Mac test 2026-10-20, which overrules the manager’s “no host picker” of 2026-10-17) Settings › Sharing › “Connect This Mac to Another Fichero” lists the Fichero Macs nearby, found by browsing Bonjour (_fichero._tcp,NearbyHosts.swift), without this Mac and without a host that has no secure address (HostChoices.offered); with none found it says so and the invite link still works. A.ts.nethost is listed only when it is in THIS Mac’s own tailnet: its suffix equals the Mac’s own MagicDNS suffix, read from Tailscale by the engine (/api/pair/tailnet), never from Bonjour; a host in another tailnet is not listed, and when this Mac’s suffix is unknown no.ts.nethost is (the Bonjour record is unauthenticated, so a name on it proves nothing: an impostor could advertise the owner’s Mac name with its own tailnet address and take the code). Picking a host starts pairing with it, showing “Connects to” before anything is sent: a listed .ts.nethost asks for the pairing code shown on that host, certified by a public authority and checked by the system’s trust and the host name (no pin, no bypass; http is refused); a.localhost asks for its invite link, because the invite carries the key to pin, which Bonjour never does, and an invite that points at another host than the one picked is refused in words. There is still no open lookup of libraries: the list is hosts, never their libraries, and the pairing code grants trust. The drawer shows the host being connected to, read-only. Tests:HostChoicesTests.swift(the pure rules); built, uncompiled here, unrun. Not built: the camera/QR scan on a Mac. The Mac drawer says nothing about scanning (read 2026-10-20:MacRemoteClientPairingSection.swifthas no QR wording; the iOS entry has its own). Open: nobody has run the list on two Macs; the owner’s 2026-10-20 build (329fcfb28) predates it, which is the likely reason it showed none (#5892, to confirm on a new build).sharing.connect-section-always-shown— [PARTIAL: superseded bysharing.settings.connect-sheet] (#5893, owner’s test 2026-10-20) the “Connect This Mac to Another Fichero” section is on the Devices pane whether or not Sharing is on: connecting to another Mac does not need this Mac to share (ShareSettingsView.swift: outside thehostingEnabledbranch, and placed right under the Sharing switch and its pairing card, above Connected Devices, Connected Clients and Advanced, because a guest Mac needs it first and the pane is long; found by reading, no test can show a view’s place). Swift, not built or run.sharing.mac-pairs— [PARTIAL] (→ #5049, read, not run) pairing sends the code toPOST /api/pairwith the pin enforced, checks health, and stores the token in the Keychain with its expiry, the pin, the host and the library path (RemoteClientPairingFlow.swift). Never run between two Macs (#5049).sharing.library-confirmed— [PARTIAL] (#3273; pinned byfichero/Tests/Unit/general/Transport/RemoteClientPairingLibraryConfirmTests.swift,RemoteClientPairingFlow.confirmLibraryAccess) before opening, the Mac asks the host whether it may open the invite’s library. Swift tests run only on the M4 (#4511); issue stays open with needs-your-test.sharing.guest-addresses-the-hosts-library— [PARTIAL] (#5965; owner live 2026-10-20: a paired iPhone showed nothing and every request was refused “Rejected library path”) a paired guest, Mac or iPhone, addresses a library the HOST serves and never sends its own local path to a remote host. The host’s library comes from the pairing (the invite’s library path); when the pairing carried none (a code-only pairing with a nearby Mac, an older invite), the guest asks the host which libraries its credential may open (GET /api/authz/libraries, app-wide, filtered to the credential) and adopts the host’s. With several, the guest takes the host’s global one, else the most recently used, WITHOUT asking (a choice for the owner to rule on later). When the host serves none, or cannot be asked (not signed in, a host too old to list libraries, unreachable), no library of the guest’s own stays current and the guest says why in words (GuestLibraryChoice.problemWords); it sends no library header. One code path for the Mac guest and the iPhone (LibraryManager.adoptHostLibraryWhenPairingCarriedNone). Dropping the guest’s own libraries never makes another LOCAL library current: only a library of the host, else none. KNOWN, older than this line: because the refusal is made before authentication, whether a given path is one this engine may open can still be told from its answer (a path oracle on the library header); this line does not change it. The host’s refusal of a path it does not serve is made before authentication, so it NAMES NO LIBRARY of the host: it says the path is not one this engine serves and, for a.ficheropath, to ask the connected Mac for its libraries after signing in. Tests (unrun, M4):fichero-server/tests/unit/api/test_rejected_library_path_names_no_library_5965.py, the two-enginefichero-server/tests/integration/test_sharing_release_bar_5911.py(an unauthenticated caller and a paired guest sending their own path get a 403 that names no host library), and the Swiftfichero/Tests/Unit/general/Models/GuestLibraryChoiceTests.swift(the app half,GuestLibraryChoiceandLibraryManager, needs compile).sharing.opens-the-library— [PARTIAL] (→ #5049, read, not run) the second Mac switches its active host to the first, adopts the invite’s library and lists the host’s libraries from/api/authz/libraries; the library path header names the path ON THE HOST. One host at a time for the whole app (#2573).sharing.share-sheet-chooses-what— [GAP] (#5967; owner ruling 2026-10-20; release bar #5910) the owner, on any of the owner’s devices, sees everything; another person sees only what the owner shared with them. The owner shares a project, or a folder in it, from a Share… sheet on that project or on that folder’s row: the people it is shared with and each one’s role (viewer by default, or editor). Today grants are per project (library roles:sharing.settings.project-share-sheet,multiuser.person-bound-invite); the FOLDER grant is new: a grant names a project and optionally one folder in it, a person-bound invite names the same target (a project, or a folder in it), and the engine checks every request against the person’s grants by the item’s folder ancestry, so a folder grant covers that folder and everything under it and nothing beside it. A list shows only what they may open, and every aggregate surface is grant-filtered too: search, entities, chat retrieval (RAG), exports and Activity; a read or write outside their grants is refused in words that name nothing they may not see. Test (to write): with two engines, a person invited to one folder of a project lists and reads only that folder’s items, is refused a sibling folder and the rest of the project (the refusal names nothing), and a viewer cannot write; a search by that person returns nothing from outside the folder; the owner’s own device still sees everything.sharing.pairing-over-uds— [PARTIAL] (#4224) pairing now dials with the app’s resolved transport (PairingTypes.swift, fixed in 734d16f56, read, not run); unproven in a Release build whose engine is on the socket, where the issue says sharing never worked. The class is guarded:scripts/check_swift_transport.pyrule 5 fails anyFicheroClient(baseURL:)that names notransportMode:,session:,transport:orexpectedSPKIPin:(one call inAppState+Heartbeat.swift, the failover probe for a different host, is the one sanctioned exception), pinned byfichero-server/tests/unit/scripts/test_check_swift_transport.py. Test:fichero/Tests/Unit/general/Transport/PairingTransportTests.swift(Swift, not run) and the checklist step 1. Open: whether a Release-embedded build ever shared before this fix is not known; only a run on a Release build answers it.sharing.cli-pairs-a-device— [PARTIAL] (#5907; owner 2026-10-20: no key copying; built, unrun until an M4 pass; testsfichero-cli/tests/test_cli_pairs_a_device_5907.py)fichero devices pair <link>pairs the CLI as a revocable device of a host, the way a phone does. The link is afichero://pair?payload=…text, or a file holding it (--link-file, so it never sits in shell history; a link on argv is accepted, with a warning on stderr that names shell history and never echoes the link). Trust follows the app’s rules (#5891): a.ts.nethost is system-TLS and carries no pin; any other host must carry a pin and its served certificate must match it, else refused in words before any code is sent; plainhttpis refused whatever the host. It redeems withPOST /api/pairat the link’s host, or over the local engine socket (FICHERO_UDS) when run on the host itself. It sends no username or password; only when the host answers “person required” does it ask for them at the terminal (never argv or env), and with no terminal it stops and says so. The device token is stored per host (owner-only file in the CLI’s config dir, keyed by the engine, with the pin) and used for that host after a signed-in person’s session: on the app socket or loopback the owner’s own key still wins, and for a remote host this Mac’s own key never outranks the host’s device token; the app’s key file is never sent to a remote host, and with no credential at all the CLI says to pair first (fichero devices pair --link-file …); a key the person set inFICHERO_API_KEYis their intent and may go to the host named with it; if the pinned certificate has expired or been replaced the pin no longer matches and the CLI refuses in words (“does not match the pin it was paired with”) until the host sends a new link anddevices pairis run again; it is never printed.fichero devices forget <host>drops the local token (the host’sdevices revokeends the device). MCP: the generatedfichero_pairing_pair_devicetakes a bare code and would return the token in tool output, so a hand tool (fichero_devices_pair, link file in, device id and host out, token stored not returned) is the agent’s way. Not built: a TOFU fingerprint for a.localhost with no pin.sharing.share-sheet-chooses-what— [PARTIAL] (#5967; owner ruling 2026-10-20; release bar #5910) the owner and his own paired devices see everything; another person sees only what the owner shared with them. He shares a project, or a folder in it, from a Share… sheet on that project or folder: the people it is shared with and each one’s role (viewer by default, or editor); this is where per-project roles live (sharing.settings.project-share-sheet), and a person-bound invitation is an invitation to that chosen project or folder (multiuser.person-bound-invite). The engine checks the person’s scope on every request: a list shows only what they may open, and a read or write outside their grants is refused in words that name nothing they may not see. A grant is (person, project, optional folder, role). Built, unrun until an M4 pass (engine half): a scoped person’s read of the library as a whole (no id named) is refused unless the route is onauthz.SCOPED_LIBRARY_READ_ROUTES, the routes that filter every row throughreadable_rows/readable_documents, so a new route is closed until listed (fail closed, review 704); an invitation of a person who already holds the whole project as editor, or as viewer to view, is refused at mint in words (“already has more access”), no second grant; a project grant is the library role (viewer,editor); a folder grant is ascopedrole on the project plus an ACL override on the folder (grant: read;edit: read and write), checked on every request by the item’s ancestry (authz._allowed,_matching_override_effect): a folder covers itself and everything under it, nothing beside it, so a sibling folder, the pages loose in the project and every other id are refused; the project itself opens to read for a scoped person (lists are filtered per document,readable_documents) and takes no write at project level;authz.grant_folder(owner only) and the invitation’sfolder_idwrite the grant, and the refusal says “read access denied” / “write access denied”, naming nothing. Not built: the Share… sheet (Swift, with the Settings work), the API fields and routes for sharing a folder without an invitation and for listing and revoking folder grants (the invitation fields are HELD for the regen), per-row filtering of library-wide rows that are not documents (entities, claims, the knowledge graph, search, chat retrieval, exports, counts, Activity): they are REFUSED, not filtered, so a person shared a folder cannot use them yet (the app shows them nothing); routes that open the library without the shared dependency orassert_library_read_authorized(ingest/core.pybackground reads,document/transfer.py) are not covered by this check and are to be walked and closed; the health probe gives a scoped person the general answer, no counts; the library-wide event streams (Activity, changes) are refused them; a workflow run’s event stream is checked against the library the run was started in (found from its thread id, not from a header the caller may omit), so a person shared a folder, a member of another project and a caller naming another library are all refused; a batch’s stream is the response of the request that started it (a library-level write, refused them) and cannot be joined later; and a person shared a folder to edit cannot start a run over the project (every library-level write route is refused them); a run they start by naming an item inside their grants is checked at that item only, and what such a job expands to beyond the item named (an embedding, knowledge-graph or language pass that walks the project) is not walked job by job; and the two-engine run of the whole path on the M4. Test:fichero-server/tests/unit/transport/test_share_scope_5967.py(a person invited to one folder lists and reads only that folder’s items and what is under it, is refused a sibling folder and the rest of the project with words that name nothing, a viewer cannot write, an editor writes only there, the owner’s own people still see everything; a word only in a sibling folder is not reachable through search; every library-level route not on the list refuses a scoped person, walked from the route table; an item moved out of the folder disappears for them; the event streams refuse them; no library-level write route lets a scoped editor through).sharing.rename-reaches-the-other-side— [PARTIAL] (#5911; release bar #5910 item 3: a rename on one Mac shows on the other, both ways; built, unrun until an M4 pass; CLI half, testsfichero-server/tests/unit/transport/test_guest_cli_reads_the_hosts_rename_5911.py) a guest Mac’s CLI, paired withfichero devices pairand holding only its device token, reads the name the host gave a document, and a rename the guest makes with the CLI is the name the host then reads. The code is minted for a NAMED person (multiuser.person-bound-invite), so the guest acts as that person: an editor’s rename is audited as the editor and the guest device (never the owner who minted the code), a viewer’s rename is refused 403, and every guest request carries the stored device token and nothing else. The pairing in the test is the real command’s code path (pairing.pair, the link’s pin checked) against the host engine in process, the host’s own side signed in as the owner (no ambient key). The HTTP-level half (stand-in pairing, LAN listener) is bugs’ (test, when built:test_sharing_release_bar_5911, under the integration tests). Not built: the same through two real engines on two Macs (the owner’s live try).sharing.cli-pairs-over-tailscale— [PARTIAL] (#5907; release bar #5910 item 2: this Mac pairs over Tailscale; built, unrun until an M4 pass; testsfichero-cli/tests/test_cli_pairs_over_tailscale_5907.py)fichero devices pairagainst a.ts.netlink needs no pin: the link carries none (#5753), a pin a link does carry for a.ts.nethost is discarded rather than trusted, the host’s certificate is the system’s to check (no certificate is read or pinned), and the stored device record holds no pin, so later calls use system trust too.httpfor a.ts.netname is refused. The token is stored for that host, used for it, and never printed. The link is the trust root over Tailscale: any.ts.netname the link names is accepted on the system’s trust, whichever tailnet it belongs to (the CLI has no own-tailnet check; the guest app’s Bonjour list does,sharing.mac-finds-host), so the link must come from the host’s owner. The match is on the host name’s suffix.ts.netexactly: look-alikes (evil.ts.net.example.com,evilts.net, a trailing-dothost.ts.net.) are NOT public-CA hosts and need the pin like any other. The port is the link’s own (443 when it names none) and plays no part in that rule. The host half (the engine accepting the redemption throughtailscale serve) is the engine harness’s test, not this one.
C. Working on it from both
sharing.edits-both-ways— [PARTIAL] (→ #5049) an edit on either Mac lands in the one library and appears on the other through the change stream (/api/changes, read-authorised per library). Run 2026-10-01 engine to engine (CLI, over Tailscale): notes written on each Mac read on the other; the stream lost an event when the host’s loop stalled past its keepalive, fixed in #5314 (test_an_event_queued_while_the_loop_stalls_past_the_keepalive_is_still_sent). The app’s windows were not run.sharing.who-did-it— [OK] (→ #5319; pinned byfichero-server/tests/unit/security/test_sharing_probe_2026_10_01.py::test_pin_who_did_it_*andfichero-server/tests/integration/test_sharing_who_did_it.py::test_the_host_and_a_paired_mac_are_told_apart_in_the_audit) each edit’s audit row names both the person AND the device that made it. With Multi-user off both people are the owner, each row naming its device; the host’s own edit is neversystem(ruled 2026-10-04). Fixed in 6b6115612 (engine, in-process). Not yet seen across two real Macs: #5319 stays open with needs-your-test until the checklist passes.sharing.revoke— [PARTIAL] (→ #5049) the host lists paired devices and can revoke one; a revoked token is refused (pinned intest_device_pairing_e2e.py, in-process; run 2026-10-01 across two Macs: the paired MBP’s next call was 401). The app’s device list was not run.sharing.revoke-closes-the-stream— [PARTIAL] (#5913, built; test unrun: [OK] when the M4 is green; found writing release-bar item 4, #5910/#5911) revoking a paired device closes that device’s open change streams at once:GET /api/changes/streamlooks at the caller only when it connects, so a revoked guest’s stream kept delivering every change until it hung up (only its next request was refused). Rule:revoke_device(and with it every device that device paired,sharing.revoke-cascades) tells the change hub, which ends each stream that device holds with a lastsharing.revokedframe (the guest app shows “this Mac removed you”, the waysharing.stoppedsays the host stopped); the host’s own windows and other devices’ streams are untouched. The same holds for EVERY long-lived stream a paired device can hold, not only the change stream (review 339, ## 590): the activity stream, the workflow-run stream, the batch execute/resume/retry streams and the streaming search each end (without a last frame) when the device is revoked, through one wrapper (security/revocation.end_on_revoke); a device that connects in the moment of the revoke is checked again after it subscribed and refused or ended; a guard test fails any streaming route that leaves the wrapper out. Test (every stream):fichero-server/tests/unit/transport/test_revoke_ends_every_stream_5913.py. Test (change stream):fichero-server/tests/unit/transport/test_revoke_closes_the_stream_5913.py(red before the fix); the two-engine check is release-bar item 4 intests/integration/test_sharing_release_bar_5911.py.sharing.revoke-cascades— [PARTIAL] (#5351; engine built, pinned byfichero-server/tests/unit/transport/test_revoke_cascades_to_spec.py) revoking a paired device also revokes every device it paired (and theirs, down the chain): a code minted by a paired device records it (devices.paired_by_device_id),revoke_devicewalks the chain, andGET /api/pair/devicesreturnspaired_by_device_idfor each device. The Swift revoke confirm lists the devices that go with it (PairingCardView.devicesRevokedWith), parse-checked only. (Ruled 2026-10-04.) Needspaired_by_device_idon pairing codes and ondevices(an idempotent ALTER migration; ruled 2026-10-17) and a walk inAppDatabase.revoke_device. Most useful with Multi-user on, where a paired person can mint codes. Test:fichero-server/tests/unit/security/test_sharing_probe_2026_10_01.py(cascade case).
D. Other people (Multi-user on)
sharing.multiuser-pairing— [PARTIAL] (→ #5049, read, not run) with Multi-user on, creating a pairing code needs a signed-in person, or the host app itself: the app’s bootstrap token on loopback acts as the one active owner for minting a code, listing devices and revoking one (#5346, 2026-10-01; before, the card could be refused and the host could not list or revoke). A code minted that way pairs a device for the owner; choosing another person issharing.device-chooses-person. Pinned byfichero-server/tests/unit/security/test_sharing_probe_2026_10_01.py::test_multiuser_on_the_host_app_can_list_and_revoke_a_device.sharing.private-by-default— [PARTIAL] (→ #2403) only with Multi-user on: a single-user paired device sees every library as owner.sharing.device-chooses-person— [PARTIAL] (#2403) a remote device must choose and authenticate a person; there is no ambient owner. Engine built as opt-in: with Multi-user on,POST /api/pairtakesusernameandpasswordand binds the device to that person, not to whoever minted the code; a wrong password pairs nothing (fichero-server/tests/unit/transport/test_device_chooses_person_to_spec.py, 4 passed). With Multi-user on, a code minted by the host app (no signed-in person) is refused 401person required: ...unless the device names a person; a code minted by a signed-in person still binds to that person. The Mac drawer reveals username/password on exactly that answer and sends them inPOST /api/pair(PairingPersonPickerTests.swift; Swift unbuilt, the drawer unrun on a Mac). Not built: the same picker for iOS and the scanned-QR path.
Acceptance: two Macs, two people, end to end (ruled 2026-10-17)
sharing.two-macs-end-to-end— [PARTIAL] (→ #5049) The maintainer ruled that sharing a library between Macs must work in the next release and Multi-user is on. In plain words: on Mac A the owner turns Sharing on (Settings › Sharing) and the pairing card appears. Mac B, on the same network or the tailnet, finds A (or is given the link) and pairs with the one-time code over Bonjour and pinned TLS, then opens A’s library. A has an owner account; a second person has an account and signs in on B; each edit in the shared library shows its author’s own name and device, on either Mac. A read run started on B shows in A’s Activity while it runs and when it ends. B’s edits land on A within seconds, and A’s Command-Z (one stack per window) still takes back only A’s steps. When A turns Sharing off, B says “A stopped sharing this library”, closes the shared windows and returns to its own projects, with no error screens. Nothing here asks the user for a port, certificate or token. Drilled by the tester withdocs/contributor_manual/testing/sharing-two-mac-checklist.md; its parts are the behaviours below. Test (in-process half: pair, open, stale-edit 409, revoke; not the stream, not two accounts):fichero-server/tests/unit/transport/test_two_macs_end_to_end_to_spec.py; the real run is the checklist on the M4.sharing.two-toggles-wording— [PARTIAL: superseded bysharing.settings.three-panesandsharing.settings.multiuser-out-of-sharing] (built 5ff159856, #3284;SharingTogglesTestswritten, not yet run in the Swift host: needs the builder’s test run) Settings › Sharing has exactly two switches. Sharing: “Share Fichero on this Mac with other devices, the fichero command-line tool, and MCP clients.” Multi-user: “Give other people their own accounts on this Mac’s libraries.” Nothing else is asked. Today the Sharing switch reads On/Off with that caption; the Multi-user switch sits beside Sharing in Settings › Sharing (ruled 2026-10-17; #3284), andEngineSettingsViewholds no toggle (ShareSettingsView.swiftrenders both rows fromSharingToggles). Red test written, not run (needs the pureSharingTogglestype from #3284, so the test target will not compile until then):fichero/Tests/Unit/general/Sharing/SharingTogglesTests.swift(the two labels and captions, and that no third switch exists).sharing.settings.three-panes— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); owner approved 2026-10-20,briefs/fable-settings-sharing-ux-2026-10-20.md§1-3) Settings › System has three rows and no sub-tab pickers: Server, Sharing, Accounts. Server: Status (dot and Running/Stopped) with one Restart button, Storage (a number); a collapsed Advanced holding the Server URL (placeholder “Built-in”, only for a hand-run engine, committed on Return) and the Clients line (app · fichero CLI · MCP, read-only). The Backend tab, its Statistics rows (dropped, not moved: the Inspector does not take them over), “Effective API Base” and every explanatory sentence are gone. The Server URL field shows blank (“Built-in”) when the stored host is the app’s own default address, and where there is no built-in engine a blank field asks for the address. Sharing: one switch “Share this Mac’s projects” with one grey status line (“Sharing on your network and Tailscale”, “Starting…”, or the one error); with the switch off the pane shows the switch and Connect to Another Mac… only; with it on, the Devices list (name and when last seen,+ Add Device…and−, multi-select Remove, blank when empty, refreshes itself, no Refresh button, no Remove All), Connect to Another Mac… (shown with Sharing on or off,sharing.settings.connect-sheet), and a collapsed Advanced (Address with Reset, committed on Return, an invalid address showing one red line under the field, “Address must start with https://”, and no caption; the Tailscale line “Run once in Terminal:” with the command and Copy, only while Tailscale is up and not yet served; Reset Invite; then Multi-user,sharing.settings.multiuser-out-of-sharing). Accounts: the people list (name, @username, role, a You mark that replaces the old “Signed In As” section, with Disable/Enable on a person’s row (kept from today’s Accounts, not part of the approved design); grey “Invited · Revoke” rows) with+and−(sharing.settings.accounts-add-sheet). Per-project roles move to the project’s Share sheet (sharing.settings.project-share-sheet); until that sheet is built Accounts KEEPS its Assigned Roles and Add Member controls, nothing is removed before its replacement exists. The Capture tab leaves Settings as a tab: “Capture…” is an action on a person’s row in Accounts, opening the same manage screen with a proper Mac Done (the policy itself issharing.settings.capture-policy-in-engine). No new switches. Built so far (Swift, needs compile): the Sharing pane (the switch and its grey status line, the Devices list with+ Add Device…and−, Connect to Another Mac…, and Advanced with the Address, the Tailscale line, Reset Invite and Multi-user; Add Device and Connect still host the existing PairingCardView and client pairing section on their sheets;SharingPaneModel.swift,ShareSettingsView*.swift, testfichero/Tests/Unit/general/Settings/SharingPaneTextTests.swift); the Server pane (one pane, no Backend tab;ServerSettingsModel.swift,EngineSettingsView.swift, testsfichero/Tests/Unit/general/Settings/ServerAdvancedModelTests.swift); Accounts is built, below. Layout tests for those: fichero/Tests/Unit/general/Settings/SettingsLayoutTests.swift. Accounts built (Swift, needs compile):UsersContentlistsAccountsPaneModel.rows(You first, owners, the rest by name, grey Invited rows) with+(the add sheet) and−(Revoke an invite, Disable or Enable a person, never you), a Capture… button on a person’s row (opens that person’s capture policy,CaptureSettingsView(startingUser:)), and Assigned Roles and Add Member kept; the Add Account form, the Invite a Person section, the Project Sharing status rows and the Signed In As section are gone. Not yet: Settings still reaches Accounts through the Sharing pane’s People picker, not as its own third row (the container change is a later slice).sharing.settings.add-device-sheet— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); brief §3-4) Add Device… opens a sheet that asks for nothing: the QR (220 pt), the code (“Code 482 913”), “Expires in 10 min” in small grey under it, Copy Link and Done. The QR and code renew by themselves when they expire (found in the review: today the card keeps showing an expired time). While the link is not ready, Copy Link is disabled and the sheet says “Not ready yet”; while the app prepares the address and certificate the sheet shows a spinner and “Finishing setup…” and nothing else (no certificate, pin or restart words); when setup needs a press, one button “Finish Setup”; if the engine is not running, “Start” beside the Sharing switch. No words about pins, certificates, HTTPS, or restarting; the failure words are the one short line, never the internals. Built (Swift, needs compile):AddDeviceSheetModel.swiftandShareSettingsView.addDeviceSheet; testfichero/Tests/Unit/general/Settings/AddDeviceSheetModelTests.swift.sharing.settings.connect-sheet— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); brief §3-4; carriessharing.connect-section-always-shown) Connect to Another Mac… opens a sheet: Nearby (the Macs found on this network, “Searching…” in grey while looking, an empty list otherwise), then Or paste a link (placeholder “https://…”), a Code field once a nearby host is picked (placeholder “Code”), and Name and Password fields only when the host asks for a person; Cancel and Connect. It shows where the code goes (“Connects to “) once, under the field, never twice. A failure is one line under the fields in the engine’s words (no internal terms). It works with Sharing on or off. Placement is a rule,SharingPaneText.showsConnect(always shown: Sharing on or off, host or guest), pinned bySharingPaneTextTests.connectIsAlwaysShown(#5893). Built (Swift, needs compile): the sheet’s words and rules areConnectSheetModel(testfichero/Tests/Unit/general/Settings/ConnectSheetModelTests.swift),MacRemoteClientPairingSectiondraws them (Nearby with “Searching…”, “Or paste a link”, Code, Name and Password only when asked, one “Connects to”, Connect; Cancel is the hosting sheet’s), and a tapped invite link opens the sheet with the link filled in (FicheroAppopens the Sharing pane, no longer the retired Backend tab). Not tested: the drawing itself.sharing.settings.multiuser-out-of-sharing— [GAP] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); brief §2, §5) the Multi-user switch is not beside the Sharing switch and has no caption in the main pane. Untilmultiuser-always-on.mdslice 10 removes it, it lives only inside Sharing › Advanced (below Reset Invite), reached by opening that disclosure; the Accounts pane has no switch and no sentence about it. It shows the engine’s state and changes it throughPUT /api/pair/multiuser(multiuser.switch-one-truth,multiuser.owner-session-sets-the-switch); the “turn it on in Settings → Engine” instruction is gone. (not yet written: fichero/Tests/Unit/general/Settings/SettingsLayoutTests.swift)sharing.settings.project-share-sheet— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); brief §3 “per-project roles live on the project’s Share sheet”) the project’s Share sheet (opened from the project’s row in the sidebar) lists the people who have a role on THAT project (name, @username, role: owner, editor, viewer), and the project’s owner may give a person a role (a picker of people without one, then a role menu; nothing to say when everyone has one), change it from the role menu, or remove it with−(never the last owner: the engine refuses in words). A FOLDER row in the sidebar offers the same Share… (#5967, owner ruling 2026-10-20sharing.share-sheet-chooses-what): the sheet opens for that folder, titled with its name, and a grant on it is (person, project, folder, role), role viewer by default or editor; a person given only a folder sees that folder and what is inside it, nothing else in the project, and the sheet says so in one grey line. A person who already has a role on the whole project is told that person already has more access (no second, narrower grant). The folder grant goes through the person-bound invite route’sfolder_id(POST /api/pair/code; for a person who already has an account it grants the role on that folder at once, #5967; a new request field, HELD for the regen), the one place a grant narrower than the project is made; Anyone else sees the list read-only. The project-level list and roles read and write through the existing library-role routes (no new route there). Until it is built, Settings › Accounts keeps its Assigned Roles and Add Member controls. (not yet written: fichero/Tests/Unit/general/Settings/ProjectShareSheetModelTests.swift) Built (Swift, needs compile, unrun): the project’s Share sheet already lists the members and grants and changes roles (ShareLibrarySheet); a FOLDER row now offers Share… with Multi-user on (SidebarItemRow+Presentation), opening the same sheet scoped to the folder (ShareLibrarySheet.folder,FolderShareTarget) and granting throughPOST /api/pair/codewithfolder_idfor a person who has an account (PairingService.shareFolder); testfichero/Tests/Unit/general/Views/Sidebar/FolderShareTargetTests.swift. Not built: the folder grants listed per folder in the sheet’s members list, the told-in-words note that a person with the whole project already has more access (the engine refuses in words; the sheet shows its sentence), and a withdraw control for a folder grant.sharing.settings.accounts-add-sheet— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); brief §2 “Add Account / Invite →+sheet”)+in Accounts opens one sheet: Full name, Username, then a choice of Password (a field) or Send invite link, and Owner (a checkbox, off). With a password it creates the account under the engine’s rule for a NEW account’s password (at least 8 characters, the same rule the invite redeem applies;multiuser.invite-registers-public-key), notmultiuser.turning-on-needs-an-owner-password, which is only about the owner having chosen one before Multi-user goes on; with the invite choice the button reads “Send Invite Link” (no ellipsis: it acts when pressed, then shows the link and Copy) and followsmultiuser.person-bound-invite: a viewer by default, 7 days by default, the person opens the link and sets their own password. Failure words come from the engine, one line under the fields (“That username is taken”, “Enter a name and a username”, the password rule, “Open a project first: a new person needs one to join”); nothing internal. Pending invites are grey “Invited · Revoke” rows in the list. Built (Swift, needs compile):AccountsAddSheetand the rules inAccountsAddSheetModel(the invite choice only while Multi-user is on; Owner only with a password), testfichero/Tests/Unit/general/Settings/AccountsPaneModelTests.swift. Not tested: the drawing; the engine’s own refusals are shown in its words. ## 757: the 8-character rule is the ENGINE’s:POST /api/users,POST /api/auth/invites/redeemandPATCH /api/users/{id}(a password change, checked after the access checks) refuse a shorter password with a 422 that says why (fichero-server/tests/unit/security/test_new_password_length_is_the_engines_757.py, unrun, M4); the app keeps its hint. Send Invite Link is NOT BUILT here until the OpenAPI regen batch:POST /api/auth/invitesmints a 15-minute invite with no role, but the owner ruled viewer, 7 days (changeable up to unlimited), a link and a password the person sets; that is r1’s person-bound invite (multiuser.person-bound-invite, in b6c04a81f, 29001b2e5, 92953daf3, 8409ee0b0, held for the regen, not on this base). No second invite path is built; the choice is hidden with the sheet saying why.sharing.settings.server-url-one-writer— [PARTIAL] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 4) the Server URL field in Server › Advanced (placeholder “Built-in”) commits on Return throughEngineHostSwitch.run, the ONE writer of the stored engine host (sharing.one-writer-of-the-engine-host, #5916); it never writesengineHostitself nor only repoints the clients. An unreachable host is put back and said so. Built (Swift, needs compile); pinned byfichero/Tests/Unit/general/Settings/ServerAdvancedModelTests.swift.sharing.settings.ios-host-pick— [GAP] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 7) on iOS and iPad, tapping a nearby Mac in the list opens the scanner or the link entry for THAT Mac (its name shown), instead of ignoring the pick; an unreachable Mac shows an orange icon. (not yet written: fichero/Tests/Unit/general/Settings/IOSHostPickModelTests.swift)sharing.settings.switch-disabled-says-why— [GAP] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 9) when this Mac is connected to another Mac’s project the Sharing switch is disabled and the grey status line under it says why in one sentence (“Sharing is set on the Mac that runs this project”); a disabled control never has its reason elsewhere on the page. (not yet written: fichero/Tests/Unit/general/Settings/SettingsLayoutTests.swift)sharing.settings.button-ellipsis— [GAP] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 10) a button’s label ends in an ellipsis only when pressing it opens a dialog before anything happens (Add Device…, Connect to Another Mac…); a button that acts at once does not (Send Invite Link, Copy Link). (not yet written: fichero/Tests/Unit/general/Settings/SettingsLayoutTests.swift)sharing.settings.capture-policy-in-engine— [GAP] (#5928; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 11; four-architecture rule: logic in the engine) the capture policy per person (who may send captures, to which project and workflow) is stored in the engine, not in this Mac’s UserDefaults, so a second Mac sees the same policy. Until then it stays where it is and the Accounts row’s “Capture…” edits that local copy. A separate issue; named here so the move out of Settings does not hide it. (not yet written: fichero-server/tests/unit/transport/test_capture_policy_in_engine_to_spec.py)sharing.settings.visible-in-release— [GAP] (#5928, → #3776; Settings: Server / Sharing / Accounts layout (approved Fable design); design §5 bug 12) Server and Sharing are hidden in release builds today (showsTesterSettingsPane); before the next release the ratchet that justified it is re-checked, and the panes are shown in release or the reason is written here. Accounts is shown in release. (not yet written: fichero/Tests/Unit/general/Settings/SettingsLayoutTests.swift)sharing.names-on-edits— [PARTIAL] (→ #5735; engine half pinned byfichero-server/tests/unit/transport/test_two_accounts_names_on_edits_to_spec.py, passing: owner on the host and a second person on a paired device each get their own name and device on the audit row; the change-streamactorfield is still a category, not a person ; the event now carriespersonanddevicetoo (test_change_event_names_person_to_spec.py; the Swift stream does not read them yet, #5735), and the app windows are unrun) with two accounts, an edit made by the second person on B is recorded as that person on device B, and the owner’s on A as the owner on A (needssharing.device-chooses-person,sharing.who-did-it). Test:fichero-server/tests/unit/transport/test_two_accounts_names_on_edits_to_spec.py.sharing.remote-run-in-host-activity— [PARTIAL] (→ #5732) a read run started from B is a row in A’s Activity (/api/activity/stream), started by B’s person, on device B, live to its end. Built: every run starter takesstarted_byfromstarted_by_for(ctx), “person on device name” for a paired device (ruled 2026-10-17), pinned byfichero-server/tests/unit/transport/test_remote_run_in_activity_to_spec.py(3 passed). The host’s Activity jobs list carriesstarted_by(GET /api/activity/jobs, pinned bytest_a_run_started_from_a_paired_mac_is_a_row_in_the_hosts_activity_naming_who). Not proven: the row live on A’s stream while the run goes, and ending (two-Mac checklist).sharing.undo-after-remote-edit— [PARTIAL] (→ #5733) A’s undo reverses only A’s own steps; if B changed the same thing since, it is refused in a sentence naming who and when (undo-everywhere.md, #5658). Built: the engine refuses over later work in a sentence naming actor and time (actions/undo.py:181, reasonchanged). Pinned byfichero-server/tests/unit/transport/test_undo_after_remote_edit_to_spec.py(host’s undo after a paired person’s edit: 409changednaming them). Not proven: per-window undo in the app (two-Mac checklist).sharing.off-closes-the-guest— [PARTIAL] (→ #5731) turning Sharing off on A ends B’s change stream with a typedsharing.stoppedevent before the listener closes; B shows one sentence, closes the shared windows and returns to its own projects; no reconnect loop. Engine built: a non-loopback/api/changes/streamyieldssharing.stoppedwhen the engine shuts down, the host’s own windows get none (fichero-server/tests/unit/transport/test_sharing_off_to_spec.py, 2 passed). Not built: the guest’s one sentence, closing its windows, and no reconnect loop (Swift,LibraryManager.swift); the window behaviour is on the checklist.sharing.stale-edit-both-ways— [PARTIAL] (→ #5348) engine half built; the app sends the version (seesharing.stale-note-edit-is-refused-and-offered-a-choice): B’s edit on a note A changed is refused 409, never silently lost. Engine pinned byfichero-server/tests/unit/security/test_sharing_probe_2026_10_01.py::test_two_macs_editing_one_note_the_second_stale_edit_is_refused(no xfail remains); the Swift editor half needs its own test, to be written with #5348.
E. Sharing in the sidebar (ruled 2026-10-17; cross-cited from ui/sidebar-crud.md)
sharing.sidebar.guest-row— [GAP] (→ #5737) on the guest Mac a shared library is a project row under PROJECTS in the same style as local projects, with a small “shared” mark, named as on the host; the host’s name is shown in the Inspector. No separate section. Built: a remote-host project already sits under PROJECTS like any other; its row now carries the small shared mark (person.2) and the Inspector’s Location reads “Shared from” ( SharedProjectRowTests.swift; Swift unbuilt, the row unrun on a Mac).sharing.sidebar.host-mark— [PARTIAL] (→ #5738) on the host Mac the shared project’s row carries a small shared mark while Sharing is on; the Inspector lists the connected people and devices, each with Revoke and Forget machine (transfer.machine.forget-machine). Nothing else changes in the sidebar. Built: the mark on the row and the Inspector’s devices list with Remove (revoke, which cascades) while Sharing is on (HostSharedMarkTests.swift; Swift unbuilt, unrun on a Mac). Not built: Forget machine in that list, and the list naming people.-
sharing.sidebar.off-greys-row— [GAP] (→ #5739; engine event issharing.off-closes-the-guest, #5731) when Sharing goes off on the host the guest’s row stays, greyed, with one sentence, “Not available: sharing is off on“; its windows close with that sentence; Forget removes the row. Built for the session: the row ( HostGoneRowView) renders fromLibraryManager.hostGoneRows, filled by thesharing.stoppedevent, Forget removes it (SharingOffGreysRowTests.swift; Swift unbuilt, the row unrun on a Mac). Not built: the row surviving an app relaunch. -
sharing.switch-from-cli— [GAP] (→ #5751) Sharing and Multi-user can be read and set through one audited action (sharing.set), so the CLI/MCP and Settings share one path. Test: (not yet written: test_sharing_switch_action_to_spec.py).
The two-machine test (next)
On the Air (host) and the MBP (client), apps launched by path, one xcodebuild at a time on each:
1. Air: Sharing on; read the advertised address, the listeners (lsof -iTCP -sTCP:LISTEN), the
Bonjour record (dns-sd -B _fichero._tcp, dns-sd -L).
2. MBP: paste the invite link; pair; open the shared library.
3. Edit on the MBP (rename, a reading); see it on the Air. Edit on the Air; see it on the MBP.
4. Revoke the MBP on the Air; the MBP is refused.
5. The same over Tailscale (tailscale serve), by hand, to learn what automating it needs.
Each failure: an issue, fixed one at a time, its behaviour above re-tagged.
Triaged from the backlog (2026-10-04)
sharing.accounts-and-users-settings— [PARTIAL: superseded bysharing.settings.three-panes(the Accounts row)] (#2083; read, not run:UsersSettingsView.swift,UsersContent+*.swift,InviteAccountSection.swiftalready give invites, add member, role picker, remove, disable/enable; login and a real run are unchecked) Settings has an Accounts & Users screen to log in, add/remove users and assign owner/editor/viewer.sharing.ios-device-token-hygiene— [PARTIAL] (#3290) outbox blobs and the manifest are written with file protection until first unlock on iOS (MobileCaptureQueue.writeOptions; testfichero/Tests/Unit/general/Models/MobileCaptureQueueFileProtectionTests.swift, written, not run); token resolution, foreground renewal and the re-pair prompt are not covered by this slice. an iOS device build never resolves the bootstrap token, renews its device token on foreground, and shows a re-pair prompt on expiry or revocation; outbox blobs are file-protected.sharing.owner-shared-libraries-view— [GAP] (#2054) Settings shows the owner every library, which are shared with whom and in what role, and whether each is open.sharing.multiuser-mode-single-source— [PARTIAL] (superseded bymultiuser-always-on.md: describes today’s code until its slice 10 removes the switch) (#3331, #3284, #5751) multi-user access is enforced ONLY while the Multi-user switch is on (owner ruling 2026-10-18); account rows alone never turn it on, andFICHERO_MULTIUSER=0is an authoritative off (the reading “accounts imply enforcement” was ruled out;test_multiuser_accounts_dont_flip.py). The toggle persists to the engine and the app, and the spawn env and engine agree. Built: the persisted setting is written by one audited, undoable action (PUT /api/pair/multiuser,test_sharing_state_and_multiuser_switch_to_spec.py), and the environment wins with a 409 that says so. Left: the app-started engine still takes the flag by restart withFICHERO_MULTIUSER(a second path to the same setting); folding that onto the one action needs the supervisor to stop passing the env (design call, #5751).sharing.acl-status-when-multiuser-off— [PARTIAL] (superseded bymultiuser-always-on.md: describes today’s code until its slice 10 removes the switch) (#3335; engine pinned byfichero-server/tests/unit/transport/test_acl_status_multiuser_off_to_spec.py; app wording parse-checked only, not built) with Multi-user off the Accounts section reads “Accounts: Off” (and no role or access rows) instead of a red Server error; an authz error is an orange warning label (Fable UI review, ShareSettingsView+Security.swift).sharing.ios-discovers-host— [GAP] (#3371) an iOS/iPad client browses Bonjour, lists verified reachable Mac engine candidates and shows stale ones as failed, without persisting trust.sharing.actor-not-from-the-body— [OK] (#3285; pinned byfichero-server/tests/unit/security/test_invoke_actor_cannot_be_forged.py, 3 passed)POST /api/actions/invokenever takes the author from the request: a bodyactorororigin_windowis refused, and the audit row’s actor comes from the signed-in session.sharing.loopback-is-owner— [OK] (superseded bymultiuser-always-on.md: describes today’s code until its slice 10 removes the ambient owner) (pinned byfichero-server/tests/unit/security/test_loopback_owner_never_gated.py,fichero-server/tests/unit/security/test_resolve_single_user_owner.py) a request on loopback carrying the bootstrap token is the owner, Multi-user on or off; a remote device is never the owner by being on the same Mac; the bootstrap secret is never a device token (api/auth.py:166-181).sharing.owner-owns-every-library— [OK] (#5755; pinned byfichero-server/tests/unit/transport/test_owner_account_owns_every_library_to_spec.py) an owner account owns every library this Mac hosts: a new or promoted owner gets owner on the existing libraries, and a library opened later is owned by every owner account (ruled 2026-10-17).sharing.owner-only-asserted-in-the-action— [OK] (#5766;fichero-server/tests/unit/security/test_owner_only_is_declared_to_spec.py) an owner-only action is declaredowner_only=Trueon its registration (acl.set) andregistry.invokerefuses a non-owner before the action runs, so/api/actions/invoke, MCP and chat cannot reach it with library WRITE alone; the loopback owner passes. A guardrail fails any action whose docstring says owner-only without the flag. Actions that other people may also run (device.revoke: the owner or the one who paired) assert their own rule inside.sharing.account-changes-are-audited— [PARTIAL] (#5765, → #5792;fichero-server/tests/unit/security/test_account_changes_are_audited_to_spec.py) creating, changing, disabling or deleting an account, minting or revoking an invite and revoking a session each write an audit row (user.create|update|delete,invite.create|revoke,session.revoke) naming the actor (user:<id>orbootstrap), the client and the paired device; no password or token is recorded. The row is written first and hash-chained like an action audit (AppDatabase.save_chained_account_audit; client and device are stored and, onv2:rows, covered by the hash; rows written before verify as they did), so a failed audit write stops the change. Not built: nothing verifies the app chain yet (verify_audit_chainreads the library database only; → #5792), so it is chained, not yet tamper-evident. The owner check stays in the route.sharing.cli-session-per-engine— [OK] (#5757;fichero-cli/tests/test_cli_session_per_engine_5757.py, 4 passed) the CLI keeps a session per engine it dials (keyed by socket path or URL); a token one engine rejects never shadows another engine’s bootstrap token. A session saved before this change (no engine stamp) is dropped from the file the first time an engine answers it 401, so it is never offered to another engine.- Accounts imply enforcement: superseded by
multiuser.always-oninmultiuser-always-on.md(owner 2026-10-18); the full always-on (#2403, slice 10) is still the plan. sharing.device-acts-as-its-binding— [PARTIAL] (#5888; code and tests written; PARTIAL until an M4 run) (S1, teststests/unit/security/test_sharing_probe_2026_10_01.py::test_s1_*) a paired device authenticates as the user it is bound to, never as the owner by fallback: an owner-bound device is the owner by its binding; a device bound to anyone else is refused while Multi-user is off (roles are only evaluated with it on) and acts as that person with it on (a viewer-bound device reads and cannot write; an editor-bound one writes, audited as that person with the device named; neither ingests a host path:test_s1_with_multiuser_on_*); a device with no binding is 401. A host path is ingested only by the host app or an ENGINE-owner credential, session or device (_require_ingest_owner; a library-owner person is refused). Loopback+bootstrap as owner stays until slice 10.sharing.stale-note-edit-is-refused-and-offered-a-choice— [PARTIAL] (#5348) a note or document edit made against an old version is refused 409 and the app offers keep-mine, take-theirs or compare. Engine built for both:PATCH /api/notes/{id}andPUT /api/documents/{id}takeexpected_updated_at(pinned byfichero-server/tests/unit/security/test_sharing_probe_2026_10_01.py::test_two_macs_editing_one_note_the_second_stale_edit_is_refusedandfichero-server/tests/unit/transport/test_stale_document_edit_to_spec.py). The note editor sends the version and offers Keep mine, Take theirs and Compare (both texts side by side;fichero/Tests/Unit/general/Sharing/NoteStaleResolutionTests.swift). Not built: the same choice for a document edit.sharing.note-deleted-elsewhere-stays-deleted— [PARTIAL] (→ #5348; owner ruling 2026-10-17) a shared library is shared editing: a note deleted on another Mac is deleted here too. When a refused (stale) save finds the note gone, the app says so in one sentence and offers nothing: no keep-mine resurrection, no clipboard copy, no dialog. (NotesInspectorPane.saveNote,NoteStaleResolution.deletedElsewhere; Swift, parse-checked only.)sharing.one-writer-of-the-engine-host— [PARTIAL] (#5916; Swift, built, unrun until a Pro build; testsfichero/Tests/Unit/general/Sharing/EngineHostSwitchTests.swift) one procedure changes which engine the app talks to (EngineHostSwitch.run): stop the engine, write the host, point the clients at it, start, read its health, and when the new host will not start or does not answer put the previous host back (and start it) and say so. Settings › Engine › Advanced › Apply and Settings › Sharing › Connect This Mac both call it, and it is the only writer of the stored host: the Server URL field edits a draft, not the setting. An unchanged host is repointed and its health read again, without a restart (a re-pair after a revoke picks up the new credentials; a host that does not answer is said so). The stored value itself has one writer,EngineConfig.storeHost/clearStoredHost, and the callers are these and no others (guard:scripts/check_swift_transport.pyrule 6, which flags any mention of the host key outsideEngineConfig.swiftthat is not an@AppStoragedeclaration or a read (so a literal key, the key in a local variable and a multi-lineset(are caught) and any assignment to a binding declared with that key, whatever its name; pinned byfichero-server/tests/unit/scripts/test_check_swift_transport.py):EngineHostSwitch(Mac, Apply and Connect This Mac);persistPairedHost(stores the pairing on iOS, which has no engine to restart; on the Mac the switch writes the same value right after);rollbackFailedHostSwitch(the undo of that store, called by the switch’s failure path);forgetPairing(dropping the host is not a switch); and the heartbeat’s failover (commitActiveEndpoint, an automatic move between endpoints of one paired host, no engine to restart). Before this, Apply repointed the clients while the embedded engine kept running. Not built: a run on a Mac with a real second engine (the live try).sharing.ios.nearby-tap-pairs-with-that-mac— [PARTIAL] (#5918; release bar #5910; Swift, built, unrun until a Pro build; testsfichero/Tests/Unit/general/Sharing/HostChoicesTests.swift) on the iPhone’s Connect screen, tapping a nearby Mac pairs with THAT Mac: the tap records which Mac was picked, the screen shows “Pairing with” and its address (when Bonjour gave one) before anything is sent, and then opens the scanner (the QR carries the code and the key to pin; Bonjour never does). A code or link for a different host than the one picked is refused in words (“That code is for another Mac than . Scan the one shown on .”), through the one place pairing finishes (scan, pasted link, manual entry). Hosts compare by name without case or a trailing dot; a .localname and the same Mac’s IP address are different hosts and refuse (fail closed), and the refusal says so. A picked Mac whose address Bonjour has not resolved is shown by name and cannot be cross-checked, so any code is accepted for it (the scan still pins the key it carries). Clearing the pick returns to the open scan. The rule is the Mac’s (HostChoices.invite(_:isFor:)), shared.sharing.pair-code-refusals-in-words— [PARTIAL] (#5910 release bar, the pairing flow’s worded refusals; built, unrun on the M4 until it runs there; alsoPOST /api/pairredeem and/enroll: a blank device name or a too-short new password is a 400, choosing a person with Multi-user off a 409, a real malformed body stays the 422invalid pairing request;api/routes/auth/pairing.py; testsfichero-server/tests/unit/transport/test_person_bound_invite_5910.py,test_share_scope_5967.py)POST /api/pair/coderefuses in words with a plain string detail, never as a 422: a request that cannot be right (a lifetime or a folder with no named person, a username with bad characters, a folder that is not in the project) is a 400; a request the state refuses (Multi-user off, no project open to share a folder from, a new person with no library to be invited to, an unusable sharing address) is a 409. A 422 is left to the framework’s own validation (the array of field errors the app reads as such), which is what the app mis-read these as before: it showed “The engine did not accept that request.” instead of the words. App:PairingService.createPairingCodeandshareFolder(PairingTypes.swift) read a 422 as the array of field errors and every other status throughEngineErrorDetail; so these refusals, now 400/409, reach the person as the engine’s words (they were a 422 the app could not decode); a real 422 (a role that is neither viewer nor editor, a lifetime out of range) still takes the field-errors path. No contract change: the new statuses are not declared responses, so no regen is needed for them.sharing.pairing-code-renews-at-expiry— [PARTIAL] (#5915; release bar #5910; Swift, built, unrun until a Pro build; testsfichero/Tests/Unit/general/Sharing/PairingCodeRenewalTests.swift) the pairing code and QR on Settings › Sharing never sit on screen past the time they say they expire: when the code’sexpiresAtarrives the card mints a fresh one (the same call that made the first), by a wait that starts when a code is shown and is cancelled when it is replaced. The wait is never shorter than 5 seconds, so a clock that disagrees with the engine’s cannot make a re-mint loop; the old code stays on screen until the new one lands (clearing it first changes the waiting task’s key and cancels the renewal itself, review 597), turning Sharing off while a code is up removes it, a cancelled wait or mint changes nothing, and a failed mint removes the expired code, shows its error, and does not retry by itself. Tests cover the renewal itself (fresh code, failure in words, cancellation is not a failure, each renewal mints exactly one code, a code that lands after the card went away is dropped). Not built: the same renewal on the share sheet’s own code (ShareLibrarySheet+Sharing.swift).sharing.people-tab-names-the-multiuser-switch— [PARTIAL] (#5917; Swift, built, unrun until a Pro build; testfichero/Tests/Unit/general/Sharing/SharingTogglesTests.swift) with Multi-user off, the People tab’s “Invite a Person” section tells the owner to turn Multi-user on in Settings › Sharing › Devices, where the switch is, built from the switch’s own label and one location constant (SharingToggles.label(_:),SharingToggles.location: a layout move is one edit), and promises no restart (Multi-user stays off until the owner has a password, #5908); it no longer points at Settings › Engine, which has no such switch. A test pins the sentence; that the pane really holds the switch is read fromShareSettingsView.swift, which a test cannot show.sharing.ios.unpaired-phone-opens-on-connect[PARTIAL] (#5396) read 2026-10-17, not run: the gate routes a never-paired launch to the connect screen (EngineConfig.iosLaunchPhase), and the tier glyphs are Mac-only (FeatureManager.shouldShowTierChrome; testfichero/Tests/Unit/general/Views/Shell/IOSNoTierChromeTests.swift); the layout items (doubled nav bar, landscape sidebar, offline chat pane, empty-sidebar hint, Mac vocabulary) are unverified and open. Behavior: an iPhone that has never paired opens on a welcome screen with Connect to a Mac (Bonjour list, QR or address), then the project list, never an empty ‘Nothing Selected’ view or a server-down error. Its screens are laid out for a phone: one navigation bar, a sidebar that survives landscape, no offline chat pane and no internal α/β/δ markers.
Future (ideas, not scheduled)
- (#2029) Multi-writer concurrency and presence: design pass explicitly last, after users and attribution land.
- (#3706) Cross-library/window/app drag and drop; design-blocked on atomicity per the issue’s own comment
- (#3375) Optional mTLS, UDS and iCloud identity sync for pairing; design-only, after the MVP is stable
- (#2096) Native iOS/iPad client for a Mac-hosted engine; pairing slices are specced as sharing.* behaviours, the client epic itself is vision
- (#2899) fichero-web keystone (browser access under a per-user token, writes via the audited registry); no web client exists and auth has no web-login route; Future section of library-sharing
- (#3123) fichero-web HttpOnly cookie session /api/auth/web-login + CSRF guard; not in the server (grep finds no web-login), prerequisite for an unscheduled web reader; Future of library-sharing
- (#4219) Retire LibraryReference.deinit’s scope stop so the nonisolated hole closes (deinit still present at LibraryManager.swift:481); a cleanup follow-up to the #4216 lock, Future
- (#2162) iOS connection stack (NWBrowser discovery, QR scan, Keychain token, SPKI-pinned URLSession, optional mTLS/UDS); later with the iPhone/iPad app.
- (#3778) iOS-initiated pairing (tap discovered Mac, Mac shows number, enter it); blocked on the SPKI pinning question.