Locked notes: refused where a trait lets others read the space

Merged
CA
5d ago
+389-103

Groundwork for published spaces: "no locked notes" follows what sharing and publishing have in common, a trait that lets others read the space, instead of being a rule of sharing.

The rule (architecture §7.8, Locked notes)

A locked note is only its account's to read, so a space refuses locked notes while any trait exposes it to someone else (a trait with an exposure). Sharing is the only such trait today; publishing will follow it with no lock code of its own.

Server

  • spaces.ExposureSource: each feature tells the server whether its trait exposes a space, whoever asks (the trait's wording depends on who asks). Sharing exposes a space while it has a second member or any invitation, exactly when its trait is on.
  • Writes check the destination space inside their transaction: create, update (lock or move within the file) and moves between files. 422 locked_in_exposed_space replaces locked_in_shared_space. The whole-file rule for a shared space's file is gone.
  • Every invitation, not just the first, counts the space's locked notes (409 space_has_locked) while holding the space file's writer (notes.Store.WithoutLocked), so no note can be locked between the count and the invitation. That also closes the gap between a first share and its invitation.
  • Behaviour change: a shared space with no one else in it and no invitation exposes nothing, so it takes locked notes again (§7.9 already called it "private again"). Inviting someone is then refused while it holds any.

Apps

  • Web, Android, iOS: Lock and Move to space check for any trait with an exposure, not space.shared, and word the dialog from that trait: "Locked notes can't be in a shared space." with its exposure and the next step ("Only you can read a locked note, and Family is visible to Ana. Move it to a space of your own to lock it."), the same on all three.
  • Kotlin and Swift sync engines: a refused locked edit becomes a recovered copy under the new code.

Docs

Architecture §7.8 (the rule) and §7.9, API reference, security model.

Testing

  • mise run lint, typecheck, test (Go -race, web 1118, sync engine JVM + JS against a real notesd) ✅
  • mise run lint:android, test:android ✅
  • mise run lint:ios, test:ios:core (238) ✅; test:ios (15 UI flows against a real notesd + 78 unit) ✅, on a dedicated simulator: another session was running UI tests on the shared one, which killed every test.
  • New tests:
    • API: moving a locked note into a shared space is refused; once the member leaves, the space takes locked notes; inviting then is refused with the count; a pending invitation alone exposes it.
    • Store: per-space refusal in an account's own file (create, lock, move within the file) and WithoutLocked's count, the path publishing will use.
    • Web, Android, iOS: the first exposing trait and the wording; Android's flow test now gives the space its trait, as the server sends it.

Summary by cubic

Makes locked notes follow any trait that lets others read a space, instead of being a rule of shared spaces.

  • Each feature now reports whether its trait exposes a space (spaces.ExposureSource); sharing is the only one today, and publishing will follow without its own lock rule.
  • Writes check the destination space inside their transaction (create, update, move within the file, and moves between files): 422 locked_in_exposed_space replaces locked_in_shared_space, and the whole-file rule for shared spaces is gone.
  • Every invitation, not just the first, counts the space's locked notes (409 space_has_locked) while holding the space file's writer, so none can be locked between the count and the invitation.
  • Behavior change: a shared space with no other members and no invitations takes locked notes again; inviting is then refused while it holds any.
  • Web, Android, and iOS refuse from the first exposing trait with the same wording — what's refused, who can read the space, and what to do instead; both sync engines keep a refused locked edit as a recovered copy under the new error code.
  • Docs updated: architecture, API reference, security model.