Skip to content

feat: editor polish, version bump & new features (v0.3.0) - #14

Merged
cuttlefisch merged 13 commits into
mainfrom
feat/editor-polish-v0.3.0
Apr 20, 2026
Merged

feat: editor polish, version bump & new features (v0.3.0)#14
cuttlefisch merged 13 commits into
mainfrom
feat/editor-polish-v0.3.0

Conversation

@cuttlefisch

@cuttlefisch cuttlefisch commented Apr 19, 2026

Copy link
Copy Markdown
Owner

Summary

v0.3.0 branch: editor polish, debugging powerhouse, documentation overhaul.

Editor Polish

  • Version bump 0.1.0 to 0.3.0 (all 11 crates)
  • BackTab/Shift-Tab, font zoom, read command, per-buffer projects
  • Doom-style status line (git branch, project, AI tier)
  • AI agent launcher (SPC a a), session persistence
  • Vimtutor onboarding: 12 interactive tutorial lessons

Debugging Powerhouse

  • Watchdog: event loop stall detection with thread backtraces
  • Introspect: AI diagnostic snapshot (threads/perf/locks/buffers/shell/ai)
  • Event recording: record-start/stop/save for reproducible debugging
  • DAP attach/evaluate: debug running processes, evaluate expressions
  • Conditional/logpoint breakpoints, lock contention tracking, FairMutex fix

Documentation

  • Doom-style init.scm: 8 sections, all 14 options, hooks, keybindings, AI config
  • Tutor KB: 2 new lessons (Debugging, Observability) + 4 concept nodes
  • Command registry: debug-attach, debug-eval, record-start/stop/save
  • ROADMAP/CLAUDE.md updated with all new capabilities

CI

  • --check-config CLI flag validates init.scm without launching editor
  • New CI E2E step: builds TUI binary, runs --check-config
  • Clippy fix: EventRecorder derivable_impls lint
  • 1,484 tests passing, 0 failures

Test plan

  • make ci passes
  • mae --check-config validates init.scm
  • :tutor navigates to Lessons 11, 12
  • :help concept:watchdog displays correctly
  • Command palette shows :record-start, :debug-attach

cuttlefisch and others added 2 commits April 19, 2026 13:59
Version bump 0.1.0 → 0.3.0 with workspace.package.version.
BackTab/Shift-Tab support across terminal, GUI, and PTY paths.
Font zoom keybindings (Ctrl+=/-/0) wired to GuiRenderer.
:read/:r command for shell output and file insertion.
Per-buffer project roots with active_project() fallback.
Doom-style status line: git branch, project, file %, AI tier.
AI agent launcher (SPC a a) with configurable ai_editor option.
KB project nodes from .project files, .project format alignment.
Session persistence (:session-save/:session-load, per-project JSON).
Vimtutor-style onboarding (:tutor, SPC h t).
Sample config template (assets/sample-config.toml).
OptionRegistry: ai_editor, ai_tier, restore_session options.
README badges updated (1,508 tests), ROADMAP updated.
Doom-style init.scm added to Phase 7 M4 roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Project switching: auto-detect and switch project context when opening
  files from different project roots (Doom Emacs parity). SPC p p palette
  now accepts typed paths for adding new projects. :add-project and
  :remove-project ex-commands added.
- Config wiring: ai.editor and editor.restore_session now loaded from
  config.toml and applied on startup. persist_editor_preference supports
  new keys.
- AI tool gaps: editor_state tool now reports git_branch and project_root.
  refresh_git_branch() called on startup and project switch.
- CPU usage fix: TUI loop uses dirty-flag rendering — only repaints when
  state actually changed. Shell tick reduced from 30fps to 20fps (50ms).
  sysinfo sampling interval doubled (every ~6s instead of ~2s).
- Roadmap updated with variable-height font priorities and KB-based tutor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cuttlefisch
cuttlefisch force-pushed the feat/editor-polish-v0.3.0 branch from 1b8cb74 to c7e27de Compare April 19, 2026 12:11
cuttlefisch and others added 11 commits April 19, 2026 14:38
- Tutor: 11 KB nodes replace static string; :tutor opens help with
  Tab/Enter/C-o navigation and cross-links to concepts/commands
- Shell exit: auto-close buffer on process exit (no more blank frames)
- Agent shells (SPC a a): tagged, auto-close with distinct status msg
- find-file (SPC f f): use active_project_root() with CWD fallback
- Shell CPU idle: generation-based dirty tracking (~0% vs 30%)
- Empty rope panic: guard set_cursor_from_offset for shell buffers
- Debug stats: show FPS instead of frame timing (μs → fps)

1,509 tests, clippy clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix stale test counts (521→1,509), feature checklist, milestone deps
- Mark M3 font zoom + BackTab as done, M8 click/scroll done in M3
- Add v0.3.0 items: tutor→KB, shell auto-close, CPU fix, find-file fix
- Update next priority order (Phase 7 tutor done, M3 remaining next)
- Sync CLAUDE.md with latest completed features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The event loop was rendering immediately on every event with no frame
interval, causing 120fps at idle and ~30-40% CPU even when nothing
changes. Now uses Alacritty/Helix pattern:

- MIN_FRAME_INTERVAL = 16.67ms (~60fps cap)
- First event after idle renders immediately (no input latency)
- Rapid events coalesce: render deferred to next frame slot via
  tokio timer in select! block
- Combined with generation-based shell dirty tracking, idle CPU
  should drop dramatically

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
open-ai-agent (SPC a a) now spawns the agent command directly as the
PTY program via spawn_command(), not inside the user's shell. When the
agent exits (e.g., user declines trust), the PTY exits immediately
and the buffer auto-closes. Previously the parent shell stayed alive
after the agent exited, leaving a useless terminal.

Also adds ShellTerminal::spawn_command() for running arbitrary commands
as PTY programs with proper lifecycle tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Shell PTY augments PATH with ~/.local/bin, ~/.cargo/bin, ~/bin,
  ~/.npm-global/bin when launched from desktop (no login shell)
- set_status() echoes to *Messages* buffer for review via SPC b m
- Dashboard buffer type: splash renders on dashboard, not scratch;
  SPC x toggles scratch, SPC h d returns to dashboard
- TUI only marks dirty on key press/resize, not mouse/focus events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- shell_render: use already-locked term ref for display_offset (deadlock fix)
- main.rs: clamp shell_dims_for_buffer to prevent underflow
- terminal.rs: defensive spawn validation + theme color API
- key_handling: is_splash_visible uses BufferKind::Dashboard
- AI tools: render_inspect, theme_inspect, mouse_event, shell_scrollback
- Theme: ANSI color export, improved gruvbox-light/solarized-dark
- GUI: popup rendering cleanup, shell scrollback rendering
- Shell lifecycle: theme-aware env vars, expanded spawn logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…AP attach/evaluate

Tier 1: Watchdog Thread + Structured Tracing
- watchdog.rs: standalone OS thread monitors main-thread heartbeat via AtomicU64
- After 6s stall: dumps /proc/self/task thread states to log
- After 10s stall: captures full backtrace
- trace! instrumentation in shell_render, terminal lock sites, shell lifecycle
- debug! render branch logging in GUI

Tier 2: Live Self-Debug + AI Introspection
- lock_stats.rs: global lock contention tracking (acquisitions, wait times per site)
- PerfStats: stall_count, jank_count, anomaly_log ring buffer (100 entries)
- Self-debug: new Performance and Lock State scopes with live refresh
- introspect AI tool: comprehensive diagnostics (threads/perf/locks/buffers/shell/ai)

Tier 3: Event Recording
- event_record.rs: 10,000-event ring buffer with microsecond timestamps
- Commands: :record-start, :record-stop, :record-save <path>
- event_recording AI tool: start/stop/status/dump actions

Tier 4: DAP Attach + Evaluate + Conditional Breakpoints
- dap_attach_with_adapter: attach to running process by PID (lldb/codelldb)
- dap_evaluate: expression evaluation in debuggee context
- BreakpointSpec: condition + hit_condition support through full stack
- dap_disconnect AI tool with terminate_debuggee param
- :debug-attach <adapter> <pid> and :debug-eval <expr> commands
- DapClient::evaluate(), DapCommand::Evaluate, DapTaskEvent::EvaluateResult

1,517 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Doom-style init.scm: 8 sections (UI, Theme, Options, Keys, AI,
  Shell, Hooks, Commands), all 14 options documented with defaults
- Tutor KB: Lesson 11 (Debugging) + Lesson 12 (Observability),
  4 concept nodes (watchdog, event-recording, dap-attach, introspect)
- Command registry: debug-attach, debug-eval, record-start/stop/save
  registered for command palette and help discoverability
- --check-config CLI flag: validate init.scm + config.toml without
  launching the editor (headless, for CI)
- CI E2E step: new e2e/check-config job builds TUI binary and runs
  --check-config to catch init.scm parse errors
- Clippy fix: EventRecorder derivable_impls lint (was failing CI)
- ROADMAP.md: test count 1484, Phase 4c M4 debugger additions,
  Phase 7 M4 init.scm marked done, priority order updated
- CLAUDE.md: Phase 8 updated with debugger/docs/CI additions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix e2e/check-config CI job: --exclude requires --workspace
- Document vertical line color bug in insert mode (GUI) in ROADMAP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Line numbers/gutter, syntax highlighting, visual selection highlighting,
Unicode/glyph fallback (7-level font chain), and click-drag select were
all implemented but still marked as "Not yet" in the roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cuttlefisch
cuttlefisch merged commit 652a618 into main Apr 20, 2026
8 checks passed
@cuttlefisch
cuttlefisch deleted the feat/editor-polish-v0.3.0 branch April 20, 2026 00:28
cuttlefisch added a commit that referenced this pull request Jun 27, 2026
…9, adversarial-testing principle (#159)

* docs(security): E2E_ENCRYPTION.md — design-of-record + security review (ADR-037 #155)

The design-pass cryptographic review (4 prior-art-grounded lenses: AEAD/key-wrap,
key-management/trust/rotation, metadata/N-peer-scaling, prior-art positioning).

Verdict: the core crypto is SOUND (no must-fix break in the primitives). MAE v1 (single
per-KB symmetric content key, sealed-box-wrapped per member, distributed via the signed
membership op-log, rotated on removal) ≈ Jazz/cojson, is more efficient than Signal
Sender Keys (O(N) vs O(N²) on removal), and avoids Megolm's homeserver-trust mistakes;
BeeKEM is the named FS/PCS + O(log N) evolution (ADR-037 §D4).

Documents the threat model, primitives, key lifecycle, prior-art comparison, the honest
"what we do NOT protect" list (no FS/PCS — the documented CRDT trade-off; O(N) rekey;
metadata: edit sizes/timing/author/social-graph; cleartext manifest titles), and the
review findings with disposition. F1/F2/F4/F6 fold into 3b (#151); F5/F7/F8/F9 + mesh
anchor pinning tracked in #156; implementation-pass review re-runs after 3b/3c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(security): SECURITY_REVIEW.md — identity + authorization deep review (#155)

Prior-art-grounded design-pass audit of the identity + authorization foundations
under E2E KB sharing (companion to E2E_ENCRYPTION.md). Adversary: MITM, malicious
key-blind relay, unauthorized peer, removed member, concurrent races.

Verdict: identity = sound SSH-style asymmetric trust (RFC-7250 raw-public-key TLS,
authorized_keys/known_hosts TOFU, mesh node-id anti-spoof); authz = sound capability+
ReBAC hybrid (UCAN attenuation, Keybase sigchains, p2panda strong-removal, external-
anchored signed op-log). NO auth-bypass / NO privilege-escalation found.

Findings concentrate at the op-log↔legacy-member_roles boundary + enforcement coverage,
sharing one fix (derive role+epoch+blocklist from the one op-log via a shared fence on
every write path):
- A1 (HIGH): epoch read from legacy member_roles not the op-log → non-epoch-0 mesh
  members wrongly fenced from editing.
- N1 (HIGH): the ADR-023 epoch fence runs only on the hub path, not the mesh dialer.
- A2 (MEDIUM): verify_content_op ignores the local blocklist.
- N2 (MEDIUM): content-key authority frozen at the genesis owner under quorum.
Identity: I1 single-key reuse (sign+TLS+node-id+X25519 wrap), I2 no key rotation/rebind,
I3 at-rest plaintext, I4 non-unix chmod, I5 two-layer revocation, I6 TOFU first-contact.

Each finding cites file:line + a primary-source prior-art basis (Zanzibar, UCAN,
SPKI/SDSI, MLS RFC 9420, RFC 7250, p2panda-auth, Matrix cross-signing, age/SSH).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(principles): add Architecture Principle #14 — adversarial testing, not confirmation

Codify the standing testing-rigor directive into the design principles: no fragile
linear happy-path tests; no cherry-picked "unicorn" values; favor property/round-trip,
N-way convergence, real/varied inputs, selective oracles, and the attacker's negative
case that MUST fail (wrong key, forged sig, stale epoch, removed member, hostile relay).
Per-phase adversarial review. Adds a matching bullet to the Scheme Testing Framework
Design Principles and an adversarial-test clause to principle #9's regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(kb-sharing): holistic reference + ADR-038/039 (editor-authored membership; identity+authz hardening)

Ties identity → authorization → encryption → the hub/P2P lifecycle into one reference:
- docs/KB_SHARING.md — "how it works in the wild": the three-layer model, both
  transports (one protocol), a worked end-to-end example (Alice enables+shares, Bob
  joins+approved+keyed, Carol removed+rotated, daemon key-blind throughout), the
  management surface, and the honest protected/not-protected summary.
- docs/adr/038-editor-authored-membership.md — the owner editor authors the signed
  membership op-log via the key-blind kb/collection_op RPC (daemon stores opaque
  owner-signed bytes); member pubkey via PendingRequest; dual-write op-log+member_roles.
- docs/adr/039-identity-authz-hardening.md — the security-review decisions: one unified
  op-log fence (role+epoch+blocklist on every write path, #157); signed encryption mode
  + fail-closed; E2e⇒SingleOwner; anchor pinned to the authenticated owner; identity
  follow-ups (key rotation/rebind, single-key separation, at-rest, Windows perms, #158).
- CLAUDE.md — index ADR-038/039 + the three sharing/security docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jun 27, 2026
) (#164)

* feat(sync): author_rotate_on_remove — content-key rotation on member removal (ADR-037 §D3, #152)

The kb.rs authoring core for Phase 3c. On removing a member from an E2e KB, the
owner authors ONE combined collection delta: a signed `Remove` of the departed
member (+ member_roles mirror with #72 epoch tombstoning), then one owner-authored
wrap-only `Admit` per REMAINING member carrying a freshly-wrapped new key.

Design:
- Re-key ops re-assert each member's CURRENT derived role/can_invite/epoch verbatim
  (a re-admit overwrites the derived entry — "later re-admit wins", membership.rs:855
  — so preserving them avoids a silent downgrade). Attributes come from the
  authoritative op-log derivation, not the legacy mirror.
- Epoch is NOT bumped: re-keying must not force the remaining members to rebase. The
  removed member is dropped from derived membership, so their stale lineage is refused
  regardless of epoch (the fence, #157).
- The removed member receives no new wrapped op → find_wrapped_content_key returns
  only their OLD key. They decrypt pre-rotation content but no post-rotation ciphertext.
- Daemon stays key-blind: it relays the owner-signed delta via kb/collection_op.

Adversarial test (the §D3 SELECTIVE oracle): 3 members share k; remove B with fresh
k'. Asserts the two remaining converge on k', the removed B keeps ONLY the old k (not
k', not nothing — proving k' is denied specifically, not B's pipeline severed), B is
dropped from membership while owner+C retain unchanged role/can_invite/epoch (the
clobber guard), a pre-rotation replica applying the relayed delta agrees on all points,
and the Remove op carries a verifiable owner signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(collab): rotate the content key when removing an E2e KB member (ADR-037 §D3, #152)

Wires the editor side of Phase 3c onto author_rotate_on_remove. Removing a member
from an E2e KB (one with a registered content key) now ROTATES the key instead of
issuing a plain daemon-authored kb/remove_member:

- The KbRemoveMember intent carries the main thread's cached collection replica
  (mirrors KbApprove / KbSetEncryption), so the network task — which holds the
  identity secret + the content key — can author the rotation.
- The network task: derives the current members from the op-log, generates a fresh
  k', wraps it once per REMAINING member (the owner re-keys itself via its own
  pubkey; others via the pubkey stored on admit, ADR-038), authors the combined
  signed Remove + per-member re-key delta, ships it KEY-BLIND via kb/collection_op,
  then persists k' and registers it so the owner's next edits seal under it.
- Members with no stored pubkey are skipped with a loud warn (they keep the OLD key
  until a re-share re-wraps them) — no silent coverage gap (principle #14).
- Adds and non-E2e removes fall through to the legacy kb/add_member / kb/remove_member
  path unchanged. The daemon stays key-blind and re-derives membership (the signed
  Remove drops the member) from the relayed delta.

Data-layer convergence + the §D3 security oracle (removed member stranded on the old
key; remaining members converge on k') are covered by the author_rotate_on_remove
adversarial test (a9c30e3); the full wired daemon e2e lands in 3d (#153, docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jun 27, 2026
…#170) (#172)

* fix(collab): fail CLOSED on E2e KBs — never emit plaintext node updates (CRITICAL #168)

build_kb_node_update_request decided seal-vs-plaintext purely on content_key.is_some()
with no E2e knowledge, so an E2e KB with a missing key (e.g. after restart, before a
share/join response repopulated content_keys) — or a seal failure — shipped PLAINTEXT
to the key-blind daemon, which stores + relays it. Silent confidentiality breach.

Fix (fail closed):
- build_kb_node_update_request takes `e2e: bool` and returns Option: None = REFUSE.
  Both the no-key arm and the Err(_) seal-failure arm return None when e2e — never the
  cleartext.
- New `kb_collection_is_e2e(collection_state)` reads the AUTHORITATIVE signed
  derive_encryption (F1 anchor pin), not the relay-flippable unsigned flag — a downgrade
  can't trick us into plaintext. The editor stamps this onto CollabCommand::KbNodeUpdate.e2e
  at drain time (the editor thread is the authority; it holds the collection replica).
- The handler lazily reloads the persisted content key from content_key_store on an E2e KB
  with no in-memory key (restart liveness), then refuses + REQUEUES with a loud warn! if
  still absent — the edit retries when the key arrives (owner: on reload; member: on
  approve). No silent plaintext, no lost edit; observable via tracing.

Tests (principle #14, the attacker oracle):
- build_kb_node_update_request_fails_closed_on_e2e_without_key — E2e + no key ⇒ None on
  BOTH the signed and unsigned paths; selective control: the same inputs on an UNENCRYPTED
  KB still ship (Some), proving the refusal is the e2e gate working, not a dead function;
  E2e + key ⇒ Some (seals).
- kb_collection_is_e2e_reads_signed_mode_not_the_flippable_flag — plain ⇒ false; signed
  enable ⇒ true; a relay forging the unsigned flag back to None does NOT downgrade.

Sibling leak in the share/re-share path (raw plaintext node snapshots) filed as #170.
117 collab_bridge tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(collab): fail CLOSED on the share/re-share path — never ship plaintext snapshots (CRITICAL #170)

The ShareKb handler sent node_states as RAW PLAINTEXT (update_to_base64(state)). The
editor re-shares durably-shared KBs on reconnect, so an already-E2e KB leaked its full
plaintext node content to the key-blind daemon every reconnect — worse than #168 (full
content, not a delta).

Fix (fail closed, principle #8 extraction):
- New pure `select_share_node_states(kb_id, e2e, node_states, op_sets)` decides the wire
  states. On an UNENCRYPTED KB it is byte-identical to before (base64 the plaintext). On an
  E2e KB it NEVER ships the plaintext snapshot: it sends the already-sealed op-set we hold
  for the node (idempotent — the daemon stores the same op-set kb/node_update produces) and
  SKIPS a node we have no op-set for (loud warn), rather than leak its plaintext.
- ShareKb computes `e2e = kb_collection_is_e2e(&collection_state)` (the authoritative signed
  mode, #168 helper) and routes through the new helper.

Test (principle #14): select_share_node_states_never_ships_plaintext_on_e2e — E2e ships the
sealed op-set for a held node, SKIPS a bare node, and a plaintext canary appears in NO wire
payload; selective control: the unencrypted path still ships every plaintext node.

Residual (documented separately): content shared BEFORE encryption was enabled stays
plaintext on the daemon — retroactive re-encryption-on-enable is a known limitation, not
this leak. This fix stops the re-share from RE-leaking post-enable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(e2e): record the enforced fail-closed seal (#168/#170) + the re-encryption-on-enable limitation (#171)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sync): regression guard — a sealed delete is fenced via its outer-op client_id (#167/#168)

Proves WHY #168's always-seal closes #167's deletion-fence gap for E2e KBs: a deletion
sealed into the op-set rides a client-id-stamped outer op, so update_new_op_authors
attributes it to the (stale) seal client_id and the ADR-023 fence rejects it — even though
the inner op is a pure, otherwise-unattributable yrs delete. Documents the residual: a
PLAINTEXT pure-delete remains unattributable (the unencrypted-path #167 gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jun 27, 2026
…r rotated keys to members (HIGH #173)

A member derived its per-KB content key ONCE, at join/share/enable. On subsequent kbc:
collection broadcasts, handle_kbc_membership_broadcast relearned the epoch but never
re-derived the content key, so the network task's content_keys (seal + open) was frozen at
first derive. Consequences:
- 3c rotation was broken for remaining members: after the owner rotated to k', members kept
  the old k — couldn't open k'-sealed content, sealed their edits under the stale k →
  divergence after any membership change.
- Wrap-on-admit for a member who already joined never reached their seal/open path.
- Subsumes #169 M2 (concurrent rotations converge once both deltas merge + re-derive).

Fix:
- KbCryptoCtx gains kb_collections: per-KB full collection replicas, seeded at
  join/share/enable, advanced by inbound kbc: deltas.
- New refresh_kb_content_key_on_collection_delta: on a kbc: delta, advance the replica and
  RE-DERIVE content_keys via derive_kb_content_key (+ persist), wired into both inbound
  sync_update paths. No-op for non-kbc / unseeded / unencrypted (a plain KB is undisturbed).
- Rotation handler now registers the find_wrapped winner derived from the post-rotation
  collection (the #169 M2 single-source-of-truth fix), not the locally-generated k2.

Test (principle #14, the rotation-delivery oracle):
refresh_kb_content_key_re_derives_on_rotation_remaining_yes_removed_no — a REMAINING member
re-derives k' on the rotation delta; the REMOVED member stays stranded on the old k.

116 collab_bridge tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jun 27, 2026
…r rotated keys to members (HIGH #173) (#175)

A member derived its per-KB content key ONCE, at join/share/enable. On subsequent kbc:
collection broadcasts, handle_kbc_membership_broadcast relearned the epoch but never
re-derived the content key, so the network task's content_keys (seal + open) was frozen at
first derive. Consequences:
- 3c rotation was broken for remaining members: after the owner rotated to k', members kept
  the old k — couldn't open k'-sealed content, sealed their edits under the stale k →
  divergence after any membership change.
- Wrap-on-admit for a member who already joined never reached their seal/open path.
- Subsumes #169 M2 (concurrent rotations converge once both deltas merge + re-derive).

Fix:
- KbCryptoCtx gains kb_collections: per-KB full collection replicas, seeded at
  join/share/enable, advanced by inbound kbc: deltas.
- New refresh_kb_content_key_on_collection_delta: on a kbc: delta, advance the replica and
  RE-DERIVE content_keys via derive_kb_content_key (+ persist), wired into both inbound
  sync_update paths. No-op for non-kbc / unseeded / unencrypted (a plain KB is undisturbed).
- Rotation handler now registers the find_wrapped winner derived from the post-rotation
  collection (the #169 M2 single-source-of-truth fix), not the locally-generated k2.

Test (principle #14, the rotation-delivery oracle):
refresh_kb_content_key_re_derives_on_rotation_remaining_yes_removed_no — a REMAINING member
re-derives k' on the rotation delta; the REMOVED member stays stranded on the old k.

116 collab_bridge tests green; clippy clean.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jul 26, 2026
…ied not rewritten

Tackled ahead of Phase C by explicit decision (see this session's stocktaking): Phase D
was already well-specified and named as this ADR's own single highest-priority
adversarial test (real Gitea CVE-2026-27771/CVE-2026-58444 + Vaultwarden CVE-2026-27898
precedent), while Phase C's stated premise needed correcting first. Same principle-#15
discipline as Phase B: write the adversarial tests against current code before assuming
new enforcement needs building.

Three named cases, three different outcomes:

- IDOR case (this phase's PRIMARY test): a genuine structural property of Phase A's own
  addressing, not a bolted-on check -- snapshot_query_layer/snapshot_store resolve the
  instance address to one specific Arc<CozoKbStore> before any inner ID is looked at, so
  every lookup is backed by only that store's relations. Proved with a real cross-instance
  ID collision (handler::tests::
  idor_a_valid_instance_address_never_resolves_a_different_tenants_id): a node inserted
  directly into tenant B's store, requested via a validly-addressed tenant A request,
  resolves Null across kb/get/kb/links_from/kb/links_to, with a third uninvolved tenant C
  per principle #14's N-way requirement.
- Role composition: roles are derived per-collection in collab_handler/mod.rs's
  kb_access -- an entirely separate daemon listener (mTLS collab) from the KB Unix-socket
  path Phase A/B touched (which has no principal/role concept at all, by design -- see
  daemon/src/config.rs's own comments). No existing test proved a principal holding
  DIFFERENT roles on two different KBs doesn't leak the stronger one across the boundary,
  so daemon/src/collab_handler/tests/collab_handler_cross_kb_role_isolation_tests.rs (new
  file) closes that gap: bob, real Owner of his own KB, denied an Owner-only action on a
  second KB where he's only Viewer; the reverse also verified.
- Forged/rotated-key signature: already covered, pre-existing, unrelated to this ADR --
  shared/sync/src/membership.rs's tampering_any_field_breaks_the_signature (+ several
  collab_handler forged-signature tests) confirmed by direct reading, not duplicated.

docs/adr/060-daemon-multi-tenancy.md gained a Phase D Implementation Note (same
convention as Phase B/C's) plus a Status line update. assets/mae-adr.cozo regenerated.
Issue #412 closed, including an honest caveat: the issue's own "regardless of quota
headroom" DoD phrasing can't be fully tested yet since Phase C's quota mechanism doesn't
exist -- the role-isolation property itself is verified; the quota-interaction half needs
re-checking once Phase C ships.

Verified: daemon workspace full test suite (cd daemon && cargo test --all-targets) --
104 bin + 156 lib tests, 0 failed; clippy -D warnings and fmt --check both clean. New
tests run 3x locally to rule out flakiness before relying on them (both are deterministic
logic tests, not timing-based, so risk was low but checked anyway). Editor workspace
(cargo check --workspace --all-targets) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jul 27, 2026
…n-seed diagrams

`partition_boundary_links_by_instance` was previously called exactly once,
against only the seed's own boundary links (`populate_graph_buffer`'s Multi
branch). Each related instance's own boundary links went straight into that
diagram as unclassified stubs, so a real link from one related instance to
another (neither being the seed) rendered as an ordinary same-instance-
looking dashed stub, never promoted to a cross-instance chord.

- `mae_kb::CrossInstanceLink` gains `source_instance: Option<String>`
  (mirrors `KbCrossInstanceLinkInfo`'s existing field), populated by
  `partition_boundary_links_by_instance` from the `owner_instance` it's
  called with.
- `populate_graph_buffer`'s related-instance loop now calls
  `partition_boundary_links_by_instance` per related instance too (not just
  the seed), accumulating cross-links from every rendered diagram. No
  double-discovery risk: a directed link is a boundary link of exactly the
  one diagram whose extraction included its source and excluded its
  target, so concatenation needs no dedup.
- `cross_link_infos`/`cross_instance_links_kept` now use each link's own
  `source_instance` instead of hard-wiring it to the seed.
- `describe_state()`'s "Cross-KB links" text now also names the source's KB
  when it isn't the seed (previously only the target's KB was shown, an
  assumption that broke once the source can be any rendered diagram).

Adversarial tests (CLAUDE.md #14): a 3-non-seed-instance chain (B->C
detected with source_instance == B; C->D dropped-with-count via the
existing hidden-link mechanism, without corrupting the B->C count);
reciprocal A<->B links authored in both directions rendering as two
distinct chords, not collapsed; a related instance's own link back to the
seed promoted correctly (the original bug's exact shape, reversed); and a
`describe_state()` text test confirming both endpoints' KB now appear when
the source isn't the seed. All pre-existing Single-mode and Multi-mode
regression tests pass unmodified.

Verified: cargo build/test/clippy/fmt clean across mae-kb/mae-core/
mae-canvas (2830 mae-core tests passing, 9 new), workspace-wide clippy
clean, code map up to date.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jul 27, 2026
… own center

flatten_scene_graph_cached computed scene origin (0,0) ONCE for the whole
merged multi-KB scene and bowed every Chord-mode edge toward that single
point -- correct only when there's exactly one diagram (a lone chord ring
is centered at scene origin by construction). After
build_multi_kb_chord_positions composes N diagrams onto a grid and
re-centers the grid's bounding box onto (0,0), scene origin becomes the
grid's CENTROID -- not any individual diagram's own center, which
DiagramLabel.center_x/center_y tracks but flatten_scene_graph_cached never
read.

Fix: broadcast each diagram's own center across its contiguous node range
(node_diagram_centers, new pub(crate) helper mirroring node_degrees) and
thread it into flatten_scene_graph_cached as a new diagram_centers
parameter, looked up by edge.source in the Chord curvature formula. The
public 4-arg flatten_scene_graph wrapper (test-only, ~35 call sites) keeps
passing an empty slice, which falls back to the old global-origin
behavior -- byte-identical for Single mode and single-diagram Multi mode,
since that one diagram's center IS scene origin by construction.

Two related bugs fixed in the same pass:
- is_boundary was derived from edge.style.dashed alone, but cross-instance
  edges are ALSO dashed (reusing the boundary-stub convention), so they
  never curved and always got boundary-stub coloring. Replaced with
  is_self_link (edge.source == edge.target), which correctly separates
  self-links/boundary stubs (still straight + boundary-colored) from real
  cross-instance edges (now curve like ordinary internal edges, with
  normal edge color) -- deliberately NOT describe_state's
  `dashed && rel_type.is_none()` formula, which would also misclassify a
  genuine self-link (it too carries rel_type: Some(..)).
- GraphStyleOptions::from_editor read the global kb_graph_layout_algorithm
  unconditionally, but Multi mode always chord-grids node positions
  regardless of that option. graph_view_reflatten_window now forces
  layout_algorithm = Chord whenever GraphView.mode == Multi.

Adversarial tests added (CLAUDE.md #14): a 3-diagram grid where each
diagram's internal edges bow toward its own center, not the centroid; a
cross-instance edge between diagonal (non-adjacent) grid cells gets real
curvature + normal color; a genuine self-link in a multi-diagram scene
still renders as a straight boundary-colored stub (the exact trap flagged
during design review); Single mode vs. single-diagram Multi mode produce
byte-identical flattened output; Force-global-option + Multi mode still
uses Chord-style curvature. Verified each new test actually fails when
the corresponding fix is reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cuttlefisch added a commit that referenced this pull request Jul 27, 2026
Opening the KB graph view always creates a 60/40 tiled split. Toggling
the fullscreen overlay (`o`) is a pure bool flip that paints the graph
over the whole screen, but the original pane is still alive in
`window_mgr`, just visually hidden underneath. `Editor::focus_window_at`
hit-tested mouse clicks against the stale pre-overlay tiled rects with
zero awareness of `kb_graph_view_overlay_active` -- a click landing
where the hidden pane used to be silently refocused it, and keyboard
dispatch then resolved that buffer's keymap (no exit binding), trapping
the user in an unresponsive-looking fullscreen graph.

Fold the same `kb_graph_view_overlay_window()` guard already used by
the GUI hover and wheel-zoom paths into `focus_window_at` itself
(CLAUDE.md principle #8) so all 3 of its call sites (button-press,
drag-select, focus-follows-mouse in gui_app.rs) inherit correct
behavior from the one shared function, with no per-call-site fix
needed.

Adds adversarial coverage per principle #14: overlay-off case proves
the fix is gated (a genuine tiled-pane click still refocuses it), a
3-window layout proves the overlay preference holds regardless of
sibling window count, and an out-of-bounds click proves the existing
no-op behavior is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This was referenced Jul 30, 2026
cuttlefisch added a commit that referenced this pull request Aug 3, 2026
…perty test

Second silent miss in the same function, this one MAE's own: the
post-query verification split the raw query on whitespace and required a
literal substring match, so the prefix query `buffer*` produced the single
term `buffer*`, which no document text contains -- every candidate the
index correctly returned was dropped and the caller saw zero hits.
Verified: `buffer*` matched 1 row in `nodes:fts` while `fts_search`
returned 0. Terms are now split on `!is_alphanumeric`, mirroring cozo's
`Simple` tokenizer, so the guard checks exactly what was indexed. A
candidate with no row in `nodes` is still dropped -- that stale-entry case
is what the guard exists for.

Replace the `fts_search_finds_nodes` unicorn assertion (principle #14)
with a property test over a named 10-node corpus: every term in a node's
title or body must retrieve that node. Covers multi-word titles,
punctuation, case variation, digits, underscores/hyphens, and non-ASCII
(NFC accents, CJK+kana, Cyrillic, Greek). Plus a ranking oracle -- a
corpus-unique term must rank its own node FIRST -- so a fix that finds
everything but orders it uselessly fails too.

Confirmed the tests falsify: reverting the separator to ' ' fails 6 of
them, naming exactly the title/body boundary terms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant