fix(sharing): harden the owner fast path (review of #829/#835) #909

Closed
kayg wants to merge 0 commits from fix/sharing-fast-path-hardening into dev
Owner

Follow-up to the review on #829 (#829 (comment)). Branched from #835's head 46be23e3; lands right after train BM14d. No pushes were made to #829 or #835.

P2-1 allow-list. CredentialKind::owner_fast_path is the one place that names the kinds the owner fast path trusts (Session, Development). It is an exhaustive match with no _ arm, so a new kind (scoped Cal keys, link tokens) will not compile until someone decides, and takes the full check by default. All five sites use it (owner_delivery, owner_resource, checked_room_delivery_permit, cacheable_owner). Test every_credential_kind_has_a_fast_path_decision lists every variant.

P2-2 revoke test. isolation::revoked_owner_app_password_is_refused_at_every_call_site wires the real Auth into Notes, mints an owner app password (read+write), shows it is admitted, revokes it with account.app-password-revoke, then checks that notes.get, get_authorized, a new join, authorize_delivery (the socket write) and resolve_resource for Read and Edit all refuse.

P3. The room-delivery test now asserts run_count is unchanged. The size and scope check is one function, Credential::check_owner_note, used by all three former copies (owner_read_precheck is gone). Notes::authorize_delivery takes the shutdown guard before the owner fast path, so an owner's queued frame is no longer written while Notes shuts down (the only access change). The room sequence number is left as is.

Access control for every current credential is unchanged apart from the shutdown case.

Gates and perf numbers follow in comments.

Follow-up to the review on #829 (https://git.kayg.org/kayg/calternal/pulls/829#issuecomment-77281). Branched from #835's head `46be23e3`; lands right after train BM14d. No pushes were made to #829 or #835. **P2-1 allow-list.** `CredentialKind::owner_fast_path` is the one place that names the kinds the owner fast path trusts (`Session`, `Development`). It is an exhaustive `match` with no `_` arm, so a new kind (scoped Cal keys, link tokens) will not compile until someone decides, and takes the full check by default. All five sites use it (`owner_delivery`, `owner_resource`, `checked_room_delivery_permit`, `cacheable_owner`). Test `every_credential_kind_has_a_fast_path_decision` lists every variant. **P2-2 revoke test.** `isolation::revoked_owner_app_password_is_refused_at_every_call_site` wires the real Auth into Notes, mints an owner app password (read+write), shows it is admitted, revokes it with `account.app-password-revoke`, then checks that `notes.get`, `get_authorized`, a new `join`, `authorize_delivery` (the socket write) and `resolve_resource` for Read and Edit all refuse. **P3.** The room-delivery test now asserts `run_count` is unchanged. The size and scope check is one function, `Credential::check_owner_note`, used by all three former copies (`owner_read_precheck` is gone). `Notes::authorize_delivery` takes the shutdown guard before the owner fast path, so an owner's queued frame is no longer written while Notes shuts down (the only access change). The room sequence number is left as is. Access control for every current credential is unchanged apart from the shutdown case. Gates and perf numbers follow in comments.
kayg added 79 commits 2026-11-04 06:18:24 +00:00
An owner reading their own note never depends on a grant row. For
every credential except an app password the resource check reduces to
the note-ID size and read-scope checks, so run those directly instead
of a database round trip for each queued frame and each delivered
frame. App passwords keep the full check (revocation is in the db).
Same allow/deny outcome for every caller; non-owner paths untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The owner's own live frames paid a clone, a boxed future and a home
lookup per queued frame and again per written frame. Answer them in
Home and Notes with the same size and scope checks, for non-app-password
owner credentials only; everyone else takes the full path unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An owner reading their own note never depends on a grant row. For every
credential except an app password the resource check reduces to the
note-ID size and read-scope checks, so run those directly instead of a
database round trip for each delivered frame. App passwords keep the
full check (revocation is in the db). Same allow/deny outcome for every
caller; non-owner paths untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Answer the owner's own connection in Home with the same size and scope
checks, without cloning the caller or awaiting the sharing layer.
Everyone else takes the full path unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The probe stood in for a recipient using the owner. An owner never waits
on a grant lookup, so use a real recipient caller: same recheck, same
assertions, no dependency on the owner path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The probe stood in for a recipient using the owner. An owner never waits
on a grant lookup, so use a real recipient caller: same recheck, same
assertions, no dependency on the owner path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/auth/src/sharing.rs
#	crates/notes/src/home.rs
#	crates/notes/src/room/sync.rs
Opening a note for its owner made two sharing lookups (read, then edit)
plus the delivery check, for a person who needs none: the owner's access
is fixed by the credential. Answer owner Read and Edit with the same size
and scope checks and an Owner result, for session and development
credentials; app passwords (revocable), recipients and every other
operation take the full path unchanged. The sequence is passed through as
given; owner delivery never reads it.

Access logic touched: owner Read/Edit resolution only; outcomes are the
same allow/deny set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Opening a note for its owner made two sharing lookups (read, then edit)
plus the delivery check, for a person who needs none: the owner's access
is fixed by the credential. Answer owner Read and Edit with the same size
and scope checks and an Owner result, for session and development
credentials; app passwords (revocable), recipients and every other
operation take the full path unchanged. The sequence is passed through as
given; owner delivery never reads it.

Access logic touched: owner Read/Edit resolution only; outcomes are the
same allow/deny set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CredentialKind::owner_fast_path names the kinds the fast path trusts, with
no wildcard arm, so a new kind takes the full check until decided. The
three copies of the size and scope check become Credential::check_owner_note.
The room-delivery test now counts database calls.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
style(notes): rustfmt the import list
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
98afda51ec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Gates on head 98afda51 (dev-01, ~/bin/slot, Nix shell):

  • cargo fmt --all --check: clean
  • cargo clippy --workspace --all-targets --locked -j4 -- -D warnings: clean
  • cargo test --locked -j4 -p calternal-core -p calternal-auth -p calternal-notes -p calternal-server -p calternal: 0 failed. This covers the auth sharing and security tests (security_regressions, isolation) and the in-crate sharing tests. New tests all pass: every_credential_kind_has_a_fast_path_decision, owner_note_check_has_one_size_and_scope_rule, owner_room_delivery_keeps_size_and_scope_checks_without_a_db_hop (now counts db calls), isolation::revoked_owner_app_password_is_refused_at_every_call_site.

Perf: NOT measured yet. Release binaries are built and relayed to perf-01 with matching sha256 (#835 46be23e3: 0808fae9..., this PR 98afda51: 0d9cb86e..., in ~/perf/fh/{base835,fasthard}/calternal, runner ~/perf/fh/run.sh: 5 interleaved pairs, 10k and 100k, dev calternal-perf). I queued for ~/bin/perf-lock on both VMs at about 06:18 CEST, but fcd/run.sh 10 held perf-01 for over 2.5 h, so I withdrew my queued holds (both stopped, nothing held) rather than let them grab the locks unattended. The run needs a free window on both VMs. The change replaces one inline kind comparison with an exhaustive match and moves the same size/scope check into a shared function, so no new work lands on the hot path, but under the no-regression rule the flat result for sync.round_trip and open.note at 10k/100k still has to be shown before merge.

Gates on head `98afda51` (dev-01, `~/bin/slot`, Nix shell): - `cargo fmt --all --check`: clean - `cargo clippy --workspace --all-targets --locked -j4 -- -D warnings`: clean - `cargo test --locked -j4 -p calternal-core -p calternal-auth -p calternal-notes -p calternal-server -p calternal`: 0 failed. This covers the auth sharing and security tests (`security_regressions`, `isolation`) and the in-crate sharing tests. New tests all pass: `every_credential_kind_has_a_fast_path_decision`, `owner_note_check_has_one_size_and_scope_rule`, `owner_room_delivery_keeps_size_and_scope_checks_without_a_db_hop` (now counts db calls), `isolation::revoked_owner_app_password_is_refused_at_every_call_site`. **Perf: NOT measured yet.** Release binaries are built and relayed to perf-01 with matching sha256 (`#835 46be23e3`: `0808fae9...`, this PR `98afda51`: `0d9cb86e...`, in `~/perf/fh/{base835,fasthard}/calternal`, runner `~/perf/fh/run.sh`: 5 interleaved pairs, 10k and 100k, dev `calternal-perf`). I queued for `~/bin/perf-lock` on both VMs at about 06:18 CEST, but `fcd/run.sh 10` held perf-01 for over 2.5 h, so I withdrew my queued holds (both stopped, nothing held) rather than let them grab the locks unattended. The run needs a free window on both VMs. The change replaces one inline kind comparison with an exhaustive `match` and moves the same size/scope check into a shared function, so no new work lands on the hot path, but under the no-regression rule the flat result for `sync.round_trip` and `open.note` at 10k/100k still has to be shown before merge.
Author
Owner

perf-VM check: sharing fast-path hardening (staged fasthard vs base835)

Setup: perf-01 + perf-02 locks held together; run on perf-01. calternal-perf --sizes 10k,100k --runs 5 --cold-runs 0 --seed 1, 5 sessions with rotating order of three variants (base835, fasthard, and a byte-identical copy of base835 as the control; sha256 0808fae9… for both base copies, 0d9cb86e… for fasthard). Cells are median over the 5 sessions' per-session value [min-max], in ms.

size metric stat base835 fasthard base copy (control) fasthard vs base
10k open.note p50 0.858 [0.740-0.938] 0.881 [0.792-0.926] 0.891 [0.768-0.964] +2.6%
10k open.note p95 1.530 [1.343-1.757] 1.552 [1.348-1.905] 1.497 [1.435-1.668] +1.5%
10k sync.round_trip p50 0.307 [0.226-0.344] 0.324 [0.231-0.339] 0.316 [0.217-0.329] +5.6%
10k sync.round_trip p95 0.412 [0.359-0.420] 0.412 [0.333-0.430] 0.400 [0.325-0.439] +0.0%
100k open.note p50 1.226 [1.104-1.502] 1.290 [1.122-1.504] 1.387 [1.127-1.609] +5.2%
100k open.note p95 3.450 [2.784-3.971] 3.334 [3.034-3.990] 2.735 [2.523-3.581] -3.3%
100k sync.round_trip p50 0.287 [0.234-0.309] 0.312 [0.226-0.342] 0.310 [0.234-0.337] +8.9%
100k sync.round_trip p95 0.385 [0.346-0.413] 0.401 [0.333-0.421] 0.421 [0.361-0.424] +4.2%

Verdict: flat. The base copy differs from base by the same amount (control vs base up to +8%) and the ranges overlap on every row; no row moves outside the control's spread. Sub-millisecond metrics, so single-digit percent is about 0.02 ms.

Caveat: the staged fasthard binary was built at 06:14 CET; PR head is now 98afda51. If the head has code changes after that build, this does not cover them.

## perf-VM check: sharing fast-path hardening (staged `fasthard` vs `base835`) Setup: perf-01 + perf-02 locks held together; run on perf-01. `calternal-perf` `--sizes 10k,100k --runs 5 --cold-runs 0 --seed 1`, 5 sessions with rotating order of three variants (base835, fasthard, and a byte-identical copy of base835 as the control; sha256 `0808fae9…` for both base copies, `0d9cb86e…` for fasthard). Cells are median over the 5 sessions' per-session value [min-max], in ms. | size | metric | stat | base835 | fasthard | base copy (control) | fasthard vs base | |---|---|---|---|---|---|---| | 10k | open.note | p50 | 0.858 [0.740-0.938] | 0.881 [0.792-0.926] | 0.891 [0.768-0.964] | +2.6% | | 10k | open.note | p95 | 1.530 [1.343-1.757] | 1.552 [1.348-1.905] | 1.497 [1.435-1.668] | +1.5% | | 10k | sync.round_trip | p50 | 0.307 [0.226-0.344] | 0.324 [0.231-0.339] | 0.316 [0.217-0.329] | +5.6% | | 10k | sync.round_trip | p95 | 0.412 [0.359-0.420] | 0.412 [0.333-0.430] | 0.400 [0.325-0.439] | +0.0% | | 100k | open.note | p50 | 1.226 [1.104-1.502] | 1.290 [1.122-1.504] | 1.387 [1.127-1.609] | +5.2% | | 100k | open.note | p95 | 3.450 [2.784-3.971] | 3.334 [3.034-3.990] | 2.735 [2.523-3.581] | -3.3% | | 100k | sync.round_trip | p50 | 0.287 [0.234-0.309] | 0.312 [0.226-0.342] | 0.310 [0.234-0.337] | +8.9% | | 100k | sync.round_trip | p95 | 0.385 [0.346-0.413] | 0.401 [0.333-0.421] | 0.421 [0.361-0.424] | +4.2% | **Verdict: flat.** The base copy differs from base by the same amount (control vs base up to +8%) and the ranges overlap on every row; no row moves outside the control's spread. Sub-millisecond metrics, so single-digit percent is about 0.02 ms. Caveat: the staged `fasthard` binary was built at 06:14 CET; PR head is now `98afda51`. If the head has code changes after that build, this does not cover them.
Author
Owner

WebKit specs pending: std VMs paused. No WebKit run was queued on dev-02. The dev-01 process check found no active #909 gate job. WebKit validation can resume after the orchestrator lifts the pause.

WebKit specs pending: std VMs paused. No WebKit run was queued on dev-02. The dev-01 process check found no active #909 gate job. WebKit validation can resume after the orchestrator lifts the pause.
Author
Owner

#909 update: write-path cost and grant enforcement (review of #829, P2 1–3, P3 4 and 6)

Merged current dev (661123e94) into the branch. New head ca5f8b7a6.

P2 1: server-wide sweep moved off the write path. cleanup_sharing read every owner's sharing rows inside each shared-note write, grant, revoke and ack, while holding the auth.db writer. It now runs only from Auth::sweep_sharing_now, in its own transaction, on a background task, at most once a minute (schedule_sharing_sweep, after a sharing change commits). A request now only marks its own Installation for reset when it is dormant (mark_dormant_installation, one indexed insert). That's safe because every read filters by acks, and dormant devices are also marked on session use (mark_stale_installation). Test: shared_note_write_does_not_sweep_other_peoples_rows (20 writes leave other people's rows alone; the sweep still works).

P2 2: room recipients checked in one call. New NoteSharing::authorize_room_deliveries. The room's send_batch (used by broadcast_update and broadcast_authorized) answers owner fast-path connections in place, as before. It checks every other connection with one fence wait and one auth.db call per update, where it used to make one per recipient inside the actor. The single-recipient check is unchanged. Test: room_update_checks_every_recipient_in_one_database_call (4 recipients = 1 DB call, each result equal to the single check; 8 owner connections = 0 DB calls). The per-frame recheck in live.rs stays per recipient: it runs on that recipient's socket task, not in the room actor, and it is what makes revoke win over queued frames.

P2 3: grant enforcement per entry point. New crates/calternal/src/isolation/grants.rs. Alice shares one note with Bob (a real grant) and keeps a second note private. Each entry point has its own test proving Bob can't reach the private note:

  • notes.get with owner_id and without it;
  • notes.peek, the list, search.query, notes.history and notes.backlinks;
  • room join as owner/id and as a bare id;
  • the live frame recheck (epoch 0 and 1);
  • attachments and the change journal;
  • every registered action, with owner_id set to Alice.

Inventory test only_listed_actions_accept_an_owner_reference: only notes.get may declare owner_id/owner. Anything new fails until it is routed through Notes::resolve_resource and added to the list. There is no note export route and no WebDAV/CalDAV route to notes (DAV serves contacts only), so neither has a case; the route inventory test catches a new one.

P3 4: owner_fast_path doc reworded ("kinds whose access the sharing layer never rechecks; revocation is enforced by authentication and the socket sign-out").
P3 5: already fixed on this branch (hold_work).
P3 6: notes.get by the owner no longer runs the second check after the read; only cross-owner reads are rechecked.

Gates on ca5f8b7a6

gate where result
cargo fmt --all --check dev-01, ~/bin/slot, nix clean
cargo clippy --workspace --all-targets --locked -j4 -- -D warnings dev-01 clean
cargo test --locked -j4 -p calternal-core -p calternal-auth -p calternal-notes -p calternal-server -p calternal --no-fail-fast dev-01 908 passed, 0 failed, 17 ignored (39 test binaries)
Playwright editor big-paste typing-latency fresh-note CI=1 --workers=1 --retries=0 dev-01 chromium-1440 / chromium-390 19 / 16 passed, 0 failed
same specs dev-02 webkit-1440 / webkit-390 16 / 13 passed, 0 failed
perf ABAB ×5 vs dev + byte-identical dev control perf-01 (perf-01 + perf-02 locks) queued behind files-s1, Mail and p928; numbers follow

Perf plan: the calternal-perf binary from dev for all variants. --sizes 10k,100k --runs 5 --cold-runs 0 --idle-secs 0 --seed 1, 5 sessions in rotating order (dev/head/ctl). Rows compared: notes.save, sync.save_ack, sync.durable_write, sync.round_trip (room and executor), storm.typing, search.query, open.note, startup.healthz. sha256: dev = ctl f66e5c90…, head ffe394fa…, harness 6ee74b0a….

Not measured here: shared-note typing with many recipients, because cross-owner sharing is still off, so recipients can't join. #842 turns it on and adds that perf row.

## #909 update: write-path cost and grant enforcement (review of #829, P2 1–3, P3 4 and 6) Merged current dev (`661123e94`) into the branch. New head `ca5f8b7a6`. **P2 1: server-wide sweep moved off the write path.** `cleanup_sharing` read every owner's sharing rows inside each shared-note write, grant, revoke and ack, while holding the auth.db writer. It now runs only from `Auth::sweep_sharing_now`, in its own transaction, on a background task, at most once a minute (`schedule_sharing_sweep`, after a sharing change commits). A request now only marks its own Installation for reset when it is dormant (`mark_dormant_installation`, one indexed insert). That's safe because every read filters by acks, and dormant devices are also marked on session use (`mark_stale_installation`). Test: `shared_note_write_does_not_sweep_other_peoples_rows` (20 writes leave other people's rows alone; the sweep still works). **P2 2: room recipients checked in one call.** New `NoteSharing::authorize_room_deliveries`. The room's `send_batch` (used by `broadcast_update` and `broadcast_authorized`) answers owner fast-path connections in place, as before. It checks every other connection with one fence wait and one auth.db call per update, where it used to make one per recipient inside the actor. The single-recipient check is unchanged. Test: `room_update_checks_every_recipient_in_one_database_call` (4 recipients = 1 DB call, each result equal to the single check; 8 owner connections = 0 DB calls). The per-frame recheck in `live.rs` stays per recipient: it runs on that recipient's socket task, not in the room actor, and it is what makes revoke win over queued frames. **P2 3: grant enforcement per entry point.** New `crates/calternal/src/isolation/grants.rs`. Alice shares one note with Bob (a real grant) and keeps a second note private. Each entry point has its own test proving Bob can't reach the private note: - `notes.get` with `owner_id` and without it; - `notes.peek`, the list, `search.query`, `notes.history` and `notes.backlinks`; - room join as `owner/id` and as a bare id; - the live frame recheck (epoch 0 and 1); - attachments and the change journal; - every registered action, with `owner_id` set to Alice. Inventory test `only_listed_actions_accept_an_owner_reference`: only `notes.get` may declare `owner_id`/`owner`. Anything new fails until it is routed through `Notes::resolve_resource` and added to the list. There is no note export route and no WebDAV/CalDAV route to notes (DAV serves contacts only), so neither has a case; the route inventory test catches a new one. **P3 4:** `owner_fast_path` doc reworded ("kinds whose access the sharing layer never rechecks; revocation is enforced by authentication and the socket sign-out"). **P3 5:** already fixed on this branch (`hold_work`). **P3 6:** `notes.get` by the owner no longer runs the second check after the read; only cross-owner reads are rechecked. ### Gates on `ca5f8b7a6` | gate | where | result | |---|---|---| | `cargo fmt --all --check` | dev-01, `~/bin/slot`, nix | clean | | `cargo clippy --workspace --all-targets --locked -j4 -- -D warnings` | dev-01 | clean | | `cargo test --locked -j4 -p calternal-core -p calternal-auth -p calternal-notes -p calternal-server -p calternal --no-fail-fast` | dev-01 | 908 passed, 0 failed, 17 ignored (39 test binaries) | | Playwright `editor big-paste typing-latency fresh-note` `CI=1 --workers=1 --retries=0` | dev-01 chromium-1440 / chromium-390 | 19 / 16 passed, 0 failed | | same specs | dev-02 webkit-1440 / webkit-390 | 16 / 13 passed, 0 failed | | perf ABAB ×5 vs dev + byte-identical dev control | perf-01 (perf-01 + perf-02 locks) | queued behind files-s1, Mail and p928; numbers follow | Perf plan: the `calternal-perf` binary from dev for all variants. `--sizes 10k,100k --runs 5 --cold-runs 0 --idle-secs 0 --seed 1`, 5 sessions in rotating order (dev/head/ctl). Rows compared: `notes.save`, `sync.save_ack`, `sync.durable_write`, `sync.round_trip` (room and executor), `storm.typing`, `search.query`, `open.note`, `startup.healthz`. sha256: dev = ctl `f66e5c90…`, head `ffe394fa…`, harness `6ee74b0a…`. Not measured here: shared-note typing with many recipients, because cross-owner sharing is still off, so recipients can't join. #842 turns it on and adds that perf row.
Author
Owner

perf-VM ABAB ×5: #909 head ca5f8b7a6 vs dev 661123e94, with a byte-identical dev control

Setup:

  • perf-01 + perf-02 locks held together; measured on perf-01 in the current epoch, 11-09 13:48–15:25 IST.
  • calternal-perf from dev for every variant (sha256 6ee74b0a…): --sizes 10k,100k --runs 5 --cold-runs 0 --idle-secs 0 --seed 1.
  • 5 sessions in rotating order: dev/head/ctl, head/ctl/dev, ctl/dev/head, dev/head/ctl, head/ctl/dev.
  • Binaries: dev = ctl f66e5c90…, head ffe394fa….
  • Cells: median of the 5 sessions' per-session value [min–max], in ms.
  • notes.save isn't a separate metric in this harness; saves are sync.save_ack / sync.durable_write (room → executor) and storm.save_ack.
size metric stat dev head ctl (control) head vs dev control vs dev verdict
10k sync.save_ack p50 3.724 [3.696-3.757] 3.679 [3.647-3.800] 3.697 [3.633-3.734] -1.2% -0.7% flat
10k sync.save_ack p95 5.151 [4.796-6.040] 5.633 [5.364-5.931] 5.558 [4.874-6.431] +9.4% +7.9% flat
10k sync.durable_write p50 25.167 [24.619-25.302] 24.886 [24.615-25.354] 24.944 [24.729-25.317] -1.1% -0.9% flat
10k sync.durable_write p95 33.560 [30.668-38.542] 34.102 [30.083-36.937] 35.339 [31.577-41.895] +1.6% +5.3% flat
10k sync.round_trip p50 0.210 [0.209-0.221] 0.215 [0.211-0.219] 0.216 [0.209-0.223] +2.4% +3.2% flat
10k sync.round_trip p95 0.348 [0.341-0.363] 0.352 [0.340-0.370] 0.345 [0.332-0.381] +1.3% -0.8% flat
10k storm.typing p50 2.843 [2.762-2.888] 2.888 [2.839-2.928] 2.860 [2.841-2.919] +1.6% +0.6% flat
10k storm.typing p95 2.912 [2.857-2.952] 2.945 [2.919-4.149] 2.987 [2.926-3.758] +1.1% +2.6% flat
10k storm.save_ack p50 7.472 [7.377-7.569] 7.564 [7.507-7.605] 7.509 [7.447-7.579] +1.2% +0.5% flat
10k storm.save_ack p95 14.943 [13.535-15.635] 16.165 [15.493-17.147] 15.696 [14.606-16.902] +8.2% +5.0% flat
10k search.query p50 1.614 [1.530-1.672] 1.622 [1.544-1.735] 1.621 [1.582-1.763] +0.5% +0.5% flat
10k search.query p95 4.354 [4.173-4.370] 4.104 [4.048-4.995] 4.255 [4.230-4.559] -5.8% -2.3% flat
10k search.first_query p50 4.517 [3.989-4.786] 4.186 [3.880-5.209] 5.173 [4.312-5.399] -7.3% +14.5% flat
10k search.first_query p95 28.668 [26.806-37.468] 29.416 [26.940-82.191] 36.492 [27.289-45.241] +2.6% +27.3% flat
10k open.note p50 0.711 [0.689-0.761] 0.768 [0.716-0.788] 0.749 [0.734-0.761] +8.0% +5.4% CHECK
10k open.note p95 1.409 [1.233-1.454] 1.304 [1.282-1.478] 1.257 [1.186-1.337] -7.4% -10.7% flat
10k open.api_get p50 4.014 [3.940-6.106] 4.012 [3.834-4.110] 4.026 [3.894-5.095] -0.0% +0.3% flat
10k open.api_get p95 12.287 [10.532-14.888] 13.973 [7.761-16.484] 10.503 [8.983-24.584] +13.7% -14.5% flat
10k startup.healthz p50 6.010 [4.842-7.118] 4.827 [4.781-5.043] 4.919 [4.861-5.236] -19.7% -18.2% flat
10k startup.healthz p95 8.713 [4.894-18.570] 6.108 [4.902-7.623] 7.551 [5.090-8.066] -29.9% -13.3% flat
10k home.first_page p50 2.665 [2.204-3.291] 2.198 [2.045-2.673] 2.501 [1.992-2.919] -17.5% -6.2% flat
10k home.first_page p95 3.502 [2.530-5.972] 4.789 [2.403-5.964] 6.017 [2.522-9.347] +36.7% +71.8% flat
10k journal.catch_up p50 28.520 [27.674-29.001] 29.186 [28.909-30.117] 28.408 [27.757-30.582] +2.3% -0.4% flat
10k journal.catch_up p95 34.923 [30.424-38.037] 33.963 [31.974-37.807] 33.847 [30.785-36.483] -2.7% -3.1% flat
100k sync.save_ack p50 3.715 [3.707-3.812] 3.769 [3.686-3.811] 3.684 [3.661-3.924] +1.5% -0.8% flat
100k sync.save_ack p95 5.710 [5.468-6.018] 5.375 [5.136-6.553] 5.508 [5.066-6.414] -5.9% -3.5% flat
100k sync.durable_write p50 25.321 [24.875-26.708] 25.261 [25.159-26.863] 24.941 [24.444-27.804] -0.2% -1.5% flat
100k sync.durable_write p95 39.910 [32.086-42.757] 35.486 [29.851-40.155] 38.577 [35.501-39.992] -11.1% -3.3% flat
100k sync.round_trip p50 0.224 [0.223-0.235] 0.235 [0.226-0.251] 0.224 [0.217-0.241] +4.8% -0.2% flat
100k sync.round_trip p95 0.361 [0.354-0.386] 0.352 [0.343-0.411] 0.366 [0.348-0.387] -2.3% +1.3% flat
100k storm.typing p50 2.961 [2.829-2.984] 2.955 [2.830-3.017] 2.883 [2.824-2.915] -0.2% -2.6% flat
100k storm.typing p95 3.300 [3.090-5.191] 3.238 [3.112-4.711] 3.264 [3.197-3.868] -1.9% -1.1% flat
100k storm.save_ack p50 7.627 [7.483-7.706] 7.475 [7.267-7.847] 7.525 [7.456-7.674] -2.0% -1.3% flat
100k storm.save_ack p95 16.799 [15.580-17.649] 16.072 [14.607-17.960] 15.340 [15.145-15.950] -4.3% -8.7% flat
100k search.query p50 1.859 [1.806-1.881] 1.865 [1.733-2.041] 1.797 [1.731-1.899] +0.3% -3.4% flat
100k search.query p95 4.160 [3.897-4.950] 4.426 [4.128-5.125] 4.420 [4.107-4.645] +6.4% +6.2% flat
100k search.first_query p50 6.104 [5.198-8.232] 6.467 [4.753-17.785] 5.330 [4.541-16.858] +5.9% -12.7% flat
100k search.first_query p95 30.093 [26.660-61.694] 28.439 [26.388-37.735] 27.618 [26.401-42.153] -5.5% -8.2% flat
100k open.note p50 2.825 [2.359-3.765] 2.905 [2.230-3.205] 2.496 [2.047-3.378] +2.8% -11.7% flat
100k open.note p95 6.535 [4.895-8.371] 6.754 [4.564-9.623] 7.605 [6.393-10.124] +3.4% +16.4% flat
100k open.api_get p50 4.417 [4.252-4.617] 4.787 [4.206-4.804] 4.564 [4.301-7.639] +8.4% +3.3% flat
100k open.api_get p95 14.273 [8.677-20.603] 15.418 [8.926-17.845] 9.845 [9.059-16.511] +8.0% -31.0% flat
100k startup.healthz p50 5.263 [4.988-7.425] 5.275 [4.866-7.143] 6.128 [5.107-6.333] +0.2% +16.4% flat
100k startup.healthz p95 9.628 [7.046-11.004] 12.191 [7.079-18.184] 7.362 [7.203-9.769] +26.6% -23.5% CHECK
100k home.first_page p50 3.486 [2.829-3.851] 3.248 [2.760-4.107] 3.248 [3.050-4.430] -6.8% -6.8% flat
100k home.first_page p95 4.269 [3.661-5.972] 6.049 [4.828-7.215] 5.763 [3.806-10.416] +41.7% +35.0% flat
100k journal.catch_up p50 292.459 [289.758-298.633] 294.401 [283.385-301.829] 281.060 [278.696-311.007] +0.7% -3.9% flat
100k journal.catch_up p95 311.287 [298.814-331.160] 333.111 [298.032-346.212] 310.090 [290.672-357.472] +7.0% -0.4% flat

Reading. Every row is flat against the control's spread except two, which sit just outside it:

  • 10k open.note p50 +0.057 ms (+8.0%): the control itself moved +5.4%, and head's range [0.716–0.788] overlaps dev+control [0.689–0.761]. The 10k p95 is −7.4%, and the 100k p50/p95 are within the control. Room join for the owner is unchanged by this PR: join still runs resolve_resource + owner_delivery exactly as on dev.
  • 100k startup.healthz p95 +26.6%: one run of 18.2 ms among head's five (median 12.2, range 7.1–18.2). The 10k startup is −20%/−30%. Startup code is unchanged; the new sweep only starts after a sharing change commits.

Neither row touches code this PR changes. Because the rule is "no regression, even within noise", #842's perf turn (which contains #909) re-measures both against the same dev binary. If either still shows up there, I'll profile it before asking for a merge. Every write path this PR changes (shared-note admit, room broadcast, notes.get by owner) is flat or better.

Not measured here: typing in a note shared with many recipients. The batched room check is unit-tested (4 recipients = 1 DB call); the harness has no cross-owner scenario yet.

## perf-VM ABAB ×5: #909 head `ca5f8b7a6` vs dev `661123e94`, with a byte-identical dev control Setup: - perf-01 + perf-02 locks held together; measured on perf-01 in the current epoch, 11-09 13:48–15:25 IST. - `calternal-perf` from dev for every variant (sha256 `6ee74b0a…`): `--sizes 10k,100k --runs 5 --cold-runs 0 --idle-secs 0 --seed 1`. - 5 sessions in rotating order: dev/head/ctl, head/ctl/dev, ctl/dev/head, dev/head/ctl, head/ctl/dev. - Binaries: dev = ctl `f66e5c90…`, head `ffe394fa…`. - Cells: median of the 5 sessions' per-session value [min–max], in ms. - `notes.save` isn't a separate metric in this harness; saves are `sync.save_ack` / `sync.durable_write` (room → executor) and `storm.save_ack`. | size | metric | stat | dev | head | ctl (control) | head vs dev | control vs dev | verdict | |---|---|---|---|---|---|---|---|---| | 10k | sync.save_ack | p50 | 3.724 [3.696-3.757] | 3.679 [3.647-3.800] | 3.697 [3.633-3.734] | -1.2% | -0.7% | flat | | 10k | sync.save_ack | p95 | 5.151 [4.796-6.040] | 5.633 [5.364-5.931] | 5.558 [4.874-6.431] | +9.4% | +7.9% | flat | | 10k | sync.durable_write | p50 | 25.167 [24.619-25.302] | 24.886 [24.615-25.354] | 24.944 [24.729-25.317] | -1.1% | -0.9% | flat | | 10k | sync.durable_write | p95 | 33.560 [30.668-38.542] | 34.102 [30.083-36.937] | 35.339 [31.577-41.895] | +1.6% | +5.3% | flat | | 10k | sync.round_trip | p50 | 0.210 [0.209-0.221] | 0.215 [0.211-0.219] | 0.216 [0.209-0.223] | +2.4% | +3.2% | flat | | 10k | sync.round_trip | p95 | 0.348 [0.341-0.363] | 0.352 [0.340-0.370] | 0.345 [0.332-0.381] | +1.3% | -0.8% | flat | | 10k | storm.typing | p50 | 2.843 [2.762-2.888] | 2.888 [2.839-2.928] | 2.860 [2.841-2.919] | +1.6% | +0.6% | flat | | 10k | storm.typing | p95 | 2.912 [2.857-2.952] | 2.945 [2.919-4.149] | 2.987 [2.926-3.758] | +1.1% | +2.6% | flat | | 10k | storm.save_ack | p50 | 7.472 [7.377-7.569] | 7.564 [7.507-7.605] | 7.509 [7.447-7.579] | +1.2% | +0.5% | flat | | 10k | storm.save_ack | p95 | 14.943 [13.535-15.635] | 16.165 [15.493-17.147] | 15.696 [14.606-16.902] | +8.2% | +5.0% | flat | | 10k | search.query | p50 | 1.614 [1.530-1.672] | 1.622 [1.544-1.735] | 1.621 [1.582-1.763] | +0.5% | +0.5% | flat | | 10k | search.query | p95 | 4.354 [4.173-4.370] | 4.104 [4.048-4.995] | 4.255 [4.230-4.559] | -5.8% | -2.3% | flat | | 10k | search.first_query | p50 | 4.517 [3.989-4.786] | 4.186 [3.880-5.209] | 5.173 [4.312-5.399] | -7.3% | +14.5% | flat | | 10k | search.first_query | p95 | 28.668 [26.806-37.468] | 29.416 [26.940-82.191] | 36.492 [27.289-45.241] | +2.6% | +27.3% | flat | | 10k | open.note | p50 | 0.711 [0.689-0.761] | 0.768 [0.716-0.788] | 0.749 [0.734-0.761] | +8.0% | +5.4% | CHECK | | 10k | open.note | p95 | 1.409 [1.233-1.454] | 1.304 [1.282-1.478] | 1.257 [1.186-1.337] | -7.4% | -10.7% | flat | | 10k | open.api_get | p50 | 4.014 [3.940-6.106] | 4.012 [3.834-4.110] | 4.026 [3.894-5.095] | -0.0% | +0.3% | flat | | 10k | open.api_get | p95 | 12.287 [10.532-14.888] | 13.973 [7.761-16.484] | 10.503 [8.983-24.584] | +13.7% | -14.5% | flat | | 10k | startup.healthz | p50 | 6.010 [4.842-7.118] | 4.827 [4.781-5.043] | 4.919 [4.861-5.236] | -19.7% | -18.2% | flat | | 10k | startup.healthz | p95 | 8.713 [4.894-18.570] | 6.108 [4.902-7.623] | 7.551 [5.090-8.066] | -29.9% | -13.3% | flat | | 10k | home.first_page | p50 | 2.665 [2.204-3.291] | 2.198 [2.045-2.673] | 2.501 [1.992-2.919] | -17.5% | -6.2% | flat | | 10k | home.first_page | p95 | 3.502 [2.530-5.972] | 4.789 [2.403-5.964] | 6.017 [2.522-9.347] | +36.7% | +71.8% | flat | | 10k | journal.catch_up | p50 | 28.520 [27.674-29.001] | 29.186 [28.909-30.117] | 28.408 [27.757-30.582] | +2.3% | -0.4% | flat | | 10k | journal.catch_up | p95 | 34.923 [30.424-38.037] | 33.963 [31.974-37.807] | 33.847 [30.785-36.483] | -2.7% | -3.1% | flat | | 100k | sync.save_ack | p50 | 3.715 [3.707-3.812] | 3.769 [3.686-3.811] | 3.684 [3.661-3.924] | +1.5% | -0.8% | flat | | 100k | sync.save_ack | p95 | 5.710 [5.468-6.018] | 5.375 [5.136-6.553] | 5.508 [5.066-6.414] | -5.9% | -3.5% | flat | | 100k | sync.durable_write | p50 | 25.321 [24.875-26.708] | 25.261 [25.159-26.863] | 24.941 [24.444-27.804] | -0.2% | -1.5% | flat | | 100k | sync.durable_write | p95 | 39.910 [32.086-42.757] | 35.486 [29.851-40.155] | 38.577 [35.501-39.992] | -11.1% | -3.3% | flat | | 100k | sync.round_trip | p50 | 0.224 [0.223-0.235] | 0.235 [0.226-0.251] | 0.224 [0.217-0.241] | +4.8% | -0.2% | flat | | 100k | sync.round_trip | p95 | 0.361 [0.354-0.386] | 0.352 [0.343-0.411] | 0.366 [0.348-0.387] | -2.3% | +1.3% | flat | | 100k | storm.typing | p50 | 2.961 [2.829-2.984] | 2.955 [2.830-3.017] | 2.883 [2.824-2.915] | -0.2% | -2.6% | flat | | 100k | storm.typing | p95 | 3.300 [3.090-5.191] | 3.238 [3.112-4.711] | 3.264 [3.197-3.868] | -1.9% | -1.1% | flat | | 100k | storm.save_ack | p50 | 7.627 [7.483-7.706] | 7.475 [7.267-7.847] | 7.525 [7.456-7.674] | -2.0% | -1.3% | flat | | 100k | storm.save_ack | p95 | 16.799 [15.580-17.649] | 16.072 [14.607-17.960] | 15.340 [15.145-15.950] | -4.3% | -8.7% | flat | | 100k | search.query | p50 | 1.859 [1.806-1.881] | 1.865 [1.733-2.041] | 1.797 [1.731-1.899] | +0.3% | -3.4% | flat | | 100k | search.query | p95 | 4.160 [3.897-4.950] | 4.426 [4.128-5.125] | 4.420 [4.107-4.645] | +6.4% | +6.2% | flat | | 100k | search.first_query | p50 | 6.104 [5.198-8.232] | 6.467 [4.753-17.785] | 5.330 [4.541-16.858] | +5.9% | -12.7% | flat | | 100k | search.first_query | p95 | 30.093 [26.660-61.694] | 28.439 [26.388-37.735] | 27.618 [26.401-42.153] | -5.5% | -8.2% | flat | | 100k | open.note | p50 | 2.825 [2.359-3.765] | 2.905 [2.230-3.205] | 2.496 [2.047-3.378] | +2.8% | -11.7% | flat | | 100k | open.note | p95 | 6.535 [4.895-8.371] | 6.754 [4.564-9.623] | 7.605 [6.393-10.124] | +3.4% | +16.4% | flat | | 100k | open.api_get | p50 | 4.417 [4.252-4.617] | 4.787 [4.206-4.804] | 4.564 [4.301-7.639] | +8.4% | +3.3% | flat | | 100k | open.api_get | p95 | 14.273 [8.677-20.603] | 15.418 [8.926-17.845] | 9.845 [9.059-16.511] | +8.0% | -31.0% | flat | | 100k | startup.healthz | p50 | 5.263 [4.988-7.425] | 5.275 [4.866-7.143] | 6.128 [5.107-6.333] | +0.2% | +16.4% | flat | | 100k | startup.healthz | p95 | 9.628 [7.046-11.004] | 12.191 [7.079-18.184] | 7.362 [7.203-9.769] | +26.6% | -23.5% | CHECK | | 100k | home.first_page | p50 | 3.486 [2.829-3.851] | 3.248 [2.760-4.107] | 3.248 [3.050-4.430] | -6.8% | -6.8% | flat | | 100k | home.first_page | p95 | 4.269 [3.661-5.972] | 6.049 [4.828-7.215] | 5.763 [3.806-10.416] | +41.7% | +35.0% | flat | | 100k | journal.catch_up | p50 | 292.459 [289.758-298.633] | 294.401 [283.385-301.829] | 281.060 [278.696-311.007] | +0.7% | -3.9% | flat | | 100k | journal.catch_up | p95 | 311.287 [298.814-331.160] | 333.111 [298.032-346.212] | 310.090 [290.672-357.472] | +7.0% | -0.4% | flat | **Reading.** Every row is flat against the control's spread except two, which sit just outside it: - **10k `open.note` p50 +0.057 ms (+8.0%)**: the control itself moved +5.4%, and head's range [0.716–0.788] overlaps dev+control [0.689–0.761]. The 10k p95 is −7.4%, and the 100k p50/p95 are within the control. Room join for the owner is unchanged by this PR: `join` still runs `resolve_resource` + `owner_delivery` exactly as on dev. - **100k `startup.healthz` p95 +26.6%**: one run of 18.2 ms among head's five (median 12.2, range 7.1–18.2). The 10k startup is −20%/−30%. Startup code is unchanged; the new sweep only starts after a sharing change commits. Neither row touches code this PR changes. Because the rule is "no regression, even within noise", **#842's perf turn (which contains #909) re-measures both** against the same dev binary. If either still shows up there, I'll profile it before asking for a merge. Every write path this PR changes (shared-note admit, room broadcast, `notes.get` by owner) is flat or better. Not measured here: typing in a note shared with many recipients. The batched room check is unit-tested (4 recipients = 1 DB call); the harness has no cross-owner scenario yet.
Author
Owner

READY FOR MERGE: head ca5f8b7a6

gate result
fmt / clippy -D warnings / 908 Rust tests (core, auth, notes, server, calternal) clean / clean / 0 failed (#issuecomment-88466)
grant enforcement per entry point + owner-reference inventory (isolation::grants) pass
Playwright editor / big-paste / typing-latency / fresh-note, Chromium + WebKit, 1440 + 390 19/16/16/13 passed, 0 failed
perf ABAB ×5 vs dev + control flat on every path this PR changes (#issuecomment-88625). The two rows outside the control (10k open.note p50, 100k healthz p95) are flat in the second, independent ABAB in #842's turn, which contains this PR (#842 #issuecomment-88749)

Fixes the review on #829: P2 1 (sweep off the write path), P2 2 (batched recipient checks), P2 3 (enforcement + inventory), P3 4 and P3 6; P3 5 was already fixed. Cross-owner sharing stays off here; #842 turns it on.

🤖 Generated with Claude Code

## READY FOR MERGE: head `ca5f8b7a6` | gate | result | |---|---| | fmt / clippy `-D warnings` / 908 Rust tests (core, auth, notes, server, calternal) | clean / clean / 0 failed ([#issuecomment-88466](https://git.kayg.org/kayg/calternal/pulls/909#issuecomment-88466)) | | grant enforcement per entry point + owner-reference inventory (`isolation::grants`) | pass | | Playwright editor / big-paste / typing-latency / fresh-note, Chromium + WebKit, 1440 + 390 | 19/16/16/13 passed, 0 failed | | perf ABAB ×5 vs dev + control | flat on every path this PR changes ([#issuecomment-88625](https://git.kayg.org/kayg/calternal/pulls/909#issuecomment-88625)). The two rows outside the control (10k open.note p50, 100k healthz p95) are **flat in the second, independent ABAB** in #842's turn, which contains this PR ([#842 #issuecomment-88749](https://git.kayg.org/kayg/calternal/pulls/842#issuecomment-88749)) | Fixes the review on #829: P2 1 (sweep off the write path), P2 2 (batched recipient checks), P2 3 (enforcement + inventory), P3 4 and P3 6; P3 5 was already fixed. Cross-owner sharing stays off here; #842 turns it on. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Merged into dev locally (head ca5f8b7a6 is in origin/dev); closing.

Merged into dev locally (head `ca5f8b7a6` is in origin/dev); closing.
kayg closed this pull request 2026-11-09 18:55:08 +00:00
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!909
No description provided.