# Security & isolation boundaries — inventory and adversarial-test map ai-memory is single-tenant wiki data with optional multi-user attribution, run by parallel harnesses and shared teams. A handful of guards keep one project, workspace, operator, or untrusted input from crossing into another. Each guard is only as good as a test that **actively tries to break it**. A happy-path or single-tenant test cannot see an isolation defect, so a regression that removes the guard would pass CI silently. This file is the source of truth for which boundaries exist, where each is enforced, and the adversarial test that would fail if the guard were removed. **Keep it current** (see the protocol at the end); the AGENTS.md "security-boundary tests" rule refers to it. An adversarial test attempts the violation and asserts refusal, and also runs a legitimate control case so a blanket deny is not mistaken for a working guard. Coverage verdicts: **STRONG** means a test would fail if the guard were deleted; **PARTIAL** means only some paths of the guard are probed; **FUTURE** means the boundary is not built yet. ## Boundary map | # | Boundary | Enforcing code | Adversarial test(s) | Coverage | |---|----------|----------------|---------------------|----------| | 1 | Per-project isolation (3-tuple) | `ai-memory-store/src/scope.rs` `ScopeResolver::resolve_read_args`/`resolve_write_args`, no-create `lookup_existing_scope`; reader queries filter by `(workspace_id, project_id)`; `reader.rs` `sessions_for_scope_filtered` joins only consolidation jobs in the requested workspace and project; `reader.rs` `incremental_pages` filters workspace and project before its keyset and limit; `reader.rs` `session_counts_by_agent` filters mixed-source observations by the requested workspace and project; `reader.rs` `observation_stream_votes` (`[retrieval] observation_stream`, on by default) filters both the matching observations and the `sessions/.md` page they vote for to the queried workspace and project, and attaches a vote's observation excerpt and ids only when the caller's session-timeline owner filter admits the session (`sessions_hidden_from`; `HybridSearch::owner`, threaded from `memory_query`, `/web/search` and `/api/v1/search` through `OwnerFilter::for_actor_context`); the raw-observation fallback `search_observations_for_project_as` drops sessions that filter does not admit; `reader.rs` `page_versions` / `page_version_body` (web page history) read by `(workspace_id, project_id, path)` and take a caller's version id only inside that triple; `ai-memory-web` `routes/session.rs` takes a raw session id only through `session_summary_scoped` (row or observation in the project, owner filter) and `session_observations_scoped` | `store` `scope.rs` read-resolution table tests; `tests/suite/multi_session.rs`; `session_observations.rs` `consolidation_summary_latest_generation_is_scope_and_owner_bound` (a foreign-scope job is absent; the own latest generation and owner controls pass), `incremental_pages_equal_timestamps_cutoff_expiry_and_foreign_scope` (foreign scope absent with a same-scope control), `mixed_capture_count_scopes_observations_and_preserves_owner_and_window` (foreign-scope observations cannot turn an own session into a mixed one; same-scope control can); handoff receiver-session scope: see row 4, `multi_session.rs` `handoff_accept_rejects_foreign_or_missing_receiver_session` (cross-project and cross-workspace receivers refused); `multi_session.rs` `observation_stream_never_crosses_a_project_or_workspace` (a home observation cannot vote for a same-path session page in another project or workspace, and a foreign project's observation cannot lift a home page; control: home's own page receives the vote), fails with either the observation or the page scope filter removed; `multi_session.rs` `observation_excerpts_follow_the_session_owner_while_the_vote_stays_shared` (operator B, and an unattributed caller, querying words only A's owned session holds get A's shared session page with its own snippet, no excerpt, no observation ids and no raw hit; controls: A gets the excerpt, ids and raw hit, an unattributed session's excerpt reaches every viewer, and the unfiltered single-user path is unchanged), bite-checked against both the vote guard and the raw-fallback clause; `ai-memory-mcp` `server.rs` `memory_query_shows_observation_text_only_to_its_sessions_owner` and `ai-memory-web` `recall_trace.rs` `project_search_shows_observation_excerpts_only_to_the_sessions_owner` (the same through `memory_query`, `/web/search` and `/api/v1/search`; each fails when its surface passes `OwnerFilter::Any`); `multi_session.rs` `page_history_keeps_the_overwritten_version_and_stays_in_scope` (a sibling project's or another workspace's version id, or a home id under another path, reads as `None`; control: both home versions); `ai-memory-web` `history_replay.rs` `history_refuses_foreign_project_workspace_and_version_ids` (another project's or workspace's path, and a foreign version id as `from` or `to`, answer 404 on `/web/w/…/h/*path` and `/api/v1/…/history/*path` without the foreign text; control: the home history lists only home's rows at a path a sibling also uses, and an explicit home pair diffs) and `session_timeline_refuses_foreign_session_ids` (a sibling project's or another workspace's session id and an unknown id → 404, another operator's owned session → 404; controls: the home session, and the owner reading theirs); bite: dropping the scope filter from `page_version_body` or the project filter from `page_versions`, or replacing the `session_summary_scoped` check or its owner filter, fails them | STRONG | | 1b | Reserved `_global` scope union never leaks a foreign project (#930) | `ai-memory-mcp/src/server.rs` `memory_query` — the union runs for single-project queries (`scopes` empty) and searches **only** `lookup_global_scope`'s reserved `(workspace_id, project_id)`, never an arbitrary project; the double-search guard resolves the *queried* project (named or active) so it can't be tricked into skipping | `server.rs` `global_union_never_leaks_a_foreign_projects_pages` (a third real project's page must not surface via the union; reserved page must), `single_project_query_unions_global_scope_and_multi_scope_skips_it` | STRONG | | 1c | Reserved `_global` scope is read-open, write-gated (GHSA-7qj3-7wqw-m5w6) | `ai-memory-store/src/project_authz.rs` `ProjectAuthz::authorize` (a write to `default/_global` needs root or a `write` grant in either access mode; creator ignored) fed by `resolve_project_authz`/`read_project_row` (`reserved_global`, grant read for the global scope regardless of mode); `ai-memory-mcp/src/server.rs` `memory_write_page` resolves `scope: "global"` through `write_target_ids_with_actor` like an explicit `default/_global` | `project_authz.rs` `the_global_scope_admits_writes_only_from_root_or_a_write_grant`; `server.rs` `a_user_without_a_global_grant_cannot_change_the_global_scope` (both spellings and delete refused for an ungranted user and for `_global`'s creator; page survives; reads stay open), `a_global_write_grant_admits_writes_and_the_union_stays_readable` (legitimate control: write grant admits both spellings, another user's restricted-project query still unions the page); bite-checked with the authz rule removed and with the raw `create_global_scope` path restored | STRONG | | 1d | A private cross-project profile is readable and writable by its operator alone (`[profile] share = "user"`) | `ai-memory-store/src/ops.rs` `initial_access_mode` (a `_profile.` project is always created `restricted`, creator recorded in the same transaction); `ai-memory-store/src/scope.rs` `lookup_profile_scope` / `create_profile_scope` (the project name derives from the authenticated viewer's id; no database user, no private profile); `ai-memory-mcp/src/server.rs` `profile_target_ids` and the `memory_query` profile union (resolved per viewer through the per-project choke point); `scope.rs` `refuse_foreign_profile_name` (called by `ScopeResolver` explicit writes and `create_explicit_scope_guarded`: a database user may name only their own `default/_profile.`, so nobody can create another operator's private profile first and lock its owner out); `ops.rs` `rename_project` (no rename onto or away from a profile name); `admin.rs` `handle_project_access` (a profile project's mode is fixed) and `move_project_core` (a private profile cannot leave `default`) | store `integration::profile` `a_private_profile_refuses_every_other_operator` (another operator's read and write by name refused; owner control admitted), `reserved_profile_projects_get_their_fixed_access_modes`; `server.rs` `a_private_profile_is_private_to_its_operator` (no union leak; read, write and delete by name refused; owner and own-profile controls); `multi_session.rs` `two_harnesses_of_one_operator_share_one_private_profile`; both fail with the restricted-mode guard removed; `server.rs` `a_user_cannot_squat_another_operators_private_profile` (another user's explicit write naming the owner's private profile refused and creates nothing; owner's profile and explicit own-name controls; fails with the name guard removed); `ops.rs` `rename_project_refuses_profile_names_both_ways`; `admin.rs` `project_access_sets_the_mode_and_refuses_what_it_must`; `admin_move.rs` `move_project_refuses_a_private_profile` | STRONG | | 1e | The shared profile scopes are read-open, write-gated like `_global` | `ai-memory-store/src/project_authz.rs` `is_reserved_global` (a workspace `_profile` joins `default/_global`: on a server that distinguishes operators a write needs root or a `write` grant, creating it grants nothing); `ops.rs` `initial_access_mode` keeps `_profile` open | store `integration::profile` `shared_profiles_are_write_gated_for_database_users` (ungranted user refused for both shares; root and a write grant admitted; reads open); `server.rs` `a_workspace_profile_is_write_gated_and_read_open`; both fail with `_profile` dropped from the gate | STRONG | | 1f | Event capture never lands in a profile project, and the sweep keeps them | `ai-memory-hooks/src/router.rs` project resolution refuses `ai_memory_core::profile::is_reserved_scope_project` (`_global`, `_profile`, `_profile.*`) from a directory or a marker override, falling back to the server default; `ops.rs` `sweep_hollow_projects` exempts `_profile` and `_profile.%` | `router.rs` `capture_is_never_attributed_to_a_profile_project` (override and directory spellings refused; ordinary `my_profile` control resolves), fails with the check narrowed to `_global`; store `integration::profile` `the_hollow_project_sweep_keeps_empty_profile_projects` | STRONG | | 1g | The profile reaches only the sessions it is meant for: off by default on a multi-user server, suppressed by `[profile] consume = false`, and an opt-out is sticky | `ai_memory_core::profile::ProfileSettings::effective_share` (`enabled = auto` is off once operators are distinguished); `router.rs` `persist_profile_flags` (flags are tri-state: an absent forwarded value keeps the stored one, so a client that sends nothing never undoes an opt-out; every front door sends explicit `0`/`1` once a marker resolved) and `render_requested_profile_digest` (a team profile is headed "team defaults"); `server.rs` `memory_query` drops `profile/` hits for a project whose stored `profile_consume` is off; `admin_profile.rs` `handle_apply` (`ai-memory profile apply`) returns no lines for such a project and sits behind the root-only `/admin/*` gate on a multi-user server | `router.rs` `a_multi_user_server_has_no_profile_digest_by_default`, `profile_digest_honours_consume_once_per_session_and_the_server_switch`, `session_start_records_the_marker_profile_flags` (a session start with no flags after an opt-out keeps the project out of the harvest and the digest; fails when an absent flag defaults to on); `server.rs` `the_single_user_profile_lives_in_global_and_honours_consume` (fails with the consume filter removed); `admin_profile_routes.rs` `apply_lists_the_lines_for_a_project_and_honours_consume`, `apply_is_root_only_on_a_multi_user_server` (database user refused, root control admitted); core `profile::tests::effective_share_matrix` | STRONG | | 1h | The profile learns only from what it may: the user's own prompts and curated pages, from contributing projects, never another operator's words in a private profile and never a restricted project in a shared one; stored text stays data | `ai-memory-store/src/profile.rs` `profile_prompts_since` (only `user-prompt` observations), `auto_improve_decided_by` (the decider agent a page's `auto_improve_proposal_id` resolves to), `profile_harvest_projects` / `profile_candidates` (`profile_contribute = 0` excluded, including candidates recorded before the opt-out); `ai-memory-store/src/ops.rs` `move_session` (a moved session's `session:` prompt candidates are re-stamped into the target project in the same transaction, so they answer to its contribute flag and access mode, #1265); `ai-memory-consolidate/src/profile.rs` `user_written` (shared with the verbatim-residue caller in row 25: a harness turn or Codex's injected instructions stored as a prompt is skipped whole, and ai-memory's routing and untrusted-history blocks, fenced by markers on their own lines, are cut out before any sentence is read), the auto-approved-proposal skip in `harvest_project` (a page the auto-improve scheduler auto-approved is model output nobody reviewed and never becomes a candidate; a human-approved page, or one whose proposal id has no row, stays curated, #1211), `convergence_targets` (a `user` target keeps only candidates whose `contributor` is that operator, from projects the per-project choke point lets them read; a `global`/`workspace` target on an operator-distinguishing server drops restricted projects), `min_contributors` / `converge` (a team profile admits a statement only on evidence from at least two distinct operators, so one operator cannot plant a default in everyone's session start) (never rewrites a page without its `generated_by` stamp or whose body changed; `profile_entry_ledger` keeps a forgotten entry out), `classify_batch` / `merge_entry` (sanitized, JSON-encoded input; the evidence quote is always the user's sentence) | consolidate `integration::profile_harvest` `tool_output_is_never_a_candidate`, `an_opted_out_project_is_never_harvested`, `a_private_profile_never_harvests_another_operator`, `a_team_profile_never_harvests_a_restricted_project`, `a_team_profile_needs_more_than_one_operator` (one operator in two open projects not admitted; a second operator admits it; fails with the threshold at one), `a_personal_profile_admits_one_operators_habit` (control), `a_hand_edited_entry_is_never_clobbered`, `a_forgotten_entry_is_not_recreated_until_said_again`, `injected_instructions_stay_data`, `injected_instructions_and_ai_memory_blocks_are_never_candidates`, `page_metadata_fields_are_never_candidates`, `auto_approved_auto_improve_pages_are_never_candidates` (an auto-approved page refused; human-approved and hand-written controls admitted; fails with the decider check removed), consolidate `profile::tests::a_stored_page_metadata_field_is_not_a_waiting_habit` (each with a legitimate control; each fails with its guard removed); mcp `integration::admin_profile_routes` `review_and_rebuild_are_root_only_on_a_multi_user_server`; store `integration::session_move` `a_moved_sessions_profile_candidate_follows_the_target_projects_governance` (a session moved into an opted-out project stops counting and one moved into a restricted project is marked restricted; an open-target control still counts, from the target; fails with the `move_session` re-stamp removed); residue control in row 25 | STRONG | | 1i | SessionEnd's bounded digest cannot read a foreign session by raw id | `ai-memory-store/src/reader.rs` `session_has_substantive_observation` joins the session row to the caller's `(workspace_id, project_id)`; `read_session_observations` validates that same tuple inside the read transaction before exposing its bounded snapshot, returning the same `SessionCollision` refusal for foreign and missing ids; `ai-memory-hooks/src/router.rs` supplies only the scope pinned by hook admission | `multi_session.rs` `session_end_readers_refuse_foreign_session_ids` (sibling-project and cross-workspace tuples cannot learn the substantive bit or open the snapshot; a missing id gets the same refusal; the owning tuple reads the one-row summary as the legitimate control). Removing either scope predicate fails its corresponding assertion | STRONG | | 1i2 | The consolidation session read fails closed on a session with no observations in its target scope, and memory is bounded by the selection, not the session | `ai-memory-store/src/reader.rs` `read_session_observations_in_scope` opens the bounded session snapshot only when at least one observation of the session exists in the caller's `(workspace_id, project_id)` (an `EXISTS` over `observations`), returning `SessionCollision` for foreign, purged and missing alike; `SessionObservationSnapshot::projection_totals` reads `COUNT(*)` and `MIN`/`MAX(created_at)` in one aggregate inside the same read transaction; `SessionObservationSnapshot::row_by_rowid` re-reads a single row only when both `session_id = ?1` and `rowid = ?2` match, so a legitimate snapshot cannot fetch another session's body by raw rowid; `ai-memory-consolidate/src/projection.rs` `project_session_observations` streams rows one at a time, keeping only the two anchors' rowids and a bounded top-k heap of the best `limit - 2` rows (the shared `selection_key` the in-memory path sorts with), re-reading only the selected rows by rowid; residue candidate lists exist only while the opt-in residue is on; `ai-memory-consolidate/src/consolidator.rs` `consolidate_session` / `consolidate_session_multi` gate on the scope `resolve_target` already resolves (the observations' majority scope) and map the refusal to `EmptySession`, exactly as an empty session did before; auto-improve keeps its `session_project_ids` gate plus the sessions-row-gated `read_session_observations` (row 1i), and computes preflight, duration, report and prompt from one single snapshot read — a purge committed between the totals aggregate and the preflight decision cannot split the two generations, and a rowless session with the preflight minimums disabled is still reviewed with the empty `(none)` projection, as the full-load path did | `tests/suite/bounded_session_load.rs` `gated_reader_refuses_foreign_scopes` (a foreign workspace and a foreign project are refused with `SessionCollision`; the session's own scope reads the totals as the legitimate control), store `tests/suite/multi_session.rs` `rowid_fetch_refuses_foreign_sessions_with_legitimate_control` (a foreign session's rowid is refused through a legitimate snapshot in both directions; the session's own rowid reads as the control; removing only the session predicate from the rowid fetch fails it), `purged_session_is_refused_without_an_llm_call` (both entry points fail closed with `EmptySession` and no LLM call once the rows are gone), `stale_sessions_row_does_not_divert_the_write` (a sessions row frozen on another project does not move the write out of the observations' project), `hybrid_two_project_session_prompt_is_unchanged` (a two-project session keeps today's exact prompt: every row of the session id is projected, the page lands in the majority project), `streaming_projection_matches_the_frozen_base_projection` and `current_projection_matches_the_frozen_base_projection` (the new paths agree field for field with a verbatim frozen copy of the pre-change `projection.rs` — an independent rule — at the 256/257/300 sample-cap edges and below), `large_session_prompts_match_the_old_full_load_path` (at 150k, 100k and 5k rows the streaming prompts are byte-identical to the frozen projection and both `#[cfg(test)]` full-load oracles, with the top-k heap asserted bounded by the selection cap and zero residue tuples while residue is off), `auto_improve_review_uses_one_generation_of_the_session` (a purge committed inside the review's single snapshot read cannot split the report's count from the prompt's), `auto_improve_review_of_a_rowless_session_with_preflight_off` (a present rowless session with zero minimums still gets its `(none)` review), `zero_budget_streaming_path_keeps_the_old_no_budget_marker` (the zero-budget early return reaches the streaming path byte-identically), and the byte-identical equivalence tests against the full-load oracles (below and above the cap, residue on/off, character-budget pruning, `created_at` ties, 16-KiB bodies, routing text, auto-improve at/above its 48/72 limits and preflight) | STRONG — the LLM input is proven byte-identical to the pre-change full load, including against a frozen verbatim copy of the base projection; dropping the `EXISTS` predicate fails `gated_reader_refuses_foreign_scopes`; removing only the session predicate from the rowid fetch fails the store `rowid_fetch_refuses_foreign_sessions_with_legitimate_control` | | 2 | Workspace isolation | same as #1; same-named project → distinct ids per workspace | `scope.rs` cross-workspace resolution rows; `multi_scope` dedup/validate; `ai-memory-web` `history_replay.rs` (another workspace's page path, version id and session id answer 404, same tests and bites as row 1) | STRONG | | 2b | Managed-workstream discovery stays checkout-local, including pagination | `ai-memory-workstream/src/repository.rs` `inspect_repository_fingerprints` shares the launch identity derivation without checkpoint scans; `ai-memory-cli/src/commands/resume.rs` `candidates_for_checkout` queries only the cwd, never registry links; the explicit `--all` walk (`candidates_across_checkouts`) lists each linked checkout through its own fingerprint-scoped request and applies `--workspace` before any request; `ai-memory-hooks/src/workstream.rs` `list_recent_workstreams` resolves an authorized explicit scope on every page; `ai-memory-store/src/workstream.rs` `list_recent` applies workspace/project/repository/worktree predicates before `LIMIT`/`OFFSET` | `resume.rs` `resume_lists_all_current_checkout_rows_not_registered_foreign_checkouts` (same scope/remote foreign checkout refused, 125 local rows visible), `empty_current_checkout_does_not_fall_back_to_the_registry`, `resume_all_walks_every_linked_checkout_and_honours_the_workspace_filter` (a foreign workspace filter sends no request; the default picker still ignores the registry); `workstream.rs` `recent_workstream_pages_reach_older_rows_without_crossing_checkout_or_scope` (foreign repo, worktree, project and workspace excluded on every page; 105 legitimate rows reach the client, huge offsets return nothing); store `recent_workstreams_are_checkout_local_and_include_linked_harnesses` (page two keeps native harness links); `repository.rs` `reports_origin_push_url_and_linked_worktree` verifies lightweight/full fingerprint parity and distinct worktree keys; CLI `resume_yolo_displays_picker_without_scanning_git_status` verifies listing skips status scans | STRONG | | 3 | Multi-user auth ladder | `ai-memory-core/src/actor.rs` `AuthLevel::authorize`; `ai-memory-mcp/src/auth.rs` middleware; `admin.rs` `require_root_for_multiuser_admin` / `require_root`; `identity.rs` authorizes `NormalRead` behind the same machine bearer middleware as MCP; `serve.rs` mounts API-only and web+API protected routers behind `require_dual_auth` | `admin.rs` `multiuser_admin_routes_reject_db_user_tier` / `…reject_anonymous` / `create_user_as_user_tier_returns_403`; `auth.rs` unknown-bearer 401; `actor.rs` `skip_admission_chain_rejects_db_users`; `identity.rs` `identity_real_auth_isolates_users_root_and_revoked_keys`, `identity_anonymous_ignores_forged_headers_and_proxy_qualifies_oidc` (distinct users and root, invalid/revoked keys, forged headers, anonymous control); `serve.rs` `api_only_mode_keeps_data_authenticated_and_web_absent` (anonymous API read refused, authenticated control admitted, browser route absent; #986) | STRONG | | 3b | Browser triage of pending writes (#855) | `ai-memory-web/src/routes/pending.rs` `require_admin` (`AuthLevel::authorize(Capability::Admin, distinguishes_operators)`, trusted-proxy flag from `WebMountSpec`) gates `GET {web_slug}/pending`; `html_auth.rs` `AdminRequired` keeps a non-root 403 off the change-password redirect; decisions post to the existing `/admin/pending-writes/{id}/approve\|reject` behind `require_dual_auth` (session + CSRF) and `require_root_for_multiuser_admin`. `ai-memory-web` has no write route | `ai-memory-web` `tests/suite/pending.rs` — anonymous refused (401, login redirect), DB user refused (403, no proposal text, no redirect), trusted-proxy flag reaches the gate, single-operator control admits, POSTs to `/pending*` refused, project filter keys do not collide on `/` in names, projects past the 500-row read cap stay counted and reachable; `ai-memory-mcp` `tests/suite/admin_pending_writes_session.rs` — anonymous and DB-user sessions refused, root session without a matching CSRF header refused (each leaves the proposal pending and no file), root approval keeps `auto_improve_proposal_id`/`auto_improve_run_id` and records the approver, root reject writes no file (each proven to fail with its guard removed); `ai-memory-mcp` `admin.rs` `pending_write_approve_foreign_scope_returns_404` and `pending_write_reject_foreign_scope_returns_404` — an id staged in workspace `alpha` requested through an existing `default` scope is 404 (never approved or rejected across the boundary), with the in-scope reject as the legitimate control (each proven to fail with 500 with the `pending_detail` guard removed, #1205) | STRONG | | 3c | Public human-auth routes refuse bodies a foreign page can send without a preflight (login CSRF, password guessing, root recovery) | `ai-memory-mcp/src/human_auth.rs` `handle_login` / `handle_recovery` read their bodies by hand (to rate-limit first), so each checks `declares_json_body` before reading: a `Content-Type` other than `application/json` or `application/*+json` (parameters and case ignored), or none, → 415 before any password or recovery-token check and without charging the per-IP failure budget. `text/plain`, form-urlencoded and multipart are the only bodies a cross-site page can `POST` without a preflight, and `/auth/*` carries no CORS layer. The built-in login page, the `docs/frontend-api.md` examples and the recovery `curl` in `docs/install.md` send `application/json` | `human_auth.rs` `public_auth_routes_refuse_preflight_free_browser_bodies`: valid root credentials and a valid recovery token, sent from a foreign origin as `text/plain`, form-urlencoded, multipart and with no content type, more times than the per-IP failure budget → 415, no session cookie; controls: a same-origin JSON login then succeeds (password unchanged, budget untouched) and a JSON recovery returns 204. `json_body_declaration` (header table) | STRONG. Bite check: with the check removed, the `text/plain` login returns 200 with the session cookie (the pre-fix behaviour) and the test fails. `/auth/password` and `/auth/logout` were already closed: they run behind `require_session`, whose unsafe methods need the `x-csrf-token` header (a custom header, so preflighted), and `/auth/password` also uses axum `Json` | | 3d | `GET /admin/metrics` is root-only on a multi-user server and content-free | `ai-memory-mcp/src/admin.rs` mounts `/admin/metrics` inside the operational router under `require_root_for_multiuser_admin` (`AuthLevel::authorize(Capability::Admin, distinguishes_operators)`); `ai-memory-mcp/src/metrics.rs` `render_prometheus` writes only counts and durations under compile-time metric names and the closed label set `result` / `outcome` / `kind` / `le`; `McpMetrics::record_tool_call` takes only a read/write bit (no client name); `ai-memory-core/src/latency.rs` `LatencyHistogram` holds bucket counts and a sum, nothing keyed on a request; scope is never a label | mcp `integration::admin_metrics` `metrics_count_queries_and_hooks_without_leaking_any_content` (secret-shaped strings planted in a `memory_query` query, a `/hook` prompt and the project name never appear in the scrape, nor do the session id, workspace name or root token; every label key is from the closed set; control: the query and hook histograms, the accepted-hook counter and the read tool-call counter move by exactly what was sent), `metrics_are_root_only_on_a_multi_user_server` (production `require_dual_auth` with a real DB-user key: anonymous 401, DB user 403, unknown bearer 401, root control 200); `admin.rs` `multiuser_admin_routes_reject_db_user_tier` / `…reject_anonymous` include the route, `multiuser_operational_admin_routes_allow_root` admits root | STRONG. Bite checks: with the route mounted outside the gate the three refusal tests fail; with the query, the project name or the hook body rendered as a label value the leak test fails on each | | 3e | The web health panel is root-only on a multi-user server, read-only and content-free | `ai-memory-web/src/routes/mod.rs` `require_admin` (`AuthLevel::authorize(Capability::Admin, distinguishes_operators)`, shared with `/web/pending`; `html_auth.rs` `AdminRequired` keeps a non-root 403 off the change-password redirect) gates `GET {web_slug}/health`, a GET-only route; `routes/health.rs` renders only counters, timestamps and configured provider/model labels, `provider_line` leaves out `last_error_message` (it can quote a response body), and the process side comes through `health::HealthProbe` as counts (`ServeHealthProbe` in `ai-memory-cli/src/commands/serve.rs` drops the journal's error text); the response is `Cache-Control: private, no-store` | `ai-memory-web` `tests/suite/health.rs` — `health_page_refuses_anonymous_once_operators_are_distinguished` (401, login redirect on navigation, no counters or fix lines), `health_page_refuses_a_db_user_and_admits_root` (403 "Root access required", no redirect, no counters; root control 200), `health_page_follows_the_admin_gate_on_a_single_operator_server` (control), `health_page_carries_no_secret_material` (secret-shaped strings planted in a failed LLM call's and a failed embed's provider error body, a page body, a page title and the project name never appear), `health_page_has_no_write_route` (POST/PUT/DELETE 405), plus a fixture per rendered check (`health_page_renders_each_failing_check_with_its_fix_line`, `health_page_renders_pending_spool_and_journal_checks`, `health_page_reports_a_healthy_server_as_passing`, and `src/health.rs` unit tests for every check id) | STRONG. Bite checks: with the `require_admin` call removed from the handler, the anonymous and DB-user tests fail; with `last_error_message` appended to the provider line, the planted-secret test fails | | 3f | The 1.x token-management surface stays removed, and tokens it issued stay ordinary API keys (3.0) | `ai-memory-mcp/src/admin.rs` mounts only `GET /admin/users` on that path and no `/admin/users/{name}/expire`, `/revive` or `/rotate-token` route; `ai-memory-store` migration `V79__drop_legacy_user_token_columns.sql` drops `users.token_hash` / `token_expired_at` and the V55 mirror triggers, with a guard that aborts the migration if the `users` rebuild would lose any resolving reference to a user (credentials, sessions, grants, authors); authentication reads only `api_credentials` (`api_credentials.rs` `find_active_user_by_token_hash`, `revoked_at` and `expires_at` honored) | `admin.rs` `removed_legacy_token_routes_are_gone_for_every_tier` (production `require_dual_auth`; each removed route as root: 405 on `POST /admin/users`, 404 on the rest; as a DB user and as a legacy-token holder: 403 on `POST /admin/users`, 404 on the rest; anonymous 401; afterwards no user was created, no credential revoked, and the legacy token still authenticates), `legacy_user_token_is_managed_as_an_api_key` (an unprefixed legacy token authenticates as a non-root user, an expired one is 401, `api-key list` shows it as `legacy-user-token`, rotate retires the old secret, revoke refuses the new one); `ai-memory-store` `api_credentials.rs` `v79_upgrade_keeps_legacy_tokens_as_api_keys` (a V78 database seeded the 2.x way with active, expired and rotated tokens plus a web session: after V79 the active and rotated tokens authenticate, the expired and superseded ones do not, all three list as `legacy-user-token`, rotate/revoke work, the session survives, no FK violation) and `v79_rolls_back_rather_than_cascade_delete_credentials` (run with foreign keys on, V79 aborts and leaves the V78 schema and the token intact); `ai-memory-cli` `removed_user_token_commands_no_longer_parse` | STRONG. Bite checks: with a stub `POST /admin/users/{name}/expire` route mounted the route test fails; without V79 the upgrade test and the schema pin fail; without the guard the rollback test fails | | 3g | A Basic browser sign-in on a server without human accounts never persists the root bearer in a cookie | `ai-memory-mcp/src/human_auth.rs` `require_dual_auth` accepts Basic (or an `ai_memory_auth` cookie an older build set) for GET only before human auth exists and sets no cookie on success; the browser resends Basic itself; `expire_legacy_cookie_mw` retires a leftover cookie once human auth is active; a request admitted this way carries `ai_memory_core::LegacyBrowserAuth`, which `/web/pending` reads to show CLI commands instead of decision buttons that would post and fail | `human_auth.rs` `a_basic_sign_in_mints_no_cookie_holding_the_root_bearer` (no `Set-Cookie` carries the token, no `ai_memory_auth` is set; control: the Basic GET is served and the bearer still authenticates; bite-checked against the old cookie minting), `legacy_browser_credentials_stop_when_human_bootstrap_completes` (Basic and a leftover cookie both stop at bootstrap), `only_a_legacy_browser_credential_is_marked_read_only` (Basic and the leftover cookie are marked, a Basic POST is still 401; control: the root bearer is unmarked and its POST is served; bite-checked by removing the insertion); `ai-memory-web` `pending_page_offers_cli_commands_instead_of_buttons_to_a_basic_browser` (bite-checked) | STRONG | | 3h | Cross-project `GET /admin/search` (no `workspace`/`project`, the `ai-memory search --global` path, #1253) is root-only on a multi-user server | `ai-memory-mcp/src/admin.rs` `handle_search` runs the no-scope branch through `ReaderPool::search_pages_with_meta` as `OPERATOR` (every project, each hit tagged with its workspace and project); the route sits in the operational router under `require_root_for_multiuser_admin` (`AuthLevel::authorize(Capability::Admin, distinguishes_operators)`), so a DB user or proxy-asserted user never reaches the unfiltered read. A partial scope is still a 400, never a silent global fallback. `ai-memory-cli/src/commands/search.rs` sends no scope only when `--global` is passed, and clap rejects `--global` with `--workspace`/`--project` | MCP `admin_status_search.rs` `global_search_never_reaches_a_non_root_caller` (a restricted project the user holds no grant on, for both a DB-user deployment and a trusted-proxy deployment with no `users` rows: user 403 and anonymous 401 with neither the sentinel path nor the query term in the body; root control 200 with both projects' hits), `global_search_tags_each_hit_with_its_workspace_and_project`, `search_rejects_partial_scope_instead_of_global_fallback`; the shared `admin.rs` all-admin 403/401 route matrix includes `/admin/search`; CLI `cli.rs` `search_global_parses_and_conflicts_with_an_explicit_scope` | STRONG. Bite check: with the gate's operator test forced off (`authorize(Capability::Admin, false)`) the refusal test fails on the DB-user case (200 instead of 403) | | 4 | Handoff single-claim / no-steal | `ai-memory-store/src/ops.rs` `accept_handoff_in_transaction` (metadata `state='open'` guard + atomic CAS; the receiver-session check, which requires the receiving session to exist in the claim's own workspace, project and agent kind and refuses with `InvalidState` before the handoff row is touched; an ended receiver is final EXCEPT for the agents where `AgentKind::reuses_session_id_after_end()` holds — Grok and Kimi Code, whose harnesses reuse the native session id across SessionEnd→resume, so accepting reopens `ended_at` only after the claim-once guard) | `multi_session.rs` `a_second_accept_cannot_steal_an_accepted_handoff`; `handoff_ownership.rs` `another_operator_cannot_claim_the_handoff`; `multi_session.rs` `handoff_accept_rejects_foreign_or_missing_receiver_session` (receivers from a sibling project, another workspace, a different agent kind, and a nonexistent session are each refused as `InvalidState` with the handoff left open and every `accepted_*` column NULL; the matching receiver claims it; the same receiver retrying and a second matching session get `false` with the first claim intact; fails with the `project_id` or `agent_kind` predicate, or the missing-session refusal, removed. The `workspace_id` predicate is redundant defence in depth: project ids are globally unique, so the cross-workspace receiver is already caught by `project_id`); `multi_session.rs` `accept_reopens_an_ended_receiver_session_but_keeps_claim_once` (Grok reopen + no-steal) and `accept_reopens_an_ended_kimi_receiver_but_keeps_every_guard` (Kimi Code resume claims and reopens `ended_at`; adversarial controls: an ended claude-code receiver stays refused, a kimi-code receiver in another project stays refused, and the already-claimed guard still refuses the resumed kimi session a second baton) | STRONG | | 4b | Handoff owner-scoped recovery (`any_owner` admin gate) | `ai-memory-mcp/src/server.rs` `require_admin_capability` on `memory_handoff_accept`/`_cancel` `any_owner` | `handoff_admission.rs` — cancel gate + **accept gate** (adversarial: non-admin `any_owner` accept refused) | STRONG | | 4c | Turn-checkpoint baton is owner+scope-bound | `ai-memory-store/src/ops.rs` `checkpoint_session_handoff` — reads the session's own `(workspace, project, owner_user)` from `sessions WHERE id=?1 AND ended_at IS NULL`, refuses on scope/owner mismatch, refreshes only that session's own `state='open'` baton (id preserved), returns `None` when the session already ended | `ops.rs` `checkpoint_session_handoff_refreshes_only_the_live_sessions_own_baton` (#865) | STRONG | | 4d | Native session rebind / drifted-end is owner+agent+cwd CAS | `ai-memory-store/src/ops.rs` — `session.moved` rebind gated on `agent==OpenCode` + `ended_at IS NULL` + lexical `normalize_cwd(stored)==normalize_cwd(from)`; `expire_same_cwd_auto_handoffs` AND-gated on `owner_user IS ?4 AND id IS NOT ?5` and on the source not being another still-open session (row 4e, #883); drifted `SessionEnd` ends only same owner+agent+cwd | `ops.rs` `native_move_rebinds_live_session_only_from_its_current_cwd`, `native_move_rejects_foreign_owner_and_ignores_completed_replay`, `drifted_session_end_ends_only_the_same_actor_agent_and_cwd` (#865) | STRONG | | 4e | A live session's baton stays its own until the session is quiet | `ai-memory-store/src/ops.rs` - `expire_same_cwd_auto_handoffs` spares batons of other open sessions; `accept_handoff_in_transaction(.., busy_since)` re-checks inside the claim that the source is not an open session with observations after the cutoff, and its sweep `expire_superseded_auto_handoffs(.., busy_since)` spares a busy open session's baton (every open session's when `None`, the explicit MCP accept); `reader.rs` `startup_handoff` selects with the same cutoff; `ai-memory-hooks/src/router.rs` `LIVE_BATON_QUIET_PERIOD` | `multi_session.rs` `parallel_live_sessions_keep_their_own_checkpoint_batons`, `startup_claim_rechecks_the_source_and_sweeps_only_quiet_open_batons`; `router.rs` `opencode_parallel_live_batons_are_owned_and_wait_for_quiet` - each fails with its guard removed | STRONG | | 4f | Finalize-on-exit closes only a session the run provably owns (invariant #16) | `ai-memory-cli/src/commands/run.rs` `finalize_hookless_session` runs only for `harness.lacks_session_end_hook()` harnesses (Command Code, Kiro v2/v3, Antigravity); `own_native_session` returns a session only when it is named, chosen before the spawn, or linked under this run's own id in this checkout — a merely *discovered* native session (possibly a concurrent launch's) is never finalized | `run.rs` `a_session_linked_during_the_run_wins_over_discovery` — `own_native_session` returns `None` for the unlinked and other-checkout (`nested`) cases, so a foreign/concurrent session is refused; the named/linked control is finalized (#944) | STRONG | | 4g | `memory_handoff_accept` `status` never points a caller at another session's baton | `ai-memory-store/src/reader.rs` `handoff_claimed_by_live_session` — the caller-supplied session id (header or native `_meta`, routing-only per row 6b) selects which claim to check, but the answer is bounded by the resolved `(workspace_id, project_id)`, the authenticated `handoff_owner_sql` owner filter, `state='accepted'`, and a still-open receiving session; `ai-memory-mcp/src/server.rs` `unclaimed_handoff` reports `consumed_by_hook` only on that match (and, for a requested `handoff_id`, only when it is the matched row), `none_pending` otherwise | `handoff_ownership.rs` `a_session_start_claim_is_confirmed_only_to_its_own_live_session` (forged session id under another operator, unattributed reader, foreign project, sibling session and ended session all empty; owner and root controls see the claim); `ai-memory-mcp` `tests/suite/handoff_identity.rs` `consumed_by_hook_is_reported_only_to_the_session_that_claimed_it` (over the production transport: a colleague forwarding the session id, the owner from another session, and a request with no session id all get `none_pending`; the claiming session gets `consumed_by_hook`) | STRONG | | 4h | Offer-mode SessionStart never consumes or injects handoff-controlled text | `ai-memory-hooks/src/router.rs` `fetch_and_accept_handoff_at` branches on the startup configuration before admission/claim and `render_handoff_notice` emits only typed metadata (`HandoffId`, `AgentKind`, computed age); the normal writer CAS remains the only later claim path | `router.rs` `handoff_notice_is_metadata_only_and_never_carries_the_summary` plants hostile summary/questions/steps/files; `offer_mode_leaves_the_baton_open_and_notice_omits_the_summary` exercises the production hook route, asserts the row remains open, then proves the legitimate exact-id writer claim still consumes it (#1030) | STRONG | | 4i | The compaction recap reads one session's own captured text and never writes or claims | `ai-memory-hooks/src/recap.rs` `handle_recap`/`build_recap` — anchored on the session row (`session_project_ids`), then `grants::authorize_resolved(.., ProjectAccess::Read)` on that project, whose refusal answers exactly like an unknown id (`200` with the no-session notice: no `403`, no project name), and `session_summary_scoped` with the `/handoff` owner filter (`User(key)` or `Unattributed`), so another operator's session answers like an unknown id; open handoffs are read with `list_handoffs` under the same owner filter and never claimed; nothing is written (no claim, active-project publish, observation, or page); stored text is rendered inside the untrusted-history fence with its markers escaped (`escape_untrusted_history_markers`) | `recap.rs` `recap_returns_the_window_and_writes_nothing` (the handoff stays open and the session's row count is unchanged), `another_operator_cannot_read_an_owned_session` (owner control: `owner_reads_own_session`), `db_user_without_grant_reads_a_session_as_unknown` (no status, project name or prompt text gives the refusal away; control: a read grant restores it); through the production `require_bearer` with real `aim_` tokens, `ai-memory-mcp/tests/suite/recap_identity.rs` `a_db_user_cannot_read_another_users_session_through_the_middleware` (owner control), `an_unattributed_caller_cannot_read_an_owned_session` (control: an unowned session stays readable) and `a_db_user_without_a_grant_reads_their_own_restricted_session_as_unknown` (control: a read grant), `recap_for_unknown_session_is_a_notice`, `fence_markers_in_stored_text_are_escaped` | STRONG | | 4j | A hook never claims a handoff for an event that names no project directory | `GET /handoff` claims the newest open handoff in whatever project the server resolves; a request without `cwd` lets the server use its default or the caller's last active project, so the claim could consume another repository's handoff. Every front door gates the claim on a resolved directory and leaves the handoff open for `memory_handoff_accept`: the native hook in `ai-memory-cli/src/commands/hook.rs` `run_with_payload` (`handoff_scoped`, a resolved `policy_cwd`, gates SessionStart, Kimi's UserPromptSubmit and Grok's PostToolUse, with a stderr warning at SessionStart); the shell bundle's `hooks/_lib.sh` `ai_memory_get_handoff` (refuses a URL without the `&cwd=` that `ai_memory_marker_qs` adds for every named directory); the PowerShell `hooks/lib/ai-memory-hook.ps1` `Invoke-AiMemoryHook` (`$FetchHandoff -and $Cwd`); and the generated OpenCode, OpenCode 2, OMP and Pi integrations' `fetchHandoff` (`if (!cwd) return undefined;`). OpenClaw's `fetchHandoff` already required a directory. The only fallbacks are Devin's documented ones (`DEVIN_PROJECT_DIR`, then the hook's own directory), in `resolve_hook_cwd_with`, `ai_memory_resolve_cwd` and `Resolve-AiMemoryCwd`, and CodeBuddy's documented `CODEBUDDY_PROJECT_DIR` in the native hook (never its process cwd) (#1299) | `hook.rs` `a_handoff_is_never_claimed_without_a_project_directory` (CodeBuddy, Claude Code and Codex SessionStart, Kimi UserPromptSubmit and Grok PostToolUse with no `cwd` send no `GET /handoff`; control: the same events with a `cwd` claim once, naming it); `codebuddy_missing_cwd_uses_codebuddy_project_dir_only` and, end to end against a real server, `ai-memory-cli/tests/suite/external_capture.rs` `codebuddy_start_without_a_directory_leaves_the_default_projects_handoff` (a CodeBuddy SessionStart with no `cwd`, run from inside the project, leaves the server-default project's handoff open; controls: the payload's `cwd`, then `CODEBUDDY_PROJECT_DIR`, claim it); `tests/hooks/test_lib.sh` "a handoff is never claimed without a project directory" (every shell script that fetches a handoff, discovered from the bundle and run from an unrelated directory: no payload directory, no claim; control: a directory claims and names it; Devin claims for its own directory; the PowerShell helper runs the same pair where a PowerShell exists, else a static pin); `install_hooks.rs` `generated_integrations_never_fetch_a_handoff_without_a_directory` and, running the generated OMP extension in Node, `slow_generated_handoff_runtime::omp_extension_never_claims_a_handoff_without_a_directory` (an empty `ctx.cwd` sends no claim; control: a directory claims). Each fails with its guard removed | STRONG for the native hook, the shell bundle and the generated integrations; the PowerShell runtime case runs only where a PowerShell is installed | | 5a | Pages shared: `author_id` is never a read filter (invariant #16) | `ai-memory-store/src/reader.rs` `search_pages`/`page_body_by_ids` — `author_id` is an attribution JOIN only, never a WHERE term; `observation_stream_votes` lets every session vote whatever its owner (the owner filter only withholds the observation excerpt and ids, never the vote or the page) | `multi_session.rs` — a page with a **non-null** `author_id` (operator A) is readable by operator B in the same project; `multi_session.rs` `another_operators_observations_lift_their_session_page_in_a_shared_project` (operator B's owned session, authored page and observations lift that page for operator A's query); `ai-memory-mcp` `server.rs` `observation_stream_evidence_stays_inside_a_restricted_project` (a second operator with only a read grant gets the session page lifted by the first operator's observation); `multi_session.rs` `observation_excerpts_follow_the_session_owner_while_the_vote_stays_shared` (B still gets A's session page ranked by A's observation, with the page's own snippet) | STRONG | | 5b | Page supersession (loser stays reachable) | `ai-memory-store/src/ops.rs` `upsert_page_in_tx` — demote `is_latest=0` (never delete) + `supersedes` chain; read side: `reader.rs` `page_versions` lists every row at the path, with no `is_latest` filter, for `/web/w/…/h/*path` and `/api/v1/…/history/*path`. It does apply TTL expiry (`reader.rs` `history_visible`): a version whose own `expires_at` passed is neither listed nor readable by id, a path whose live version expired shows no history at all (the forget sweep hard-deletes every row there), and a `supersedes` link to a hidden version reads as `null`. Expiry is the only thing that hides a superseded version; a concurrent write never does | `multi_session.rs` `concurrent_writes_to_one_path_supersede_rather_than_destroy`; `retrieval_superseded.rs`; `multi_session.rs` `page_history_keeps_the_overwritten_version_and_stays_in_scope`; `ai-memory-web` `history_replay.rs` `history_keeps_both_sides_of_a_concurrent_divergent_write` (two attributed writers on one base: all three versions and both authors listed, one `is_latest`, the two writers' versions diff by id) and `history_lists_k_plus_one_versions_newest_first_and_diffs_the_latest`; bite: an `is_latest = 1` filter in `page_versions` fails both; `history_hides_expired_versions_and_expired_pages` (an expired superseded version and every version of a page whose live version expired answer 404 by path and by id on HTML and JSON, and the live successor's `supersedes` is `null`; control: a TTL in the future is listed and diffable; bites with the per-row predicate removed, with the live-version clause disabled, and with the `supersedes` hiding removed) | STRONG | | 5c | Watcher reindex and Wiki writes share per-page serialization (invariant #16) | `ai-memory-wiki/src/wiki.rs` `reindex_page` takes `mutation_lock.read` before `reindex_page_locked` takes `lock_page`, covering disk read through the single-writer upsert; callers already holding the exclusive global guard do not reacquire it. `write_page` uses the same page mutex and `apply_batch` retains sorted, deduplicated locks; embedding runs after both guards are released | `wiki.rs` `reindex_page_lock_waits_before_reading_and_allows_another_path` (transient snapshot refused while locked, another path progresses, release indexes the committed bytes without rewriting disk), `reindex_page_lock_serializes_write_through_the_upsert` and `reindex_page_lock_and_reversed_duplicate_batches_do_not_deadlock` (real concurrent operations, final disk/SQL winner and supersession ancestry preserved); all three fail with the reindex page guard removed. `reindex_page_lock_releases_both_guards_before_embedding` checks both body and abstract embedding; `decay_cleanup_reindex_does_not_embed_under_the_exclusive_lock` covers the exclusive caller | STRONG for a shared Wiki handle and its clones. External editors and independently constructed Wiki handles do not participate in these locks | | 5d | Wiki project-tree filesystem confinement | `ai-memory-wiki/src/confinement.rs` checks the wiki root, workspace/project roots, every existing ancestor, the final component, and the in-root ordinary `.git` directory with link-aware metadata; repository workdir/gitdir/commondir are containment-checked after opening, and write parents are created one component at a time and rechecked. `Wiki` checks before admission and again under mutation/page locks, while `GitAdapter` rechecks immediately before writes, appends, renames, and removals. Direct reindex, checkpoint restore, manifests, auto-improvement artifacts, ledger append, startup repair, purge/session moves, rollback, migrations, and git tree staging use the same boundary. Confinement is a distinct `WikiError` and therefore cannot trigger missing-file DB fallback | `wiki.rs` `page_reads_refuse_leaf_and_ancestor_symlinks_without_fallback_classification`, `ai-memory-mcp` `admin_read_page.rs` `read_page_does_not_db_fallback_through_a_symlink`, `page_mutations_refuse_ancestor_symlink_and_leave_outside_and_store_unchanged`, `confinement_refusal_happens_before_blocking_and_observer_admission`, `restore_and_reindex_refuse_ancestor_symlink_without_touching_canary_or_db`; `git.rs` `git_metadata_link_is_refused_without_touching_external_repository`, `gitfile_redirect_outside_is_refused_without_touching_target`, `git_metadata_redirect_files_are_refused_before_repository_open`, `configured_worktree_outside_the_wiki_root_is_refused`, `linked_git_metadata_never_reaches_commit_fallback`, `gitfile_metadata_never_reaches_commit_fallback`, `out_of_root_worktree_never_reaches_commit_fallback`, `fallback_preflight_refuses_invalid_git_metadata`, `windows_commit_fallback_accepts_established_eligible_errors`, `eligible_windows_open_error_dispatches_the_fallback_after_validation`, `windows_init_error_dispatches_fallback_after_root_preflight`, `invalid_root_never_reaches_init_fallback`, `checkpoint_and_revision_reads_refuse_a_replaced_git_directory`, and ordinary-repository/API controls `init_is_idempotent_and_creates_dotgit`; `linked_wiki_root_is_refused_during_construction`, `linked_or_dangling_scope_roots_are_refused_but_real_roots_work`, existing `decay_cleanup_refuses_to_reindex_a_symlinked_recreation` and `reindex_rejects_symlinked_scope_manifest`; `ai-memory-mcp` `admin_move.rs` `true_move_refuses_an_invalid_destination_namespace_without_db_changes` and `delete_workspace_refuses_invalid_namespace_before_db_or_notification`; `admin_backup.rs` `backup_refuses_redirected_git_metadata_before_returning_an_archive`, `backup_refuses_wiki_leaf_links_before_returning_an_archive`, `backup_refuses_linked_wiki_directories_before_returning_an_archive`, and ordinary archive controls; `watcher.rs` `direct_file_event_skips_symlink`, `walk_markdown_skips_linked_directories_and_their_ledgers`, and `shared_link_like_helper_accepts_regular_files_and_directories` (Windows directory-link coverage exercises reparse metadata when privilege is available); `backup.rs` `a_pre_migration_backup_refuses_a_linked_wiki_tree_without_an_archive`; `confinement.rs` metadata-helper controls run on every platform | STRONG for imported/static links and same-process mutations. Component checks do not claim descriptor-relative protection against a concurrently malicious same-user process; Windows reparse behavior requires the `windows` PR label/full CI | | 5e | Conditional page mutation (#986, invariant #16) | `ai-memory-store/src/ops.rs` `upsert_page_if` reads the latest version and writes in one transaction, the authority; `ai-memory-wiki/src/wiki.rs` `write_page_if` pre-checks under the per-page lock before touching disk and restores the file when the store check fails; conditional deletes reuse `delete_page_if_latest` (exclusive mutation lock plus an in-transaction check). `ai-memory-mcp/src/server.rs` checks the precondition only after scope resolution and the write grant, against the resolved scope's own latest row, and returns `precondition_failed` with no write, checkpoint or webhook dispatch | `multi_session.rs` `conditional_writers_on_one_base_exactly_one_wins_and_nothing_is_destroyed` (two writers from one version: one wins, the base stays reachable, no loser row; fails with the store check removed); `server.rs` `conditional_page_writes::*` (stale id, `create_only` on an existing page, a version id from another project, and conditional deletes are refused and change nothing, with and without the wiki pre-check; the store check proven to bite with the reader pre-check off); `a_reader_may_read_everything_and_change_nothing` (a read-only user's conditional write or delete, with the right id or a wrong one, gets the authorization error and no version data; the page is unchanged) | STRONG | | 6 | Active-project pointer (PerActor, no clobber) | `ai-memory-core/src/active_project.rs` `set_for`/`lookup_for` (fail-closed on `Mismatch`); `ai-memory-cli/src/commands/mcp_bridge.rs` `bridge_session_id`: the session-less bridge (`--no-session-id`, every non-Claude stdio client on a token-file install) never forwards an inherited `CLAUDE_CODE_SESSION_ID`, so a client launched from a Claude Code terminal cannot steer that session's per-session pointer; the session-aware bridge reads only the variable its `--agent` names (`CLAUDE_CODE_SESSION_ID`, or `MUSE_SESSION_ID` for Meta Muse Code), so neither harness's id stands in for the other's | `active_project.rs` `parallel_harnesses_of_one_user_keep_separate_pointers`, `two_operators_never_read_each_others_pointer`, `a_session_mismatch_fails_closed_once_anything_has_been_keyed`; `mcp_bridge.rs` `only_the_session_aware_mode_forwards_and_requires_a_session_id` (an inherited id is dropped in session-less mode; the session-aware control forwards it and still fails closed without one), `each_harness_forwards_only_its_own_session_id` (a Muse bridge never forwards an inherited Claude id and fails closed without its own, a Claude bridge never forwards an inherited Muse id; controls forward each harness's own id) and `a_session_less_upstream_config_carries_no_session_header`; `auth_default_e2e.rs` `a_session_less_bridge_serves_any_client_without_holding_the_token` (the built `mcp-bridge --agent muse` refuses to start with only `CLAUDE_CODE_SESSION_ID` set) | STRONG | | 6b | Native `_meta` routing coordinate is routing-only (never grants identity) | `ai-memory-mcp/src/server.rs` `attach_native_session` reads only `_meta["ai.opencode/sessionID"]` into `ActorKey.session_id` (trimmed, non-empty-string-validated); `user` still comes from the authenticated `ActorContext`, so a forged `_meta` cannot cross a user/scope boundary — it only selects a session slot behind the existing `active_project` guard | `server.rs` `native_session_metadata_is_routing_only_and_preserves_precedence`, `native_session_metadata_validates_strings_without_granting_identity`; `autoscope_multiuser.rs` `native_metadata_routes_concurrent_reads_and_writes_to_hook_workspaces` (#864) | STRONG | | 6c | Per-user slot namespaces stay per operator (`[slots] per_user`) | `ai-memory-mcp/src/server.rs` `place_slot_mutation`, shared by `place_slot_write` (`memory_write_page`) and `place_slot_delete` (`memory_delete_page`): a non-admin's generic `_slots/.md` is redirected to their own `_slots/u-/` namespace, a path inside another operator's namespace is refused, admins bypass, and nothing changes with the flag off (#1083) | `slot_identity.rs` `oidc_operator_cannot_delete_another_operators_personal_slot`, `oidc_operator_deleting_shared_slot_path_deletes_their_personal_slot` (shared slot survives), legitimate controls `oidc_operator_can_delete_their_own_personal_slot_explicitly`, `admin_can_delete_shared_and_personal_slots` | STRONG | | 6d | MCP page restore is admin-only and cannot bypass the write boundary | `ai-memory-mcp/src/server.rs` `memory_restore_page`: `require_admin_capability` gates listing and restoring alike (git keeps text a root deleted, purged or let expire), the same rung as `/admin/restore-page`; scope resolves once with the no-create mutation resolver (`effective_ids_for_mutation_args_with_actor`), so a restricted project needs a write grant even past the gate; a restore writes through `write_page_in_scope` (the `memory_write_page` body) into that resolved scope, so the sanitizer, admission chain, slot placement, attribution and supersession apply, and `checkpoint_write_metadata` re-validates the checkpoint's metadata | `tests/suite/restore_page_authz.rs`: `a_db_user_with_a_write_grant_cannot_list_or_restore` (an `aim_` user who can write the project is refused for both; root control restores), `a_foreign_restricted_project_is_refused_without_a_grant` (no grant on a restricted project, plus an unknown bearer, refused without leaking the version; root control), `a_personal_slot_restores_to_its_exact_path_and_only_for_root` (the slot owner is refused; root restores the exact `_slots/u-…` path, never the shared slot); `server.rs` `memory_restore_page_refuses_unknown_explicit_scope_and_bad_rev`, `memory_restore_page_round_trips_frontmatter_metadata`, legitimate control `memory_restore_page_lists_checkpoints_and_brings_back_a_deleted_page` | STRONG. Bite checks: removing the admin gate fails the write-grant and slot tests; removing it and also resolving without the viewer fails the restricted-project test; dropping the checkpoint metadata fails the round-trip test | | 7 | Sanitizer trust boundary (invariant #6) | `ai-memory-core/src/sanitize.rs` `Sanitized` (private field, only `sanitize()` ctor); `Sanitized::new` scrubs the title *before* the 80-char display cap; `ai-memory-mcp/src/server.rs` `sanitize_feedback_reason` scrubs the reason *before* the 500-char cap; `ai-memory-hooks/src/router.rs` `durable_body` scrubs the full excerpt *before* the per-event body caps (`payload::durable_body_cap`), which no longer run at excerpt extraction; `ai-memory-hooks/src/payload.rs` `cap_object_fields` scrubs with the built-in sanitizer *before* the client spool cap; `WriterHandle::insert_observation` requires `Sanitized`; `ops` crate-private | Structural (compile-time) + `store/src/lib.rs` `insert_observation_boundary_scrubs_before_disk` + sanitizer scrub unit tests + `ai-memory-core::sanitize` `title_is_scrubbed_before_the_80_char_cap_runs` + `ai-memory-hooks::router` `issue_980_user_prompt_title_is_sanitized_before_truncated` (a secret straddling the 80-char boundary is redacted, not stored truncated; #980) + `ai-memory-mcp::server` `feedback_reason_is_scrubbed_before_the_500_char_cap` (#1109) + `ai-memory-hooks::router` `issue_1114_user_prompt_body_is_sanitized_before_the_16kib_cap`, `issue_1114_post_compaction_body_is_sanitized_before_the_16kib_cap`, `issue_1114_tool_body_is_sanitized_before_the_2kb_cap`, `tool_body_cap_keeps_the_utf8_boundary` + `ai-memory-hooks::payload` `client_body_cap_scrubs_before_truncating` (each places a secret straddling the cap and asserts a `[REDACTED:…]` marker survives with no raw fragment; #1114) | STRONG (structural) | | 7a | Eval observations: server provenance and reason privacy | `ai-memory-consolidate/src/auto_improve.rs` constructs private results from the existing execution, ignores extra model/scorer result fields and all eval results on report deserialization, hashes the evaluated request/bodies, and scrubs reasons before the 2,048-character cap and pending persistence; changed bodies cannot receive the earlier result | `auto_improve::tests::structured_eval_records_server_observation`; `tests/suite/eval_results.rs` `eval_reason_is_sanitized_before_pending_storage_with_legitimate_control` attempts forged deserialized reports and IDs/digests and malicious reasons with secrets/control characters, checks report and stored evidence before sidecar rendering, and preserves a legitimate reason and external evidence without independent results; fails with reason scrubbing or the report deserialization guard removed | STRONG | | 7b | New public page metadata is bounded and cannot supply scope, author or admission controls; admin kind retains its legacy adapter | `ai-memory-core/src/page.rs` `PageWriteMetadata::into_frontmatter`; MCP validates all four fields; admin consumes legacy kind explicitly and shares entities/abstract/relations validation before scope creation; both use `Wiki::write_page` for sanitization/admission/authenticated attribution | `admin_write_page.rs` `write_page_metadata_rejects_invalid_payload_before_scope_creation` (invalid shapes, unknown relation kinds, raw-input over-limit values, untrimmed relation scopes and escaping paths refused; legitimate control admitted), `write_page_metadata_preserves_admin_auth_and_sanitization` (anonymous/DB-user refused, root control admitted with scrubbed metadata and authenticated attribution); `server.rs` `memory_write_page_metadata_rejects_invalid_before_scope_creation`, `memory_write_page_metadata_preserves_scope_author_and_sanitization` (foreign user denied; writer control admitted, forged frontmatter/author ignored, secrets scrubbed, invalid rewrite leaves disk and SQL latest unchanged); core `page_write_metadata_scope_components_refuse_surrounding_whitespace` and `page_write_metadata_limits_apply_before_trimming_and_normalization`; admin `write_page_metadata_admin_legacy_kind_matches_base` compares long/control-character kind behavior with the base adapter; entity-bound refusal bite-checked with its validation removed | STRONG | | 7c | A compaction checkpoint cannot bypass `consolidate` admission by deferring its provider rewrite (#1229) | `ai-memory-hooks/src/router.rs` `write_checkpoint_page` gives the PreCompact / PostCompaction rule-based write the `consolidate` admission operation whenever a consolidator is configured, and `process_authorized` queues the durable rewrite only after that write succeeds; the worker's rewrite runs admission again through `Consolidator::consolidate_session` | `router.rs` `checkpoint_admission_refusal_writes_no_page_and_queues_no_rewrite` (a policy refusing `consolidate` leaves no page and no queued job; control: a policy gating only `handoff_begin` writes the page and queues the job), `checkpoint_preserves_the_consolidate_admission_operation` (the hook path sends exactly one `consolidate` admission call and never calls the provider); both bite-checked with the admission context removed | STRONG | | 7d | The Cursor Agent CLI child, which reads captured session text, never sees the server's own credentials | `ai-memory-llm/src/cursor_agent.rs` `complete_text` spawns `agent` with `env_clear()` plus `CursorAuth::child_env`, which `CursorAuth::new` filters through `cursor_child_env` (an allowlist of `PATH`/`HOME`/user, locale, temp/XDG, session bus, proxy/CA and Windows basics, plus `LC_*`/`CURSOR_*`); the environment and the workspace root are captured once by `ai-memory-cli/src/config.rs` `Config::cursor_auth`, so the provider reads no environment itself; a relative workspace root is refused | `cursor_provider.rs` `the_child_gets_only_the_allowlisted_environment` (`AI_MEMORY_AUTH_TOKEN` and `OPENAI_API_KEY` canaries and the test process's own `CARGO_*` variables never reach the child; control: `PATH`, `HOME` and `CURSOR_API_KEY` do, and the workspace sits under the injected root; bite-checked by dropping `env_clear()`), `a_relative_workspace_root_is_refused`; `config.rs` `cursor_provider_hands_the_child_only_allowlisted_env` | STRONG | | 7e | Executable auto-improvement eval policy is server-owned | `ai-memory-mcp/src/admin.rs` retains the legacy request `eval` key only as a rejected compatibility field and builds `AutoImproveReviewConfig.eval` exclusively from `AdminState`; `ai-memory-cli/src/commands/auto_improve.rs` sends no executable policy over HTTP | MCP `auto_improve_admin_refuses_request_eval_and_inherits_server_defaults` sends a request-controlled program path and receives 422, then omits the field and proves the configured server gate still runs | STRONG | | 7f | Remote authentication and provider error bodies cannot inject terminal controls or expose recognizable secrets at the display cap | `ai-memory-llm/src/response.rs` `provider_error_body` bounds the network read to 1 MiB, applies the built-in sanitizer to the complete bounded body, then caps terminal output to 1 KiB; every LLM provider and every failure branch in `ai-memory-cli/src/commands/auth.rs` uses that helper | `response.rs` `provider_error_body_redacts_a_secret_straddling_the_display_cut`, `provider_error_body_strips_terminal_control_sequences`, and `provider_error_body_caps_what_it_reads_and_what_it_shows` (legitimate prefix/control preserved while secrets and controls are removed) | STRONG | | 8a | Messaging: recipient-only visibility | `ai-memory-store/src/ops.rs` pop target-select + `reader.rs` `list_messages` (`to_*` predicate) | `agent_messages.rs` `a_message_is_only_visible_to_its_recipient`; `agent_messages_tools.rs` non-recipient cannot pop | STRONG | | 8b | Messaging: cancel-own-only | `ai-memory-store/src/ops.rs` `cancel_messages` (AND-gated on `from_*` sender coordinate) | `agent_messages.rs` — whole-outbox **and** a foreign **specific-id** cancel refused | STRONG | | 8c | Messaging: pop-exactly-once | `ai-memory-store/src/ops.rs` `pop_message_in_transaction` atomic CAS `WHERE state='pending'` | `agent_messages.rs` — sequential **and** `tokio::join!` concurrent double-pop yields exactly one `Some` | STRONG | | 8d | Messaging: an inferred scope never changes a queue; a read names it (#854) | `scope.rs` `ScopeSource::is_inferred`; `ai-memory-mcp/src/server.rs` `refuse_inferred_message_scope`, called by `memory_message_pop` and `memory_message_cancel` (mailbox scope) and `memory_message_send` (sender scope) before admission or any writer call, so a `shared_slot`/`startup_seed`/`default`/`default_after_mismatch` scope is refused with nothing claimed, cancelled, or delivered; `inferred_scope_hint` on every inferred `memory_message_list` (inbox and outbox, empty or not); the SessionStart notice (`ai-memory-hooks/src/router.rs` `render_inbox_notice`) and `memory_briefing` `scope` name the inbox from server-side names only | `agent_messages_scope.rs` `unscoped_pop_from_the_shared_slot_is_refused_and_claims_nothing`, `unscoped_cancel_from_the_shared_slot_is_refused_and_the_outbox_survives`, `unscoped_send_from_the_shared_slot_is_refused_and_explicit_sender_is_stamped` (each with the shared slot on another project; session-bound and explicit callers as controls; each fails with the guard removed), `unscoped_list_names_the_mailbox_it_read_even_when_not_empty`; `agent_messages_briefing.rs` `no_scope_pop_is_refused_and_the_briefing_names_the_inbox_to_pop`, `no_scope_inbox_list_that_misses_the_mail_names_the_inferred_scope`; router `session_start_notice_surfaces_pending_mail_without_consuming_it` (names the inbox, carries no message text) | STRONG | | 8e | Messaging: admission is the same for MCP and admin sends (#756) | `ai-memory-mcp/src/admin.rs` `handle_send_message` runs `Wiki::authorize_operation(.., AdmissionOp::MessageSend, ..)` at the recipient scope before `insert_message` (a refusal is 403 and stores nothing) and calls `notify_operation_observers` only after a successful commit; skipping webhooks needs `Capability::SkipAdmissionChain` (`skip_webhooks_for_admin_request`); the MCP path is `server.rs` `memory_message_send` | `admin.rs` `admin_message_send_reject_leaves_inbox_empty` (a rejecting webhook gets 403 and an empty inbox; fails with the admission call removed), controls `admin_message_send_observer_sees_commit_without_blocking_response` and `admin_message_send_full_inbox_does_not_notify_observer` | STRONG | | 8f | Messaging: the web mailbox shows a restricted sender's name and a card's count only to a reader of that repository; it never consumes mail | `ai-memory-store/src/reader.rs` `list_inbox_with_senders` (`readable_repository_predicate("sp.id", viewer)` on the sender join, so the name is `NULL` for an unreadable sender) and `pending_inbox_counts` (`readable_repository_predicate("p.id", viewer)`); `ai-memory-web/src/routes/project.rs` `mailbox` is `list`-only, no pop/cancel route exists, and `mailbox_scope` matches the URL's exact project name (as `list_pages` does), so an alias never shows another project's mail | `routes.rs` `web_mailbox_hides_restricted_sender_names_and_cards` (a granted reader of the recipient is shown "a project you cannot read", the restricted sender's name and card never reach the grid, alice as the control; fails with either predicate removed), `web_mailbox_lists_only_this_projects_inbox_and_never_consumes_it` (sender and bystander pages show nothing of the recipient's mail, a POST is 405 and the message is still pending, the body is HTML-escaped), `web_mailbox_does_not_follow_a_project_alias` (a legacy key that resolves to `acme-api` shows none of its mail on `/w/default/api`; `acme-api` itself is the control) | STRONG | | 9 | Scope resolution fail-closed | `ai-memory-store/src/scope.rs` no-create `lookup_existing_*`; identity-aware lookup accepts exact/canonical/v2-basename keys only when they deduplicate to one UUID; `ops.rs` `resolve_project_name_for_write` performs write authorization, optional name-only promotion, and audit in one writer transaction; create only via `create_explicit_scope`. The admin destructive project routes (`admin.rs` `resolve_target_for_admin`, used by `purge-project` / `rename-project` / `move-project`) accept a bare `project_id` as an alternative to the name: it is parsed to a UUID (400 on a malformed one), looked up **within the workspace the request named** via `ReaderPool::project_name_by_id` (404 when it names no row there, so a real id from another workspace is refused rather than resolved), and the two spellings are mutually exclusive (both or neither is a 400). `list-projects` carries the id in each row, and inherits the same row-level visibility filter as the name, so a restricted project leaks neither. `ProjectNameAmbiguous` refuses cross-forge/multi-candidate keys before mutation; its message names each holder and the key it matched by, but a restricted holder only as "a restricted project", because the lookup runs before any caller is authorized and the project list hides restricted names from callers without a grant; an audit failure after the name UPDATE rolls the transaction back. V76's derived-key indexes bound hot lookup to exact/canonical/legacy equality probes with at most four rows. Promoting public APIs return `ResolvedWriteScope` metadata (no untraced promotion API); MCP, admin, and managed-workstream write entry points make Wiki refresh/checkpoint the committed scope manifest after promotion and surface `manifest_warning` on success or any later operation failure; pure managed-run input validation precedes promotion. A managed run whose `project` is the name its checkout's git remote derives (`PrepareManagedRunRequest.repository_identity`, accepted only through `accept_wire_identity` and only when `path_style_name` equals the requested name) resolves through `resolve_project_by_identity_for_capture` — the identity holder, else the cwd-prefix project with `$HOME` excluded, claimed or renamed only for a writer — and then `authorize_scope_for(Write)` on the writer connection, so an identity can neither redirect a differently named run nor open, claim, or create a project the caller may not write; Project manifests persist the typed full identity/source and derived keys so clean reindex restores the same UUID and lookup behavior. Also `ai-memory-hooks/src/router.rs` `workspace_override_is_rescope` — the hook router's session-sticky gate resolves a marker's `workspace` override via this same no-create `lookup_existing_workspace` (never a hand-rolled lookup) and fails closed (treats it as a rescope, disqualifying sticky) on a different or unresolvable workspace; only an override that resolves to the session's OWN workspace is not a rescope (#976) | `scope.rs` `read_resolution_table_driven`, `multi_scope_resolution_deduplicates_and_validates`, `a_slashed_label_reaches_only_a_project_the_caller_may_use` (a `workspace/project` label passed as the project is authorized as the pair it names and creates nothing when refused; granted control), `a_slashed_write_resolves_its_own_pair_and_never_creates`, `an_existing_slashed_project_wins_over_the_split`, no-auto-create + `unscoped_write_with_unresolvable_coordinate_errors`; `dual_key_scope.rs` `page_write_failure_after_promotion_preserves_manifest_warning`, `handoff_failure_after_promotion_preserves_manifest_warning`; `workstream.rs` `managed_run_rejects_mutually_exclusive_selection_before_promotion`, `managed_run_downstream_failure_after_promotion_preserves_manifest_warning`; `identity_resolution.rs` `canonical_and_legacy_reads_resolve_one_legacy_row_without_renaming`, `authorized_write_promotes_in_place_and_preserves_dependents_and_legacy_lookup`, `read_only_user_cannot_promote_a_legacy_name`, `canonical_target_conflict_rolls_back_the_promotion`, `audit_failure_rolls_back_promotion_and_keeps_the_writer_alive`, `exact_name_that_is_another_forges_canonical_key_fails_closed`, `an_ambiguous_name_names_its_holders_but_not_a_restricted_one` (restricted holder unnamed; open holder named as the control), `concurrent_legacy_and_canonical_resolve_or_create_converge_on_one_uuid`; admin `admin_purge.rs` `purge_project_refuses_a_real_id_from_another_workspace` (a row that really exists in another workspace answers 404 from `default` and is left intact), admin_rename/admin_move `rename_project_refuses_a_real_id_from_another_workspace` / `move_project_refuses_a_real_id_from_another_workspace` (the same for rename and move, the foreign project keeps its name and workspace, with the same id accepted from its own workspace as the control), `purge_project_still_refuses_an_ambiguous_name_by_default` (a name held by two rows -- one via its `legacy_name` -- stays a 400 and purges nothing), `purge_project_by_id_reaches_the_twin_and_leaves_the_name_holder_intact` (legitimate control: the id reaches the row the name could not, and the name-holder survives with its name), and store `list_projects_with_stats_hides_a_restricted_projects_id_from_an_ungranted_viewer` (the id is absent exactly where the name is, for an ungranted viewer, and present for the operator -- bite-checked with the visibility filter removed); `project_coordinates.rs` `shared_candidate_query_deduplicates_before_bound_and_uses_only_indexes`; wiki `promoted_identity_round_trips_through_manifest_and_clean_reindex`, `old_project_manifest_without_identity_still_reindexes`, `reindex_rejects_project_coordinate_fields_that_do_not_match_identity`; admin `write_page_surfaces_manifest_failure_after_promotion`, `bootstrap_surfaces_manifest_failure_after_promotion`, `move_project_merge_surfaces_destination_promotion_manifest_warning`, `move_session_create_surfaces_destination_promotion_manifest_warning`; `multi_session.rs` `two_operators_promote_two_clone_keys_to_one_uuid_without_moving_active_pointers`; MCP `dual_key_scope.rs`; hooks `workstream.rs` `managed_run_by_remote_identity_claims_the_folder_named_project`, `managed_run_by_remote_identity_routes_past_an_identityless_twin` (name-only control is ambiguous), `managed_run_identity_never_redirects_another_project_name`, `managed_run_by_remote_identity_refuses_a_viewer_without_write` (outsider refused with no project created; granted member admitted as the control), `managed_run_identity_never_claims_a_restricted_legacy_project` (an outsider whose cwd sits in a restricted identity-less project is refused, the identity stays unrecorded and no twin appears; fails with either the writer's claim check or the route's write check removed; a granted member claims it as the control), `managed_run_identity_routing_stays_inside_the_requested_workspace`, `concurrent_identity_runs_on_a_legacy_project_converge_on_it` (invariant #16), `managed_run_promotion_refreshes_the_scope_manifest`, `managed_run_discloses_manifest_failure_after_committed_promotion` static read/write/authz/cross-workspace/cross-forge cases; `router.rs` `workspace_override_is_rescope_only_on_a_genuine_workspace_change` (same/different/unresolvable workspace cases), `sticky_routing_keeps_the_session_project_across_a_sibling_checkout` (regression: same workspace forwarded on every event + `project_src=repo-root` from a nested checkout stays sticky), `sticky_routing_still_rescopes_when_marker_names_a_different_workspace_mid_session` (opposite direction: a genuinely different workspace still rescopes even under `sticky`) | STRONG | | 10 | Destructive-op live-process refusal + confirm flags (invariant #9); `purge-project`'s `dry_run` always wins over `confirm` so a preview request can never become destructive | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project`; `admin.rs` `handle_purge_project` checks `req.dry_run` unconditionally, before `req.confirm`, mirroring `reclaim-ledger-versions` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir; `uninstall.rs` `purge_guard_refuses_a_live_sibling_before_stopping_the_service` — `uninstall --purge-data` checks for siblings (the `setup` service's own pid excepted) before it stops that service, and `purge_guard_rechecks_after_stopping_the_service` refuses when the stopped service is still alive; `admin_purge.rs` `purge_project_confirm_true_and_dry_run_true_still_only_previews` — `{"confirm": true, "dry_run": true}` deletes nothing (the regression this row now also covers); `ops.rs` `purge_project_dry_run_changes_nothing` — every project-scoped row count, the `purged_scopes` tombstone, and the `audit_log` row are identical before/after a `PurgeMode::Preview` run; `ops.rs` `purge_project_counts_collateral_damage_in_another_project` and `admin_purge.rs` `purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project` — a purge of project P collaterally deletes an observation, and orphans a handoff's session reference, in a *different* project Q (via `sessions` cascading out of P), and both the preview and the confirmed report count it | STRONG | | 10b | Session purge is scope+owner-bound (no cross-session/project over-delete); `purge-session`'s `dry_run` always wins over `confirm`, same as `purge-project` | `ai-memory-store/src/ops.rs` `purge_session` — selection scoped to `(workspace_id, project_id)` and keyed on this session's own `summary_page_id` **or** `path='sessions/.md'` + `json_extract(frontmatter_json,'$.session_id')=` (frontmatter owner, not the recursive latest-chain); `in_scope==0 → NotFound` fail-closed; the session's prompt `profile_candidates` (`source_ref = 'session:'`, a globally unique id) are deleted in whichever project the harvest filed them, matching the `observations` cascade they were harvested from; whole op in one transaction, and the tombstone it leaves is checked by `begin_session_row` and by the new-session arm of `admit_hook_session_event`; `move_project_workspace` re-stamps the `purged_sessions` tombstone with the rest of the project so it still matches after a move; `admin.rs` `handle_purge_session` checks `req.dry_run` unconditionally, before `req.confirm` | `ops.rs` `purge_session_leaves_a_sibling_session_in_the_same_project_intact`, `…refuses_a_session_from_another_project_and_deletes_nothing`, `…refuses_a_session_from_another_workspace`, `…does_not_delete_an_identically_pathed_page_in_another_project`, `…removes_older_summary_versions_without_deleting_prior_manual_page` (#862); `admin_purge.rs` `purge_session_confirm_true_and_dry_run_true_still_only_previews` — `{"confirm": true, "dry_run": true}` deletes nothing; `ops.rs` `purge_session_dry_run_changes_nothing` — every table a session purge touches, the `purged_sessions` tombstone, and the `audit_log` row are identical before/after a `PurgeMode::Preview` run; `ops.rs` `purge_session_counts_collateral_damage_in_another_project` and `admin_purge.rs` `purge_session_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project` — purging session S collaterally deletes an observation, and orphans a handoff's session reference, in a *different* project (via `S`'s own id cascading), and both the preview and the confirmed report count it; `admin_purge.rs` `purge_session_dry_run_refuses_a_session_outside_its_named_scope` — the same session previewed under a different real project (same workspace) or a different real workspace whose project shares the name both 404 with no counts in the body, with the session's own scope as the 200 control; the `purged_sessions` tombstone holds against live hook ingest as well as `begin_session`: `ops.rs` `a_purged_session_is_not_recreated_by_hook_admission` (`admit_hook_session_event` refuses a purged session and writes nothing, with a sibling session and the same id in another project admitted as controls) and `router.rs` `async_processing_drops_events_of_a_purged_session` (the late delivery is a terminal `dropped_invalid`, not a failure the client retries) and `batch_drops_a_purged_session_then_commits_next_item` (the batch endpoint acknowledges it as `dropped_invalid` and commits the next item instead of stopping fail-fast); the user's own sentences the profile harvested from the session's prompts go with it: `ops.rs` `purge_session_deletes_the_profile_candidates_harvested_from_it` (including the candidate harvested from a prompt the session recorded in another project, whose observation the purge deletes through the cascade; a sibling session's candidates in both projects and a page candidate survive); `ops.rs` `a_purged_session_stays_purged_after_its_project_moves` (after `move-project`, `begin_session` for the purged id in the destination is refused with `SessionPurged`; a fresh session there is admitted as the control) | STRONG | | 10c | Ledger reclaim is content-gated, not filename-gated (invariant #16) | `ai-memory-store/src/ops.rs` `reclaim_ledger_versions` — a candidate row must pass `ai_memory_core::log_ledger::body_opens_with_log_ledger` on its own `body`, and only `is_latest=0 AND superseded_at IS NULL` rows are eligible, so decay-owned rows stay with `forget-sweep`; confirm gate in `admin.rs` `handle_reclaim_ledger_versions` | `reclaim_ledger_versions.rs` `a_prose_page_wearing_the_ledger_name_keeps_its_whole_version_chain`, `an_ordinary_page_chain_in_another_project_is_untouched`, `a_decay_tombstone_stays_with_the_sweep_that_owns_it`; `admin_reclaim_ledger_versions.rs` `deleting_without_confirm_is_refused_and_changes_nothing` (with dry-run and confirmed controls) | STRONG | | 10d | Workspace delete's own row counts are scope-bound to `workspace_id` (a workspace's *own* rows are never over- or under-counted by a sibling workspace's), its cross-workspace observation/handoff cascade is reported by both preview and confirm rather than prevented (same as `purge-project` one level down), the non-empty guard fails closed without `force`, and `delete-workspace`'s `dry_run` always wins | `ai-memory-store/src/ops.rs` `delete_workspace` — every direct count and the `DELETE FROM workspaces` itself are scoped by `workspace_id = ?1`; the two `collateral_*` counts are scoped by `workspace_id != ?1` so a row is counted as one or the other but never both; `projects_deleted > 0 && !force → WorkspaceNotEmpty` fail-closed, and a missing workspace is `NotFound`, both checked identically under `PurgeMode::Preview` and `PurgeMode::Commit`; whole op in one transaction; `admin.rs` `handle_delete_workspace` checks `req.dry_run` unconditionally, before building the confirmed path, mirroring `reclaim-ledger-versions`/`purge-project`; root-only gate is the shared `/admin/*` multi-user auth ladder, not a separate check | `admin_move.rs` `delete_workspace_dry_run_never_counts_a_different_workspaces_rows` — previewing workspace A counts only its own project/page/session/observation/handoff rows, not workspace B's (which holds two like-named projects and its own full row set), and a session living in B that stamps an observation directly into A is counted in A's own `observations_deleted`, never in `collateral_observations_deleted` (the two counts are mutually exclusive by construction); `admin_move.rs` `delete_workspace_force_true_and_dry_run_true_still_only_previews` — `{"force": true, "dry_run": true}` deletes nothing; `ops.rs` `delete_workspace_dry_run_changes_nothing` — every table's row count, including `workspaces` itself and the `purged_scopes` tombstone (the two tables a confirmed delete's own transaction writes to), is identical before/after a `PurgeMode::Preview` run; `ops.rs` `delete_workspace_dry_run_and_confirmed_delete_both_report_collateral_damage_in_another_workspace` and `admin_move.rs` of the same name — deleting workspace W collaterally deletes an observation, and orphans a handoff's session reference, in a *different* workspace (via `sessions` cascading out of W), and both the preview and the confirmed response count it identically — the cascade itself is reported, not blocked, by either; `admin_move.rs` `delete_workspace_dry_run_refuses_non_empty_without_force` / `delete_workspace_dry_run_nonexistent_workspace_returns_404` — same `409`/`404` a confirmed delete would give; `admin.rs` `multiuser_delete_workspace_dry_run_rejects_db_user_and_anonymous` — root reaches the preview handler (404 control), DB user gets 403, anonymous gets 401 | STRONG | | 10e | Reconcile-tombstone of a disk-deleted page is opt-in, soft, scope-bound, and false-positive-guarded (invariant #16, #929 delete-half) | `ai-memory-wiki/src/watcher.rs` — off by default (`[maintenance] reconcile_tombstones_deleted_pages`); a page is tombstoned only after its file is observed missing on two consecutive passes (streak resets if the path's `PageId` changed), re-verified against the live filesystem immediately before acting, with reserved/indexed-but-unwalked paths (`bootstrap.md`, `_meta.md`, `_pending/`, `sessions/*.md`) excluded and a circuit breaker that skips a whole scope when `> max(3, 50%)` of its pages look missing or a non-partial walk finds nothing; `ai-memory-store/src/ops.rs` `soft_delete_for_reconcile_if_latest` is a soft tombstone (`is_latest=0` + `superseded_at`, decay's shape) through the single writer actor in one transaction — never a filesystem write — scoped to the page's own `(workspace_id, project_id)` | `watcher.rs` `reconcile_delete_disabled_by_default_never_tombstones_a_missing_page`, `reconcile_delete_cross_project_isolation` (project A's deletion never tombstones project B's same-path page), `reconcile_tombstone_refuses_a_stale_latest_id`, `reconcile_tombstone_refuses_when_the_file_still_exists`, `reconcile_tombstone_never_touches_the_filesystem`; `ops.rs` `reconcile_tombstone_resurrects_instead_of_orphaning_on_recreate` | STRONG | | 10f | Session-time repair is scope-bound (no cross-project timestamp rewrite) | `ai-memory-store/src/ops.rs` `repair_session_times` — every candidate is read back and updated with `WHERE id=? AND workspace_id=? AND project_id=?`; a row outside that 3-tuple is reported `NotFound` and untouched, same code path as an id that does not exist at all; `ai-memory-mcp/src/admin.rs` `handle_repair_session_times` passes the caller's `(workspace, project)` straight through, never the candidate's own | `ops.rs` `repair_session_times_does_not_touch_a_session_of_another_project` (control: sibling session in-scope repaired in the same batch); `admin_repair_session_times.rs` `cross_project_session_is_not_found_and_untouched`; both bite-checked by dropping the `project_id` filter | STRONG | | 10g | The event-ledger age sweep deletes only closed ledger files of the swept project: opt-in, content-gated, admission-gated, never the writer's months (invariant #16) | `ai-memory-consolidate/src/sweep.rs` `sweep_event_ledgers` + `ledger_is_expired` (off at `[decay] ledger_retention_months = 0`; never the current or previous UTC month, whatever the age; a dry run only lists; a per-file delete failure is counted in `ledgers_undeleted`, kept and retried — the sweep does not abort, exactly as a refused page delete is kept and retried); `ai-memory-wiki/src/wiki.rs` `list_event_ledgers` / `delete_event_ledger` — rotated `log-YYYY-MM.md` name AND `log_ledger` content gate (checked before and after the admission round-trip under the mutation guard), a project-confined path, no symlink, never `log.md`, no store write; then the same blocking admission check a page delete runs (`remove_page_locked`): webhooks subscribed to `delete_event_ledger` are notified BEFORE the unlink, a `reject`-policy webhook refuses and the file stays, and the non-blocking observers are told only after the removal is durable | `consolidate` `tests/suite/ledger_sweep.rs` `an_ordinary_page_named_like_a_ledger_is_never_deleted` (a page, a frontmatter page and `log.md` are kept; the real ledger is the control), `another_projects_ledgers_are_untouched`, `the_current_and_previous_month_survive_even_a_one_month_retention`, `the_default_configuration_deletes_no_ledger`, `a_dry_run_reports_files_and_bytes_and_deletes_nothing`, `a_rejecting_admission_webhook_keeps_the_ledger_and_the_report_says_so` (adversarial: a `reject`-policy webhook on `delete_event_ledger` — the file stays intact, the report counts it in `ledgers_undeleted`, the sweep keeps the rest going), `an_accepting_admission_webhook_is_told_after_the_file_is_gone` (legitimate control: an accepting gate lets the delete through and the non-blocking observer receives the op only after removal); `sweep.rs` `ledger_guard_protects_the_current_and_previous_month_regardless_of_age`; `wiki` `tests/suite/ledger_files.rs` `an_admission_webhook_cannot_swap_a_ledger_for_an_ordinary_page_before_delete` (the webhook overwrites the accepted ledger while deletion awaits its response; the final gate preserves that page, while a no-swap webhook is the legitimate delete control), `a_page_wearing_a_ledger_name_is_not_deleted_even_when_asked_by_name`, `only_a_rotated_ledger_name_is_accepted` (traversal and `log.md` refused), `deleting_in_one_project_leaves_the_neighbours_ledger` | STRONG | | 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter; batch acknowledgements retain only processed indices and classify terminal drops without consuming capacity | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded`; `router.rs` `handle_hook_batch_skips_rate_limited_first_item_and_accepts_later_source`, `handle_hook_batch_reports_failed_index_after_rate_limited_skip`, `handle_hook_batch_drops_subagent_events_before_capacity_check` (each asserts result indices and includes an accepted control) | STRONG | | 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`; argv elements matched whole and tokenized, and every word that holds several words, such as a quoted `sh -c` script or a `"$(…)"` substitution, re-split to four levels by `ShellCommand::split`/`push_nested_words`, a deeper script metadata-only; ANSI-C strings decoded and a backslash before an ordinary letter dropped (`shell_words`, `push_word`); backslash-newline continuations joined (`shell_words`); a parameter default's operand, a value glued to a short flag and a `file:` URL's path read as candidates (`parameter_operands`, `shell_arguments`, `file_url_path`); PowerShell and cmd escape characters read out under the `PowerShell` tool or on a Windows host (`without_windows_escapes`); `~+` resolved to the cwd and a `~-`/`~N`/`~name` prefix metadata-only (`names_unknown_directory`); shell and interpreter options that take a value skipped while looking for a `-c` script or inline code (`takes_shell_value`, `runs_inline_code`, which also lists `lua`/`Rscript`/`osascript -e`, and `su -c`/`script -c` as script runners); paths compared without regard to case on a macOS host (`fold_case`, set by `resolve`; patterns and candidates composed to NFC and lowercased in full by `fold_path`, TS `captureFold`; client-only, the server's sentinel re-inspection never depends on it); a `file:` URL cut at its query or fragment before decoding (`file_url_path`); a parameter default nested past four levels metadata-only and a default read again after backslash removal (`parameter_operands`, `push_word_reading`); a tilde expanded or treated as an unknown directory only when the shell expands it, unquoted and in a script rather than a quoted message or argv element, every tilde under PowerShell (`marked_shell_words`, `literal_tildes`, `expand_tilde`); a file or shell read of a harness's saved tool output, or a glob that could match one, metadata-only under an active policy (`output_spool`: Claude Code `tool-results/` and `tasks/*.output`, OpenCode `tool-output/`, Pi `pi-bash-*.log`; glob segments fit a name they share a match with, `globs_intersect`, TS `captureGlobsIntersect`, and one fitting segment must spell three characters in a row of that name's distinctive text literally, `spells`, TS `captureSpells`, so `*.log` is not read as `pi-bash-*.log`, a trade-off that leaves a glob with no such literal run, `/tmp/p?-b*`, outside the rule; the TS matcher applies it to `read_many_files` entries too); every other marker governing a file-tool target (an `apply_patch`'s header paths, read after leading whitespace, `patch_paths`), a search/list tool's directory, file or path-like glob argument (`search_scope`, Hermes `file_glob` included; a grep `pattern` is not read) or a separator-bearing shell argument also inspects the call, found by the lexical marker walk from each location's nearest existing directory, each directory's answer remembered for every lookup that passes it (`marker.rs` `MarkerMemo`, TS `captureMemoMarker`, seeded with the event's own walk, `remember`, TS `captureMemoRemember`), bounded (4,096 locations, 16 markers, and 16,384 probes sharing the glob walk's 50 ms, `MAX_LOOKUP_PROBES`, TS `CAPTURE_MAX_LOOKUP_PROBES`; locations outside the own checkout are looked up first, a glob's literal root before the walk below it (whose single listing can spend the time budget on a large directory), and past a bound fail closed as under an invalid policy, while running out inside the own checkout, whose locations are looked up shallowest first, ends the lookup, its own policy governing there) and combined strictest-first (`capture_lookup_locations`, `CaptureDecision::stricter`, `hook.rs` `foreign_capture_decision`, also applied by `transcript_capture.rs` `decide_call`; TS `captureForeignConfigs`, `captureDecide`, `captureSearchScope`, `capturePatchPaths`); a location with glob syntax is looked up from its literal root and, when that root lies outside the event's `.git` checkout, every directory the glob could pass through is checked for a marker (`glob_walk`, `hook.rs` `GlobBudget::markers_below`; TS `captureGlobWalk`, `captureMarkersBelow`, `captureOwnCheckout`: a `*`/`?` segment lists the child directories it matches, a class/brace/group every child, a `**` the subtree except the event's checkout's; a glob rooted inside the event's checkout is not walked; a checkout root at or above the home directory, or the filesystem root, is never the own checkout for these rules (`own_checkout`), so a dotfiles `~/.git` does not stop the walk from `~/Projects`; symlinks never followed; 256 directories, counted as a `**` lists, 32,768 entries and 50 ms, `MAX_GLOB_TIME`, TS `CAPTURE_MAX_GLOB_MS`, injectable clock, past which, or for a brace/group spanning a separator, a `..`/`~` alternative or one a bounded brace expansion builds, `brace_alternatives`, TS `captureBraceAlternatives`, a directory alternative bash before 5.2 expands to `..` (`.{*,x}/`), or an expansion too large to list whose pieces could build one of these (`brace_overflow_could_leave`, TS `captureBraceOverflowLeaves`: integer and letter sequences cannot, so `file{1..200}.txt` is walked like any glob), the call fails closed); a `..` after a `**` is read both as the `**` matching no directory and as matching some, by the event's own matcher and the lookup (`normalize_readings`, TS `captureReadings`, at most 16 readings, past which the call fails closed), and so is a directory segment that bash before 5.2 can expand to `..` (`could_name_parent`, `class_glob_matches` with POSIX classes such as `[[:punct:]]` and extglob groups such as `@(.)`, `?(.)`, `*(.)`, `+(.)`, `!(x)`; TS `captureCouldNameParent`, `captureClassGlob`, `captureNamedClass`), read as `..` too; inside double quotes a backslash before `$` or a backquote is dropped, so a quoted script (`bash -c "…"`, `eval "…"`) re-split for its words reads an ANSI-C string as the shell it runs does (`shell_tokens`, TS `captureMarkedWords`); a zsh glob group kept in its word and ANSI-C numeric escapes decoded are fed to the lookup (`shell_tokens` with `expand`, `ShellWords::expanded`, `glob_group`, `ansi_c_numeric_escape`; TS `captureMarkedWords(…, true)`, `captureGlobGroup`, `captureAnsiNumeric`); a file tool whose targets cannot be read or resolved, and a search/list argument that is not a string, fail closed; the path of a git `REV:path` object name tried from the cwd and the marker directory (`git_object_path`); Antigravity `run_command` resolved from its `Cwd`); an invalid marker makes file, search/list and shell calls metadata-only; an active one makes a shell call metadata-only when a word uses unquoted brace expansion, a bracket class (a leading `]` included), a zsh glob group or extglob, or a numeric/control ANSI-C escape (`has_unevaluated_expansion`, also run on scripts given to `sh -c`, `eval` or a substitution), or when it runs inline interpreter code (`runs_inline_code`: `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `deno eval`), and either makes tools that read back an earlier command's output metadata-only (`reads_earlier_output`: `BashOutput`, `TaskOutput`, `write_stdin`, `process`, `read_bash`/`write_bash` and PowerShell twins); the server admits a metadata-only shell or unknown-family body only under an active or invalid marker, with no paths and an `extracted` state (`router.rs` `metadata_protocol_is_legal`), and its own re-inspection strips an older client's keep for either; a Gemini `read_many_files` literal entry is also read as `entry/**` (`glob_matches_below`), and Copilot CLI's runtime `view`/`create`/`str_replace_editor` are file tools for that agent only (`agent_family`); Factory Droid's Claude-shaped payload is read by `extract`, its `Create` and `ApplyPatch` are file tools for that agent only (`agent_family`), `ApplyPatch` reads its `input` envelope like Codex `apply_patch`, and its `LS` `directory_path`, `Glob` `folder`/`patterns` and `Grep` `glob_pattern` locate the call for the cross-checkout lookup (`search_direct_paths`, `search_scope`) (#1240); Meta Muse Code's Claude-shaped payload is read by `extract` with its `tool_use_id`, its `read_file`/`write_file`/`edit_file` `path` resolves lexically against `cwd`, its `read_memory`/`add_memory`/`edit_memory` are file tools for that agent only whose `project`-scope `path` is placed under `.agents/memory/` and whose personal scopes fail closed to metadata-only (`muse_memory_paths`), `monitor` is a shell for that agent only, and `bash_input` is an output reader for that agent only; CodeBuddy Code's payload carries Claude Code's tool names and fields and is read by the same `extract` arm (#1299); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`, `captureShellSplit`, `captureUnevaluatedExpansion`, `captureInlineCode`, `captureGitObjectPath`, `captureGlobBelow`, the `reader` flag). Candidate/argument flavor (`Flavor::Posix` vs `Flavor::Windows`, used to pick which `ignore_paths` patterns even apply) is derived from the **host** (the cwd), never from the candidate string alone — a POSIX-host candidate spelled with a leading `//` is collapsed to a single `/` before flavor detection (`normalize_candidate`; TS `captureNormalize`'s `windowsHost` parameter, sourced from `captureHostWindows(cwd)`), fixing GHSA-vh98 (a `//`-prefixed candidate used to self-classify as `Flavor::Windows` regardless of host, matching zero POSIX patterns and being captured instead of dropped); a genuine Windows/UNC host (cwd itself windows-flavored) is unaffected; `hook.rs` `--check-capture` preflight uses the same capture policy before any spool or spawn; transcript imports (`ai-memory backfill`, including the automatic first-boot run, the `ai-memory run` ledger upload, and `ai-memory recover`'s finish-failed re-import and degraded-run replay in `recovery.rs` `recover_finish_failed`/`recover_degraded_run`, each built for the journaled checkout's `cwd`) apply the same decision table through `commands/transcript_capture.rs`: each recorded tool call is rebuilt in its agent's hook shape (`capture_policy.rs` `tool_call_payload`) and inspected by the checkout's policy, and by a reroute destination's policy too; a Drop removes the call and its result (paired by native id, else the strictest call of the turn when the result has no id; a result whose id matches no call, or with no call, follows the orphan rule: dropped under a policy, content-free without one), metadata-only posts the hook's metadata-only body with its protocol (backfill) or a withheld placeholder keeping only the family and a call id that passes the server's `valid_call_id` (ledger), and a shell call whose recorded arguments cannot be parsed drops; both imports also apply the hook's repository admission gate (`hook.rs` `checkout_admits_capture`: persisted capture mode, marker opt-in, readable routing marker — the same helpers the hook's own gate uses), so under allowlist mode an unmarked checkout imports nothing, prompts included: `backfill` stops before contacting the server and the run ledger finishes with no events; a tool the transcript names differently from the hook is rebuilt under the hook's name and fields (`capture_policy.rs` `hook_tool_call`: Codex `exec_command` `{cmd, workdir}` → `Bash` `{command, workdir}`); the Claude Code and Codex JSONL adapters label each event with its record's cwd (`transcript.rs` `stamp_record_cwd`, `RECORD_CWD_KEY`), from which the import resolves arguments, policy and reroutes, falling back to the session cwd, and which is removed before a ledger upload; a rerouted call, and an event whose record cwd lies outside the checkout, must pass that directory's admission gate, resolve to the same server-profile route as the checkout and to the same project scope (`transcript_capture.rs` `admits_elsewhere` → `hook.rs` `checkout_admits_capture` + `delivers_to_same_server`, which compares two `resolve_hook_route` results and never matches a refused selection, + `routes_to_same_project`, which compares the two `hook_scope` hint sets and, when they leave the project to the server, under `repo-root` the repository/marker anchor or the directory itself, under the default `basename` the directory itself or a destination inside the repository whose root is the source), else it drops with its result, since the import files every event under the importing project; compaction, checkpoint and annotation events answer to the same record-cwd checks; Claude Code `!` shell-mode output (a user message opening with a ``/``/`` block, whatever it holds) is dropped (`shell_mode_output`); the Claude Code adapter excludes harness-written background-task turns (`transcript.rs` `claude_task_notification`: `origin.kind` `task-notification` in any case, or a first text block opening with a ``, a prompt quoting a tag, and another agent's message kept); **`backfill.rs` `backfill_posts_no_content_for_unrecognized_tools`** (a planted Codex MCP call's arguments and output never post, the call and output post as `tool unknown`; a recognized shell output is the posted control) and `transcript_capture.rs` `unrecognized_tools_keep_no_content_with_or_without_a_policy` (no policy and an active one; ledger content withheld; public read kept; a call-less result content-free), failing with the unknown-family rule removed, the backfill one also with its content-free body removed; **`backfill.rs` `backfill_keeps_reroutes_to_the_same_server_profile_only`** (a reroute into a checkout selecting the source's registered profile and project is posted; one selecting a different registered profile is not, nor one of another project on the same profile, nor a prompt recorded there), failing with the profile comparison forced either way and with the project comparison removed; `transcript_capture.rs` `a_record_cwd_in_another_project_is_dropped` and `a_reroute_into_another_project_is_dropped` (prompt, call and result recorded in, or a read reaching into, a checkout declaring another project drop; one declaring the same project is kept), failing with `routes_to_same_project` removed; `hook.rs` `subdirectories_of_one_repository_are_one_project_only_under_repo_root` (two subdirectories of an unmarked repository, and a subdirectory importing its root, are two projects; the same directory, the root importing a subdirectory and a `repo-root` marker agree), failing with the basename rule removed; **`backfill.rs` `backfill_posts_no_task_notification_or_compact_summary_as_a_prompt`** and `transcript.rs` `claude_adapter_keeps_task_notifications_and_compact_summaries_out_of_prompts` (a monitored command's notification, a background agent's report and an origin-tagged notice are excluded, also behind a byte-order mark and zero-width characters, in another case, with attributes, after a non-text block, or with an origin kind in another case; a compact summary, also flagged `"true"` or `1`, becomes a compaction event; the typed prompt, also one quoting the tag, and a look-alike tag are the controls), failing with either adapter rule removed or with the spelling rules reverted; `transcript_capture.rs` `boundary_events_answer_to_the_record_cwd_admission_gate` (compaction and annotation recorded in a refused directory drop; in the checkout kept), `a_result_whose_id_matches_no_call_is_an_orphan` (an unmatched id cannot borrow a public read's keep; the read's own result kept), `claude_shell_mode_output_is_not_imported` (a block holding a literal closing tag drops) and `unrecognized_tools_keep_no_content_with_or_without_a_policy` (ledger keeps no tool name and no invalid call id), each failing with its rule reverted; `windows_spelling_fixture_vectors` (shared `capture-policy.json` `windows` drop vectors for every spelling above, file tool and shell, with other-drive, other-share, longer-name and pipe controls, and `windows_posix_controls` proving a POSIX session keeps `/c/…`, trailing-dot and colon names; `windows_scopes` drop vectors for a `\\?\C:\`, `\\?\UNC\…`, `\\localhost\C$\…` or UNC cwd and marker directory with relative patterns, a `\\?\C:\…` absolute pattern from `C:\repo`, bare and double-quoted `\\server\share\…`/`\\?\…`/`\\.\…`/`\\localhost\c$\…` PowerShell, cmd, `run_shell_command` and argv commands, and both readings of `/a/x` from `D:\a\…`, each with public-path, other-drive and other-share keep controls; the Node runtime evidence runs the same vectors with the marker spelled in the Windows cwd), `tool_family_fixture_vectors` (shared `tool_families`, every native name per family), new `decisions` vectors (`execute_code` metadata-only, `read_file`/`patch`/`fs_read` operations drop, `write_to_file`/`browser_navigate` keep), `output_spool_work_is_linear_and_budgeted` (a 2,040-wildcard-segment path stays under a linear work bound; 32 such paths fail closed; one long or 32 short paths keep), `an_oversized_shell_command_is_unprovable` (one character over the limit is metadata-only and unlooked-up, at the limit keeps, argv summed, inactive untouched), `inline_code_tools_are_unprovable_under_a_policy`, `hook_capture::capture_section_spellings_match_toml_semantics` (shared `marker_spellings`, including a nested-array line `["docs"]` before a dotted `capture.ignore_paths` that must still drop, and a key line inside an open array, text after a closed array and a multi-line inline table that must fail closed), and the Node evidence test running all of these vectors against the generated TypeScript plus its entry-bound read count for the directory walk; each fails with its rule reverted (Windows reading off, spool memo dropped or budget failing open, length check off, inline-code rule off, a name dropped from the list, TS dotted key or inline-table rule reverted, TS walk back on `readdirSync`, Windows reading of the cwd/marker directory or of an absolute pattern off, literal-backslash reading off, the cwd-drive reading of `/a/x` off, TS array tracking or any `captureValueScan` refusal off, Rust and TS alike); new `windows_scopes` vectors (drive-relative, device-namespace and Git Bash doubled-backslash shell words and a `C:docs` `workdir` metadata-only, `FileSystem::` file paths, shell words and `workdir` dropping, a POSIX absolute pattern in a Windows marker failing closed, with `type README.md`, `dir /s /q`, `git status`, a URL, `type C:\repo\README.md` and `Get-Content -Path C:\repo\README.md` kept) and `windows_marker_rejects_posix_absolute_patterns` (the same pattern stays active in a POSIX marker) fail natively and in the Node evidence test with the unplaceable check, its device branch, the `workdir` check, the provider strip or the marker rejection removed; `inspection_sentinel_matches_cwd_flavor` (#1250: POSIX sentinel Invalid on a Windows cwd, Windows-flavored sentinel Active there and on UNC, POSIX sentinel Active on `/repo` and Git Bash `/c/repo`) is native only, since `inspection_sentinel` is server re-inspection with no TypeScript counterpart, and fails with the cwd-flavored sentinel or the marker rejection removed; removing the POSIX-reading excusal makes `type C:\repo\README.md` metadata-only, and excusing a device reading keeps the doubled-backslash device path; `capture_policy.rs` `windows_lookup_locations_keep_glob_segments_whole` (a Windows session trims trailing dots from plain segments only, so `{.,}.` still reaches `..` and the sibling checkout's marker; bites with the glob-segment exemption removed) | STRONG with listed residuals. Matching is lexical by design, so these stay open: shell variables (`$VAR`, `FILE=… sh -c 'cat $FILE'`) and the output of a command substitution, a `cd` inside the command, a script read from a file or a heredoc fed to an interpreter (read only for the words it spells out), git's repository root approximated by the marker directory, symlinks (paths are never resolved on disk), a recursive search of a parent directory (`grep -r x docs`, `rg x`) through a shell, a search tool rooted at a literal directory above another checkout (it is not walked) and a glob whose literal root lies inside the event's own checkout reaching a checkout nested there (it is not walked), a marker nested inside the own checkout below what the lookup budget reaches, a `.git` the same command creates (`touch .git; cat */x`: the hook reads the tree after the command ran, so that directory counts as the own checkout; a `.git` at or above the home directory never does), a parameter expansion next to glob syntax (`${X}/*/x`, not walked), a glob through a symlinked directory, a tool's own directory option (`git -C`), script runners and interpreters the matcher does not list (their words are still read, but expansion or inline code in them is not recognized), a value glued after the first letter of a short-flag cluster, non-ASCII percent-escapes in a `file:` URL, a bare word naming another checkout's directory (only separator-bearing arguments are looked up for another marker), harness output files outside the listed default locations, and tools the matcher does not list, which keep their current unknown-tool behavior. Transcript imports add: assistant prose quoting an excluded file is kept (as live prompt/assistant capture keeps it), harnesses whose hook schema the matcher does not read (Crush, Kimi Code) keep their tool events as live capture would, adapters that record no per-record cwd (Pi, OMP, OpenCode, Crush, Kimi Code, Command Code, Kiro, Grok) judge every event from the session cwd, a Codex ledger upload resumed from a mid-file cursor judges the events before the next turn context from the session cwd, and id-less adapters over-drop a whole turn's results when one call is excluded, and when the routing hints leave the project to the server, events from inside one repository count as the importing project even where the server has a separate project registered for a subdirectory (anywhere under `repo-root`; under `basename` only when the import runs from the repository root); a metadata-only import keeps an `event_id` (ledger) and `ingest_key` (backfill) derived from a SHA-256 of the original content plus session and record ids, left in place because those ids are the retry-dedupe keys and a content-free id would merge distinct events — it only confirms a guess of low-entropy content. Under allowlist mode a refused checkout's `ai-memory run` still records repository checkpoint metadata (HEAD, branch, a status hash and changed-path names, never file content), which the run also sends at launch. Re-splitting quoted words over-drops a quoted message naming an ignored path, and inline interpreter code is metadata-only under an active policy. Mitigation: allowlist capture mode (`install-hooks --capture-mode allowlist`) for sensitive projects. Bite checks: removing the nested re-split, the git object path or its marker-root base, Antigravity's `Cwd` key, the leading-`]` rule, either zsh-group rule, ANSI-C decoding or its numeric-escape rule, the `\o` reading, the inline-code rule, the nesting-depth rule, the nested-script scan, the line-continuation join, the Windows escape reading, the shell-option and interpreter-option value skips, the added runners and interpreters, the glued-flag value, the `file:` URL path, the `~+` resolution, the unknown-tilde rule, the parameter-default reading or case folding each fails the Rust tests, and each TypeScript counterpart fails the Node evidence test; so does reverting the `file:` URL cut, the deep-default rule, the escaped-default reading, the quoted-tilde rule, the harness-output rule or Unicode folding (the new fixture vectors fail against the previous matchers in both languages), and disabling the foreign-marker lookup fails the native, import and Node cross-checkout tests; reading every glob location as literal, skipping search/list locations, or skipping a file tool whose targets cannot be read each fails the native, import and Node glob/search/unreadable tests, removing `apply_patch` header reading fails the own-patch control, and reverting the saved-output glob rule fails the new fixture vectors in both languages; walking globs rooted inside the event's checkout fails the monorepo tests, accepting any literal text for a saved-output glob fails the `*.log` vectors, removing the time budget or counting a `**`'s directories only after it lists fails the bounds tests, and removing the `**`-then-`..` readings, the brace-expansion check, the expanded lookup words, the patch-header trim, the `file_glob` key or the dot-glob parent reading each fails the Rust tests and the Node evidence test; treating every oversized brace expansion as leaving, dropping the brace-built dot-glob check, listing the own checkout's subtree under a `**`, looking up locations in their given order (outside before inside, then shallowest first), dropping the per-directory memo, counting a checkout at home as the own one, keeping the backslash before `$` in double quotes, or dropping the POSIX-class or extglob reading each fails its Rust test and the Node evidence test; in a Windows session a shell `workdir` spelled `/c/x` is read only as Git Bash reads it, so arguments resolved from it miss a native tool's `D:\c\x` reading, the TS marker reader rejects structural errors anywhere in the marker but does not validate other values' TOML (`x = nope` invalidates the policy only natively), an administrative share reached through a loopback or host alias other than `localhost`/`127.0.0.1` (`\\127.0.0.2\c$`, `\\127.1\c$`, `\\\c$`) or a `\\wsl$\…` path is matched as the UNC path it spells, Windows matching folds ASCII case only, and a drive-relative word counts as placed when a placeable argument's POSIX reading spells it; a `**/` matches one or more directories, never zero (documented in `marker-file.md`) | | 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH | | 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | `ai-memory-cli/src/server_profiles.rs` `resolve` (validated `ProfileName`, strict `servers.toml` parse, `roots` required once two profiles exist, component-wise root match) and `marker.rs` `find_server_selection` (inherited down the tree, any value shape counts); `commands/hook.rs` `resolve_hook_route` drops a `Rejected` route before spool, handoff and backfill, and hands the drainer no live token for a profile route; `commands/hook_spool.rs` `static_retry_token` (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on `profile`; generated TS `captureServerRouted` drops a routed repository and gates `fetchHandoff`; `hooks/_lib.sh` `ai_memory_server_routed` (flag refused by `ai_memory_post_hook`/`ai_memory_get_handoff`) and `hooks/lib/ai-memory-hook.ps1` `Test-AiMemoryServerRouted` drop it in the script hooks; `--check-capture` refuses a rejected server-profile selection without printing its token or URL; once `[auth].bearer_token_file` names `auth-tokens/default` (this install's root bearer), `commands/server.rs` `refuse_the_root_token_name` refuses `server add default` and `server remove default` so no profile can be handed, overwrite or delete the root bearer | `hook.rs` `each_repository_spools_to_its_own_profile_with_its_own_token`, `muse_hooks_honour_marker_scope_profiles_exclusions_and_allowlist` (the Meta Muse Code settings-hook front door: marker workspace/project, the profile's server and token never the install's, the payload's session id, an ignored relative `read_file` dropped with a kept control, allowlist silence from an unmarked checkout with a denylist control), `a_selection_that_does_not_resolve_emits_nothing` (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), `session_start_handoff_comes_from_the_profile_server_only`, `a_repository_without_a_server_key_keeps_the_install_default`; `hook_spool.rs` `a_profile_entry_is_never_retried_with_the_install_live_token` (server B accepts exactly the install's live token and must still not get it), `a_profile_entry_recovers_with_its_own_rotated_token` (control), `a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default`, `profile_and_default_entries_at_one_address_ride_separate_batches`; `server_profiles.rs` roots/registry/name tests; `marker.rs` `nested_markers_without_server_inherit_the_ancestor_selection`; `install_hooks.rs` `generated_integrations_fail_closed_on_a_server_profile_marker`, `openclaw_plugin.rs` `openclaw_plugin_fails_closed_on_a_server_profile_marker`, and the `server-routed-*` checks in `generated_capture_policy_v1_node_runtime_evidence`; `hook.rs` `an_event_without_a_payload_cwd_routes_by_the_process_cwd`, `a_refused_route_prints_nothing_for_kimi_user_prompts`; `hook_spool.rs` `a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url`; `server_profiles.rs` `changing_the_url_without_a_token_discards_the_old_token`, `omitted_roots_keep_the_registered_ones`; `marker.rs` `outside_home_the_walk_reaches_a_marker_above_the_checkout_root`, `encoding_noise_cannot_hide_a_server_key`, `an_unreadable_marker_is_a_refused_selection`; `backfill.rs` `a_spawned_backfill_authenticates_like_the_hook_that_spawned_it`; `tests/hooks/test_lib.sh` "server profiles (#992)" section; `hook.rs` `a_mixed_spool_drains_each_event_only_to_its_own_server` (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); `tests/suite/server_profiles.rs` (the built binary: `server add` → `hook` spools to the profile with its token, unknown profile spools nothing, `uninstall` removes the tokens; `two_real_servers_each_receive_only_their_own_repository` runs two real `ai-memory serve` children with different root tokens and checks on each server which repository landed there); `ai-memory-hooks` `powershell_server_routed.rs` `server_routed_guard_mirrors_the_native_walk` (runs `Test-AiMemoryServerRouted` under real PowerShell: inherited, BOM, look-alike keys, the `$HOME` boundary with its control, past a checkout root outside home, current directory); `hook.rs` `a_selection_that_does_not_resolve_emits_nothing` (rejected routes refuse preflight; resolved profile control passes); `tests/hooks/test_lib.sh` and `powershell_home.rs` `a_trailing_separator_on_home_keeps_the_walk_boundary` (a `$HOME` ending in `/` or `\` keeps both script walks at home: a `server` marker above it routes nothing, with a root-home control that does); `server.rs` `the_root_bearer_file_name_cannot_be_registered_or_removed` (refused at the root bearer path; another name and an install without a root bearer file are the controls); `server_profiles.rs` `a_default_profile_shares_the_root_bearer_only_at_its_path`, `clear_tokens_keeps_the_root_bearer_and_removes_profile_tokens` | STRONG for native hooks, generated TS, `.sh` and `.ps1` hooks. Known gap: an older binary draining a shared spool ignores `profile` | | 11e | Native self-upgrade archive confinement (#1025) | `ai-memory-cli/src/commands/upgrade.rs` normalizes every tar/zip path, rejects links and special entries, extracts only the binary and `hooks/`, accepts only the exact non-runtime support layout emitted by `release.yml`, and caps both entry count and declared expanded bytes before writing | `upgrade.rs` `release_workflow_support_assets_are_exact_and_ignored` (shipped support paths admitted, near-miss docs/packaging/template paths refused), `extracts_archive_built_like_release_yml` and `extracts_zip_happy_path_with_hooks` (real workflow-shaped archives succeed but support assets are not written), `validate_rejects_path_traversal`, `zip_extract_rejects_path_traversal`, `tar_extract_rejects_declared_expansion_past_limit`, with binary/hooks extraction as the legitimate control | STRONG | | 11f | Native hook executable-path integrity after self-replacement (#1027) | `ai-memory-cli/src/commands/render_shared.rs` `hook_embedded_exe_path` is the single executable resolver for every persisted native hook form (POSIX/Windows command, Windows exec, Zero, ZCode, OpenCode 2 assistant capture): it prefers an existing literal path, strips Linux's trailing ` (deleted)` procfs marker only when the stripped installed binary exists, and otherwise falls back to the platform command name. A binary running from `/nix/store` always renders the bare command (#1275): the store path names one package version that garbage collection deletes, while the profile's `bin/` on `PATH` follows upgrades. Upgrade keeps the already-loaded config and refreshes in-process | `render_shared.rs` `embedded_exe_strips_the_marker_when_the_stripped_path_exists`, `embedded_exe_preserves_a_real_file_whose_name_has_the_marker_suffix`, `embedded_exe_falls_back_when_the_resolved_path_is_missing`, `hook_embedded_exe_path_names_an_existing_file_without_marker` (asserts the bare command only when the test binary itself runs from the Nix store), `hook_rendered_from_a_nix_store_exe_names_the_bare_command` (the existence probe reports the store binary present, so only the store-path guard yields the bare command in the rendered exec hook) with its control `hook_rendered_from_a_normal_exe_keeps_the_absolute_path` (`/usr/bin`, `~/.nix-profile/bin`, and a non-root `.../nix/store/...` keep the absolute path); `upgrade_e2e.rs` `native_upgrade_refreshes_hooks_without_a_deleted_path_or_losing_cli_data_dir` reproduces the replacement window through the shipped binary, plants the corrupt suffix, and proves refresh removes it while preserving a CLI-only data dir | STRONG on Linux reproduction plus platform-neutral resolver/render coverage; Windows path rendering remains covered by its existing command/exec tests and full Windows CI | | 11g | Cross-project tool attribution cannot bypass source or destination capture, or destination server routing (#932, #1199) | `ai-memory-hooks/src/capture_policy.rs` `absolute_tool_location_paths` accepts only bounded, recognized, absolute locations: file-tool targets, a search/list tool's explicit `path`, or a shell command's structured `workdir` (command text is never parsed for a location); `ai-memory-cli/src/commands/hook.rs` `tool_location_routing_cwd` requires every path to prove the same repository/marker boundary, refuses the reroute when the payload cwd's own capture policy would not keep the event (source `ignore_paths` may name paths outside the source checkout, and a command may name source files while its `workdir` points elsewhere), and resolves that destination before capture policy, allowlist marker, server profile, scope hints, or spool credentials. Relative, mixed-project, unsupported, and arbitrary non-project paths keep the payload cwd | `hook.rs` `absolute_file_target_uses_destination_scope_and_capture_policy` (hostile content under the destination exclusion is never spooled; a public control is routed there), `absolute_file_target_cannot_escape_a_destination_server_profile` (unregistered destination profile drops instead of falling back to source/install default), `file_target_routing_refuses_relative_external_and_mixed_project_paths`, `workdir_reroute_applies_the_destination_capture_policy` (a `workdir`-rerouted command reading a destination-excluded path is dropped, public control kept), **`a_reroute_cannot_escape_the_source_capture_exclusions`** (adversarial: a file read under a source pattern that names another project's directory, and a shell command naming an excluded source file with `workdir` in another project, are both refused; public file and shell controls still reroute; fails with the source-policy check removed), `hermes_tool_location_finds_the_marker_the_process_cwd_lacks` (Hermes `terminal` `workdir`, `read_file` `path` and `search_files` `path` reach the repository marker under allowlist; no-workdir, relative-workdir and relative-search controls stay with the unmarked process cwd); `capture_policy.rs` `absolute_tool_location_paths_cover_search_dirs_and_shell_workdirs` (relative workdir, command-text paths, workdir without a command, and Hermes `process` yield no location) | STRONG for native `ai-memory hook`; generated and compatibility hooks retain cwd-only routing | | 11h | Docker-wrapper capture-policy capability (#1002) | `bin/ai-memory` routes ordinary Linux/macOS `install-hooks`, upgrade refreshes, and every `hook`/`hook-drain` invocation through the stable checksum-verified native host client, so the hook process executes the capture-policy boundary in 11b before spool/network; a hook config that calls the wrapper never runs in the helper container, and an unavailable client drops the event with exit 0 instead of falling back to it. Only an explicit `AI_MEMORY_HOOK_PLATFORM=posix|windows` retains the documented script compatibility path and its warning; `AI_MEMORY_NATIVE_BIN` may not point back to the wrapper. For a server initialized on the named volume, `bin/ai-memory` `refresh_volume_token` (run only by `install-hooks`) copies `/data/auth-tokens/default` (only when `/data/config.toml` names it) to an owner-only `native-runner/server-token` through the helper's stdout, and `use_volume_token` points the native client at that copy (the managed host commands and `mcp-bridge` only read it and never enter the container engine) with `AI_MEMORY_AUTH__BEARER_TOKEN_FILE`, so it is a file-sourced token (never rendered into a harness config) and never an argv; a refresh that reads no token from a reachable volume deletes the copy, and a successful full `uninstall --apply` deletes it after the container returns; a bind-mounted `AI_MEMORY_DATA_DIR` is read directly and an explicit `AI_MEMORY_AUTH_TOKEN` wins | `scripts/check-native-packaging.sh` `Checking host-launch wrapper routing`: a default allowlist install reaches the fake native client while a Docker executable that always fails proves no container fallback occurred; the explicit `posix` control reaches fake Docker and adds no native invocation; the self-referential native override is refused. `tests/wrapper_upgrade.sh` `lifecycle hook native host routing`: `hook --event session-start` and `hook-drain` reach the fake native client with the stdin payload while `docker`/`podman` stubs on PATH and in `AI_MEMORY_DOCKER` stay unused, and a non-executable `AI_MEMORY_NATIVE_BIN` exits 0 without reaching the engine; `Checking the wrapper hands host commands the volume's root bearer`: for `mcp-bridge`, `install-hooks` and `run` the fake native client receives the token file (mode 600, right content) and the token is absent from its argv; controls: a bind-mounted data dir and an explicit `AI_MEMORY_AUTH_TOKEN` make no copy and ask no container (bite: dropping the export fails the check); `Checking the host token copy goes with the volume's token and with uninstall`: a volume answering with no token removes the copy and hands none over, while an unreachable engine keeps it; a dry-run, `--only hooks` or failing `uninstall` keeps it and a successful `uninstall --apply` removes it (bite: without either removal the check fails); `auth_default_e2e.rs` `a_host_client_given_the_token_file_by_env_authenticates` (the native side of the handoff, with a no-variable control) | STRONG on the shipped Linux/macOS wrapper. Windows and remote/manual script installers remain explicitly outside capture-policy v1 | | 11j | Offline recovery journal and transcript replay | `ai-memory-cli/src/commands/recovery.rs` keeps bounded locators, ordered semantic transcript digests, pre-launch cursors and persistent correlated-delivery/loss evidence in an atomic journal, refuses a symlinked data dir and any symlinked component inside it (ancestors above the data dir are trusted, so macOS `/var` → `/private/var` works), sanitizes/caps failure reasons, and bounds each replay plus the whole pass; `hook_spool` coordinates enqueue and quarantine with short filesystem locks, canonically resolves partial scope from the recorded cwd, bounds per-file reads and current global occupancy across the main spool and recovery directories, sweeps completed tombstones, and holds correlated events outside drain until replay succeeds or restores them on failure; degraded resumes use `ai-memory-workstream`'s adapter-validated delta export, whose blocking parse and baseline scans are capped by record, event-count, source-byte and serialized-byte totals; `backfill::import_exact_session` chunks by item and byte limits and requires per-item durable acknowledgements; `run.rs` journals every session launch, including explicit spool-only evidence for unsupported exporters, and journals a resumed export only after capturing its exact pre-launch cursor; `workstream.rs` `/recover/finish` retains owner/project authorization, rejects superseded runs, and validates a finished retry by durable transcript/native/cursor/exit identity without regenerating its checkpoint; validated recovery ingest keys bind session, agent, event kind and sanitized event identity until session purge | `transcript.rs` `persisted_baseline_exports_only_offline_append_and_rejects_rewrite`, parser/baseline cap and ordered-semantic digest tests; CLI `run_offline.rs` real degraded spool-only and overlap/idempotency tests; `recovery.rs` `spool_only_without_correlated_evidence_never_auto_clears`, `a_dropped_spool_event_stays_lost_on_the_next_empty_pass`, loss/time-budget tests, `symlinked_journal_and_ancestor_are_refused`, `a_symlinked_ancestor_of_the_data_dir_is_trusted`, `a_symlink_inside_a_data_dir_under_a_symlinked_ancestor_is_refused`; `hook_spool.rs` partial-scope late enqueue, completion-race, occupancy churn/active-cap, bounded-read and tombstone-sweep tests, plus interval isolation (`completed_tombstone_keeps_a_later_resume_of_the_same_session` proves a post-interval resume hook is delivered, `completing_one_recovery_interval_leaves_the_other_intervals_entries_intact` proves one interval's completion never consumes another's evidence and ambiguous entries fail closed into the main spool); `backfill.rs` `two_sequential_recovery_intervals_on_one_resumed_session_replay_exactly_once` proves the second interval's content and session-end are stored with end-of-session effects while reruns stay pure replays; `transcript.rs` `an_oversized_first_record_is_skipped_by_session_listing_caps` proves the first-record byte caps bite before allocation; store `insert_observation_ingest_dedups_on_key` rejects cross-session and changed-event preclaims; hooks `expired_run_recovery_is_owner_only_and_refuses_superseded_runs` proves changed transcript refusal and changed-checkpoint no-op | STRONG on Unix integration coverage; Unix lock opens use no-follow and atomic replacement rechecks the destination. Windows owner-only ACL/reparse and lock semantics require the PR `windows` lane | | 11i | OpenCode executable probing and generated-config cleanup (#1097) | `ai-memory-cli/src/config.rs` captures the full process environment once in typed `RuntimeEnv`; `run.rs` overlays explicit `run --env` values with platform key semantics and uses that same effective environment for executable resolution, probing, and launch without rereading process environment; `opencode_dialect.rs` invokes only direct argv `--version`, caps output and time, and rejects malformed/future majors. Automatic launch skips only OpenCode on probe failure; explicit OpenCode fails. `install_hooks.rs` quarantines the incompatible sibling before reading its exact ownership markers, restores unowned content with an atomic no-replace link, and never deletes or overwrites a concurrent replacement; `install_mcp.rs` owns the shared dual-schema endpoint/token inference used by both installers, rejects conflicting generated V1/V2 entries before mutation, and migrates an alternate-major entry only when endpoint, generated shape, and absent-or-Authorization-only headers match, preserving custom headers and ambiguous entries; auto-wire migrates MCP before hooks infer from that exact config path and skips hook mutation when transition preflight fails | `run.rs` `automatic_opencode_probe_failure_keeps_another_harness_available`, `opencode_executable_probe_selects_v2_for_launch_and_autowire`, effective-child-environment PATH casing controls, fake V1/V2/env-derived-major/malformed/future/timeout executable tests, `setup-agent` V1/V2 no-probe integration controls; `install_hooks.rs` `opencode_plugin_cleanup_removes_only_exact_owned_incompatible_sibling`, `opencode_plugin_cleanup_preserves_a_concurrent_user_replacement`, `opencode_plugin_restore_moves_quarantine_when_destination_is_free`, `opencode_plugin_restore_preserves_an_existing_replacement`, `opencode_plugin_restore_does_not_replace_a_file_created_at_restore_window`, `opencode_plugin_cleanup_does_not_escape_custom_target_directory`; `install_mcp.rs` `opencode_install_reuses_owned_endpoint_and_token_across_major_transitions`, `opencode_install_refuses_conflicting_owned_transition_entries`, `opencode_install_does_not_infer_from_a_user_owned_transition_entry`, `opencode_mcp_migration_moves_only_owned_alternate_shape`, `opencode_mcp_migration_preserves_ambiguous_user_entry`, `opencode_mcp_migration_preserves_custom_headers`, `opencode_mcp_migration_accepts_only_historical_generated_headers` | STRONG for Unix process probes and platform-neutral ownership gates; exact Windows executable resolution remains covered by `run.rs` resolver tests and requires the PR `windows` label | | 11k | Server-side tool drop list (#1241) | `ai-memory-hooks/src/drop_list.rs` `IngestDropList::matches`, checked in `router.rs` `admit_hook` and the `handle_hook_batch` loop right after the client capture protocol and before the rate limiter, the ingest semaphore, the sanitizer and storage; lists from `[ingest] drop_tool_use_id_prefixes` / `drop_tool_names`, validated once in `Config::load` (at most 64 entries of 128 printable characters each). Only tool events match: a tool-call id by prefix, a tool name exactly, both read through the per-agent mapping `capture_policy::tool_payload_fields` that tool metadata uses (generic Claude-style fields for an agent without one; Factory Droid sends no tool-call id, so only its tool name can match; Meta Muse Code sends its provider call id in `tool_use_id`, and CodeBuddy Code Claude Code's `tool_use_id`, so both can). A drop answers 202 and counts as `dropped_by_policy`; the matched value is never logged | `router.rs` `the_ingest_drop_list_drops_before_any_capacity_is_spent` (with the semaphore exhausted a listed event still answers 202; control: an unlisted one gets 429) and `the_ingest_drop_list_applies_to_hook_batches` (listed items acknowledged as policy drops, the unlisted item ingested); `drop_list.rs` unit tests (prefix only at the start, names only exact, non-tool events never match, bounds refused; `each_agent_is_matched_on_its_own_tool_fields` matches OpenCode `callID`, Hermes `extra.tool_call_id`, Antigravity `toolCall.name`, Muse and CodeBuddy `tool_use_id` and CodeBuddy `tool_name`, with same-agent controls kept, and fails when only the generic fields are read). Both router tests fail with `matches` forced to `false` | STRONG. The list applies to every project on the server; it narrows what is stored and never widens access | | 11l | Marketplace plugin front door: a plugin-launched event gets exactly the native hook's capture policy, scope and server routing, is captured once beside settings-file hooks, and sends nothing without the binary (#1166) | `plugins/*/scripts/ai-memory-hook` (rendered from `crates/ai-memory-cli/templates/plugin/ai-memory-hook.sh` by `render_plugins.rs`) has no capture logic: it only finds the binary (an `ai_memory_bin` option is honoured only as an absolute path, so a checkout cannot plant a relative one) and runs `ai-memory hook` with `AI_MEMORY_HOOK_ORIGIN=-plugin`, `hook` first so the Docker wrapper routes it, omitting `--server-url` when the option is empty (a pre-3.0 binary's usage error, exit 2 before it reads stdin, is retried once against the default local server with a stderr hint), and without a binary drains stdin, prints `{}` (guidance on `session-start` only) and exits 0; `hook.rs` `is_dropped_plugin_copy` + `plugin_origin.rs` `drops_plugin_event` drop a plugin copy when the user `settings.json` (honouring `CLAUDE_CONFIG_DIR`) or the checkout's `.claude/settings.local.json` carries an ai-memory hook for the same event (`install_hooks::hooks_file_delivers_ai_memory_event`); a Droid-launched copy (`droid-plugin`) is checked against the one file Droid reads hooks from (`~/.factory/hooks.json`, else the `hooks` key of `~/.factory/settings.json`); a Muse-launched copy (`muse-plugin`, recognized by `MUSE_PLUGIN_ID` with `MUSE_SESSION_ID`) is checked against the `hooks` key of `${XDG_CONFIG_HOME:-~/.config}/muse/settings.json`, and its subagent events are dropped; both drop a folderless session (no `cwd`, or a Claude desktop `scratch-workspaces` cwd); Codex plugin events are never de-duplicated (an untrusted settings hook is skipped silently, so dropping could lose the event); the drift test keeps every plugin hook on the native path (no `hooks//*.sh`, no credential in any plugin file) | CLI `tests/suite/plugins.rs` through the committed launcher and the real binary: `ignore_paths_hold_through_the_plugin` (ignored path never spooled; public-file control kept), `allowlist_mode_holds_through_the_plugin` (unmarked checkout emits nothing, prompts included; marked control captured), `marker_scope_routes_plugin_events`, `server_profiles_route_plugin_events` (profile URL and token, never the plugin's URL), `a_settings_hook_for_the_same_event_drops_the_plugin_copy` (user and project settings; unwired-event and no-settings controls), `a_droid_hook_for_the_same_event_drops_the_droid_plugin_copy` (Droid `hooks.json`; no-Droid-hook, Claude-settings-only, unwired-event and folderless controls), `a_muse_hook_for_the_same_event_drops_the_muse_plugin_copy` (Muse `settings.json`; no-Muse-hook, Claude-settings-only and unwired-event controls; subagent and folderless events dropped), `plugin_origin.rs` `muse_plugin_copy_is_dropped_when_muse_settings_hooks_deliver_the_event` (a matcher-narrowed hook does not count) and `test_plugin_launcher.sh` (an inherited `MUSE_SESSION_ID` without `MUSE_PLUGIN_ID` stays `claude-code`), `folderless_sessions_are_not_captured_through_the_plugin`, `without_a_binary_nothing_is_sent_and_the_agent_is_not_blocked`, `foreign_hosts_are_attributed_or_ignored`, `committed_plugins_match_the_renderer`, `plugin_tree_carries_no_credentials`; `plugin_origin.rs` unit tests (`codex_plugin_events_are_never_deduplicated_against_settings`, `droid_settings_hooks_count_only_while_hooks_json_is_absent`, `only_a_catch_all_matcher_suppresses_the_plugin_copy`: a settings hook narrowed by a matcher never drops the plugin copy); `hook.rs` `plugin_copy_is_dropped_only_with_the_plugin_origin`; `tests/hooks/test_plugin_launcher.sh` (a relative `ai_memory_bin` planted in the checkout is never run; PATH control); `tests/e2e/claude_plugin_smoke.py` (real `claude -p`: settings hook plus plugin give one `user-prompt`) | STRONG. Verified 2026-10-13: removing the `is_dropped_plugin_copy` guard fails the de-duplication and folderless tests (and the smoke reports `user-prompt x2`); disabling the native `Drop` disposition or the allowlist gate fails the `ignore_paths` and allowlist tests through the launcher. Not covered: tool events from a live model turn (no credential in CI), Windows Git Bash, Cowork and the desktop Code tab (see `docs/design-marketplace-plugins.md`) | | 11m | The server never opens a filesystem path named in a hook payload | `ai-memory-hooks/src/payload.rs` `HookEnvelope::from_query_and_body` (run by `/hook` and every `/hook/batch` item) only reads the posted JSON; Antigravity step-log enrichment (`antigravity.rs` `enrich_antigravity_step_output`, which opens `/.system_generated/steps//output.txt`) runs only client-side in `ai-memory-cli/src/commands/hook.rs` against the client's own home, and the server stores whatever `tool_response` the client sent | `router.rs` `a_hook_payload_cannot_make_the_server_read_its_own_disk` (an Antigravity `PostToolUse` batch naming a server-only directory through `artifactDirectoryPath` and through `transcriptPath`; no stored observation or file contains the server-side sentinel); `payload.rs` `envelope_parsing_never_reads_a_payload_named_antigravity_artifact` and `antigravity_post_tool_use_renders_client_enriched_output` (control: client-supplied output still rendered) | STRONG. Both adversarial tests fail with the server-side enrichment call reinstated (the sentinel file content is stored and readable through search) | | 12 | Network/auth posture, including auth on by default for new installs (an existing install keeps loopback no-auth until `ai-memory auth enable`) | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer`; `serve.rs` mounts `/identity` inside the host and machine-auth gates even with web disabled; `nix/nixos-module.nix` treats only literal `127.0.0.1`/`::1` as loopback, requires exactly one secret environment source for other binds, keeps secret auth and `llm_headers` values out of the Nix store, and brackets IPv6 in `ExecStart`; new-install default: `commands/init.rs` `root_bearer_for_new_config` mints a root bearer only on a fresh data dir (no config file and no database) and only when no bearer is supplied otherwise, via `local_auth.rs` `provision_root_token` (`auth-tokens/` `0700`, file `0600`, atomic, reused when present) and records the relative `[auth].bearer_token_file`; `config.rs` `apply_bearer_token_file` reads it once (`AI_MEMORY_AUTH_TOKEN` wins, inline + file together refused, a missing or blank file leaves no bearer) and `discover_root_token` lets a packaged install's client commands use `/auth-tokens/default` only with no config file at the probed path, a loopback server URL and no registered `default` profile; `serve.rs` `refuse_a_missing_token_file` stops a server whose configured file holds no token; `AuthSettings::renderable_bearer_token` keeps a file-sourced token out of every renderer: `install_mcp.rs` `route_file_token` (Claude Code gets the session-aware `ai-memory mcp-bridge` entry, Codex and Prime Agent name `AI_MEMORY_AUTH_TOKEN`, every other client a `stdio_bridge_entry` that runs `mcp-bridge --no-session-id` in its documented stdio shape, Pi keeps its extension, ZCode (HTTP-only) is refused) and `mcp_bridge.rs`, which authenticates with the config-resolved bearer and connects upstream before serving stdio, so a refused token ends it with `401 Unauthorized` instead of hanging; `uninstall.rs` `mcp_entry_is_ours` / `toml_mcp_entry_targets` recognise bridge entries by `install_mcp::bridge_entry_url`; `auth_enable.rs` `rewire_clients` re-registers every client whose entry targets this server, `install_hooks.rs` (persisted to the hook credential files, never inlined, not even on the F5 persist-failure fallback), `setup_agent.rs`, `run_autowire.rs`, `setup.rs` (runs `init`, adopts the minted token in memory through `adopt_token_file`, and calls the installers with no explicit token; the rendered user unit carries none; `user_service.rs` `is_rendered_by_setup` lets `uninstall` stop and delete only a unit carrying `setup`'s identity line whose SHA-256 still matches, never a hand-written or edited one); `local_auth.rs` `seed_hook_credential` gives hooks the token through `auth-token`/`auth-header` and keeps a different stored credential; `uninstall.rs` keeps the root bearer (`server_profiles::clear_tokens` `keep`); `commands/auth_enable.rs` is the idempotent opt-in | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401; `serve.rs` `machine_identity_remains_authenticated_with_web_disabled_and_expired_keys` (wrong and expired keys refused, valid machine key accepted, web remains disabled); `flake.nix` `nixos-module-eval` refuses `localhost` and `0.0.0.0` without a secret source, refuses two secret sources, `settings.auth` secrets, and `settings.llm_headers`, with literal IPv4/IPv6 loopback and one-source controls; `cli/tests/suite/auth_default_e2e.rs` against the shipped binary: `a_fresh_install_requires_the_token_and_its_own_clients_still_work` (after `init`, unauthenticated `/mcp`, `/hook`, `/api/v1/projects`, `/admin/status` → 401 and `/healthz` → 200; the same requests with the token succeed; the native hook delivers with no token in its environment, `status` authenticates through config, `mcp-bridge` lists tools; the token is in neither process's `/proc//cmdline`; after `install-hooks`, `install-mcp --apply` for every client it writes (21, by default config path under the test home), a refused ZCode install and `auth enable` twice, a scan of the whole home finds the token nowhere and a scan of the data dir finds it only in the token file and the two hook credential files), `an_existing_install_is_unchanged_until_auth_enable` (control: a pre-default install serves every route open and `status` names the fix; `auth enable` twice is idempotent, rewires Claude Code to the bridge, and after a restart the routes refuse and the hook still delivers), `a_packaged_install_with_an_external_config_wires_its_clients`, `uninstall_keeps_the_root_bearer_and_a_missing_one_stops_the_server`, `a_host_client_given_the_token_file_by_env_authenticates`; `init.rs` `a_fresh_init_mints_a_root_bearer_the_loader_reads`, `init_on_an_existing_install_does_not_turn_auth_on`, `init_with_a_supplied_bearer_mints_nothing`, `re_running_init_keeps_the_same_root_bearer`; `config.rs` `a_relative_bearer_token_file_resolves_against_the_data_dir`, `a_missing_or_blank_bearer_token_file_is_reported_not_guessed`, `the_env_token_wins_over_the_bearer_token_file`, `an_inline_bearer_and_a_bearer_token_file_together_are_refused`, `a_client_without_a_config_file_discovers_the_root_bearer`, `root_bearer_discovery_is_refused_outside_its_narrow_case` (existing config, remote URL, `default` profile, env token); `auth_default_e2e.rs` `a_session_less_bridge_serves_any_client_without_holding_the_token` (`tools/list` and a scoped `memory_query` through `mcp-bridge --no-session-id` against a fresh auth-on server, the token in neither the bridge's `/proc//cmdline` nor its `/proc//environ`; control: the session-aware mode still refuses to start without `CLAUDE_CODE_SESSION_ID`) and `a_bridge_without_the_token_is_refused_cleanly_not_hung` (exits promptly naming `401 Unauthorized`); `mcp_bridge.rs` `only_the_session_aware_mode_forwards_and_requires_a_session_id`, `a_session_less_upstream_config_carries_no_session_header`, `stdio_bridge_forwards_tools_and_session_header_in_both_http_modes` (control); `install_mcp.rs` `a_file_sourced_token_wires_every_client_without_entering_its_config` (every written client: no token, no header, an entry that reaches the server; ZCode refused and unwritten), `stdio_bridge_entries_follow_each_clients_documented_shape`, `inline_and_explicit_tokens_still_embed_as_before` (control); `uninstall.rs` `bridge_entries_are_recognised_by_their_upstream_url` (JSON, OpenCode array and TOML shapes; a bridge to another server and a foreign command are kept); `install_hooks.rs` `a_file_sourced_token_is_persisted_but_never_embedded`; `auth_enable.rs` `enable_twice_is_idempotent_and_leaks_the_token_into_no_harness_config` (every client pre-wired over HTTP is re-registered, ZCode reported as manual, the second run byte-identical), `enable_leaves_an_entry_for_another_server_alone`, `enable_refuses_while_a_default_profile_would_share_the_token`; `local_auth.rs` `the_root_bearer_is_owner_only`, `seeding_never_replaces_a_different_hook_credential`; `setup_e2e.rs` `setup_installs_wires_is_idempotent_and_uninstall_removes_its_unit` (one `setup --yes` in a fresh temp home: the token is in no file under the home but the token file and the two hook credential files, not in the user unit and not in `setup`'s output; the self-check's `memory_status` and a hook event authenticate through the file; `uninstall` then disables and deletes the unit once and a second `uninstall` calls nothing), `uninstall_leaves_a_unit_it_did_not_write` (adversarial: the packaged unit copied by hand is kept, no `systemctl` call); `user_service.rs` `a_foreign_or_edited_unit_is_left_alone`, `rendered_units_carry_an_identity_that_survives_only_unedited` | STRONG. Bite checks for the new-install default: with fresh-install minting disabled the fresh e2e routes answer without a token and the `init` tests fail; with `renderable_bearer_token` returning the file token, `install-mcp`, `install-hooks`, `auth enable` and the e2e scans find it in harness configs; without the `serve` refusal a server whose token file was deleted keeps running (the e2e times out on it); without the hook seeding the fresh e2e hook is refused; without `clear_tokens` `keep` uninstall deletes the root bearer; with `route_file_token` no longer choosing the stdio bridge the every-client and `auth enable` tests fail; with `auth enable` skipping the re-registration its test fails; with the session-less mode forwarding an inherited id the bridge unit test fails; without the 401 naming the refusal e2e fails; without bridge-entry recognition the uninstall test fails; with `setup` handing the token to `install-mcp` its scan finds it in `.claude.json`; with `is_rendered_by_setup` accepting any unit, the foreign-unit tests fail; with discovery removed the packaged e2e gets 401, and with each discovery guard removed the matching case of `root_bearer_discovery_is_refused_outside_its_narrow_case` fails. Residual: a hand-edited `servers.toml` that adds a `default` profile after the root bearer exists would read the root bearer as that profile's token (`server add` refuses it, `auth enable` refuses while it exists); ZCode takes only HTTP entries and is refused, so it needs a manual entry; the stdio entries for Zero, Muse Code and DeepSeek Harness follow their documented or source-level format but were not run against those clients | | 12b | Managed-workstream provenance privacy, stable replay identity and server event namespace | `ai-memory-core/src/workstream.rs` `scrub_workstream_provenance`; `ai-memory-hooks/src/workstream.rs` `finish_run` reserved client-id check and `sanitize_events` before immutable segments, with `sanitize_events` and `append_boundary_events` scrubbing event/checkpoint/losses content *before* the 64 KiB cap so a cap-straddling secret is never cut into an unmatchable prefix (#1113); `ai-memory-store/src/workstream.rs` `finish_run` stable identity check and `stored_event`, scrubbing content with the explicitly supplied configured sanitizer before its 16 KiB store-boundary bound; no changes to the typed observation boundary | `store/tests/suite/workstream_provenance.rs` `workstream_provenance_is_bounded_scrubbed_and_idempotent_before_storage` (hostile and oversized source ids, unknown metadata, legitimate correlation, retry and distinct event ids), `workstream_provenance_legacy_reads_strip_secrets_and_unknown_metadata` (search and tail, malformed/oversized legacy JSON), `workstream_provenance_reused_event_id_retains_data_and_refuses_identity_change` (first indexed version retained, identity refusal and batch rollback), `workstream_provenance_replay_survives_sanitizer_changes` (new extra patterns and removal of a built-in-pattern allowlist exemption, with identity-refusal control), `workstream_provenance_replay_preserves_legacy_rows_above_bounds`, `workstream_provenance_finished_run_ignores_late_events`, `workstream_provenance_retry_with_marker_matching_pattern_is_idempotent`, `workstream_content_is_scrubbed_before_the_store_cap` (a credential straddling the 16 KiB store cap is redacted, not persisted as an unmatchable prefix; benign control verbatim; #1113); `hooks` `workstream_provenance_finish_scrubs_before_raw_segment_and_search` (custom sanitizer, immutable segment, SQL recovery and finished-run no-op), `workstream_provenance_client_cannot_use_server_event_ids` (reserved ids refused before raw writes, normal client event and server-generated checkpoint/loss controls), `finish_run_scrubs_event_content_before_the_byte_cap` and `finish_run_scrubs_boundary_events_before_the_byte_cap` (a credential straddling the 64 KiB event cap is redacted in the raw segment and never reaches the index; checkpoint/losses and benign controls; #1113); `cli/tests/suite/workstream_provenance.rs` (text/JSON and old server DTOs) | STRONG: privacy, stable-identity and reserved-namespace regressions fail with their guards bypassed; an isolated allowlist bypass fails on the unknown key, and bypassing the raw-id search authorization returns 200 where its test requires 403. No provenance is emitted in startup packets, and old raw segments are not rewritten | | 12c | Browser cross-origin writes refused on an unauthenticated server (a foreign page in the operator's browser, DNS rebinding, and a LAN host where browsers send no fetch metadata) | `serve.rs` `refuse_cross_origin_browser_requests` / `is_cross_origin_browser_request`, layered on the machine (`/mcp`, `/hook*`, `/handoff`, `/workstream/*`, `/identity`) and admin (`/admin/*`) routes when auth is off: `Sec-Fetch-Site` other than `same-origin`/`none` → 403, or, without fetch metadata, an `Origin` whose authority is not the `Host` → 403; and `is_non_script_browser_request`: any `Sec-Fetch-Dest` other than `empty` (case-insensitive; non-UTF-8 refused) → 403 whatever `Sec-Fetch-Site` says, so links, images, navigations, iframes and form posts are refused even as `same-origin` or `none`. Browsers send fetch metadata over plain HTTP only to a potentially trustworthy host, so on any other `Host` (a LAN address or name in the allowlist, `0.0.0.0`, `host.docker.internal`; `is_loopback_host` admits only `localhost`, `*.localhost`, `127.0.0.0/8` and `[::1]`) `handoff_fetch_lacks_client_marker` also refuses `GET`/`HEAD /handoff` without a non-empty `X-AI-Memory-Client` header (`ai_memory_hooks::CLIENT_MARKER_HEADER`) → 403 whose body names both remedies (re-stage hooks, or set a bearer token). A page cannot add that header without a preflight these routes never answer. `/handoff` is the one state-changing `GET` on these routes; the other `GET`s are reads a foreign page cannot see the answer to, and every cross-origin `POST` carries `Origin`. Every first-party client sends the marker: the native hook (`hook_capture.rs` `post_hook`/`post_batch`/`get_handoff`), CLI subcommands (`http_client.rs` `ServerEndpoint::authenticate`), every curl in `hooks/_lib.sh`, both header tables in `hooks/lib/ai-memory-hook.ps1`, and the generated TypeScript integrations (`render_shared.rs` `ts_auth_headers_fn` plus the spool drain). Loopback hosts keep accepting a fetch without it, so hooks staged before the header existed still work on a default install, except that a request carrying any proxy forwarding header (`Forwarded`, `X-Forwarded-For`/`-Host`/`-Proto`/`-Server`/`-Port`, `X-Original-Forwarded-For`, `X-Real-IP`, `X-Client-IP`, `CF-Connecting-IP`, `True-Client-IP`, `Via`; `PROXY_HEADERS`) counts as non-loopback, since a plain-HTTP proxy can rewrite `Host` to loopback while the browser behind it sent no fetch metadata. No first-party client sends those headers. Hooks, the CLI and MCP clients send none of these headers; the web UI's same-origin `/admin/pending-writes` calls are `fetch()` (`Sec-Fetch-Dest: empty`) and pass. Behind it, axum `Json` and rmcp's `Content-Type: application/json` check refuse the preflight-free content types (`text/plain`, form-urlencoded, multipart) with 415; `require_allowed_host` (and rmcp `allowed_hosts` on `/mcp`) refuses a rebinding `Host`; machine/admin routes carry no CORS layer, so a preflight never comes back permissive. A stored wiki page viewed in the built-in web UI could otherwise turn the guard's same-origin allowance into a claim: `ai-memory-web/src/markdown.rs` `safe_image_url` / `is_safe_image_target` render as `#` any image target that is root-relative, a network path, carries a `..` segment (also percent-encoded), or, for a UI mounted at the base-path root, starts with a server route segment Every `/web` surface (built-in pages including `/web/pending`, the login page, a custom SPA and `/api/v1`) answers `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` (`ai-memory-web/src/mount.rs` `deny_framing`), because a framed page's own Approve/Reject `fetch()` is same-origin and the guard rightly lets it through | `cli/tests/suite/cross_origin_e2e.rs` `browser_cross_origin_requests_cannot_write_to_an_unauthenticated_loopback_server`, against the shipped `serve`: every write route (`/hook`, `/hook/batch`, MCP `memory_write_page`/`memory_delete_page`/`memory_message_send`/`memory_handoff_accept`, `/admin/write-page`/`delete-page`/`purge-project`/`messages/send`, `/workstream/runs`) × each simple content type × modern, Origin-only and fetch-metadata-only browser shapes → 4xx; body-less `GET /handoff` (which claims the single-use baton), `POST /admin/profile/rebuild` and `POST /admin/backup` → 403; a rebinding `Host` → 403; preflights answer no `Access-Control-Allow-*`; `GET /handoff` as a same-origin `document` or `image` and as a `Sec-Fetch-Site: none` `document` → 403 with the baton still open. Then a canary scan of the whole data dir, page/project/handoff survival, and legitimate controls (JSON MCP calls, a JSON hook → 202, the web UI's same-origin `/admin/pending-writes` list and approve reach their handlers, the anonymous active-project pointer is unmoved after the refused `/handoff` fetches while the closing hook-style fetch claims the baton and moves it). `serve.rs` `cross_origin_browser_request_detection` and `non_script_browser_request_detection` (header tables) and `cross_origin_browser_guard_applies_only_without_auth` (same-origin `image` and `none` `document` refused, same-origin `empty` passes, both pass with auth on); `ai-memory-web` `markdown.rs` `neutralises_images_that_reach_server_routes` (`/handoff?…`, `/admin/backup`, `//host/…`, `../` and `%2e%2e/` traversal, root-mounted route names refused) with `preserves_relative_images` as the legitimate control. `cross_origin_e2e.rs` `handoff_fetch_on_a_lan_host_requires_the_client_marker`, against a `serve` that allowlists a LAN name and address: `GET` and `HEAD /handoff` through either without the marker, and with an empty one → 403 carrying the remedy text, with the baton still open and the active-project pointer unmoved; controls: a marked fetch through the LAN name claims the baton, and a marker-less fetch through `127.0.0.1` (a hook staged before the marker) claims the next one; then a `127.0.0.1` fetch carrying each proxy forwarding header and no marker → 403 with the baton still open, and the same fetch with the marker claims it. `serve.rs` `loopback_host_detection`, `handoff_fetch_client_marker_decision` (incl. a loopback `Host` with each proxy header, refused without the marker and admitted with it, and a named row per header so none can drop out of the list) and `handoff_client_marker_guard_on_lan_hosts_and_under_a_base_path` (also under a `--base-path` nest; inactive with auth on). Front-door parity: `hook_capture.rs` `every_native_hook_request_carries_the_client_marker` (handoff GET, hook POST, batch POST), `http_client.rs` `authenticate_no_token_sets_only_the_client_marker`, the client-marker block of `tests/hooks/test_lib.sh` (every curl branch of the GET, the POST and the drain, plus a static check that every PowerShell header table carries it and every `Invoke-WebRequest` passes one), `render_shared.rs` `assert_every_ts_request_carries_client_marker` run for OpenCode, OpenCode 2, OMP, Pi and OpenClaw, `external_capture_ts.rs` (the rendered OpenCode plugin under Node sends no unmarked request) and `external_capture_powershell.rs` `every_request_carries_the_client_marker` (Windows leg); `ai-memory-web/src/mount.rs` `web_surfaces_refuse_to_be_framed` (`/web`, `/web/pending`, `/web/login`, `/api/v1/projects` all carry both headers; bites with `deny_framing` neutralized) | STRONG with listed residuals. Bite checks: with the marker check disabled, the LAN e2e marker-less `GET /handoff` returns 200 and the unit layer test fails; with the proxy-header rule disabled, the proxied loopback e2e fetch claims the baton and the unit decision test fails; dropping the marker from the native handoff GET, the shell token-drain curl, a PowerShell header table or the TypeScript spool drain fails the matching front-door test. With the guard removed, the cross-origin `GET /handoff` returns 200, consumes the baton and moves the active-project pointer (the pre-fix behaviour); with the guard treating `same-origin` as foreign, the web UI control gets 403; with the `Sec-Fetch-Dest` check removed, the same-origin `document` `GET /handoff` returns 200 and the unit guard test lets a same-origin image through; with the host layer removed, a rebound `/hook` returns 202; a permissive `CorsLayer` fails the preflight assertion; reverting `is_safe_image_target` to the pre-fix rule fails the markdown test on `/handoff?…`, dropping the percent-decoding fails it on `%2e%2e/`, and emptying the route list fails it on a root-mounted `handoff`. To reproduce the bite on the 415 layer, remove the guard and make `/hook` read a raw body instead of `Json`: a `text/plain` hook then returns 202 and the test fails. Residual, closed by setting a bearer token: a browser that sends no fetch metadata even to a loopback host (Safari before 16.4) sends no `Sec-Fetch-*` header and no `Origin` on an image `GET` or a link click, so such a request to a loopback host still reaches `/handoff` (loopback keeps accepting marker-less legacy hooks) and the other, read-only `GET` routes. On a non-loopback host the marker requirement closes `/handoff` for every browser. A plain-HTTP proxy that rewrites `Host` to its loopback upstream and adds no forwarding header (nginx's default `proxy_pass`) looks exactly like a local client, so in front of an unauthenticated server it must add `X-Forwarded-For` or keep `proxy_set_header Host $host`, or the server must sit behind TLS or use a bearer token (`security.md`, `https-via-proxy.md`). Browsers that send fetch metadata no longer have the earlier link residuals: a link opened from another app (`Sec-Fetch-Site: none`) and a link clicked in a stored page (`same-origin` navigation) both carry a non-`empty` destination and are refused. With auth on, the bearer token and the session CSRF token are the gate and this layer is a no-op | | 12d | The web UI and `/api/v1` cannot write, and render stored text inert | `crates/ai-memory-web/clippy.toml` `disallowed-methods`: every `Wiki` write and destructive method (`write_page`, `write_page_if`, `apply_batch`, the delete/evict/purge/restore/move/reindex/remove-dir helpers, `commit_all`, `approve_auto_improve_proposal`) and the `writer()` / `git()` accessors are lint errors in the crate, so the `Wiki` held by `WebState` stays read-only under `cargo clippy -D warnings`; only the integration-test module carries the `allow`, for fixtures. Templates are askama with HTML auto-escaping; the history diff (`templates/history.html`) and the session timeline (`templates/session.html`) print page lines, observation titles and bodies escaped, with no `|safe`, and no captured value is interpolated into a script | `ai-memory-web` `history_replay.rs` `session_timeline_renders_captured_markup_inert` (a `