A malformed access list is excluded, not treated as empty

Cortana

A local-first second brain whose search query validates each row's ACL shape in SQL before matching it, gates every read on a validity window and an active status, and starts query-only until each capability is separately authorised.

Carries 3 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

Cortana is "a local-first, agent-native second brain for people and the AI agents that work with them" — Apache-2.0, Rust, version 0.58.2, 138,872 lines with 605 test functions, reachable through a Tauri desktop app, an MCP stdio server, a loopback or explicitly secured HTTP API, and a local-owner CLI.

Its product statement is a list of refusals before it is a list of features:

"It is not an unrestricted crawler, implicit backup service, agent harness, or hosted personal-data warehouse. A new installation starts query-only. Source authorization, validation, ingestion, reconciliation, recurring synchronization, model use, shared-agent access, and memory writes are separate explicit decisions."

Eight capabilities, eight separate decisions, and a default of query-only. For a tool whose value proposition is ingesting a person's notes, messages, documents, calendars and code, starting with none of that enabled is the right default and an uncommon one.

The search query is where the care shows. One statement gates on the active status, on both ends of a validity window against a supplied moment, on project, kind, content type, retention tier and scope, on a flag that must be set before owner-global rows are reachable at all — and then, before it matches the row's access list, it checks that the access list is shaped like one:

AND json_valid(m.acl_json)
AND json_type(m.acl_json)='array'
AND NOT EXISTS (
  SELECT 1 FROM json_each(m.acl_json) AS memory_acl
  WHERE memory_acl.type<>'text'
)

Only a row that passes all three reaches the branch where an empty ACL means unrestricted or a principal match admits it. So a corrupted, wrongly-typed or half-written ACL matches nothing rather than falling through to public.

That is the failure direction that matters, and it is the one an application-layer check usually gets wrong: a lenient JSON parse turns a malformed ACL into an empty list, and an empty list means everyone. Putting the shape check in the same statement as the match means there is no window in which a row is loaded with an ACL nobody validated.

Three clocks, kept apart. observed_at is when the thing was observed; valid_from and valid_until bound the window it applies in; created_at and updated_at are when the row was written. The search gates the validity window in both directions against the moment it is given, so a memory whose validity has not begun, or has ended, is absent from the answer without being removed from the store.

A correction keeps the corrected row. supersedes_id links a new memory to the one it replaces, and the superseded row keeps its place while leaving the active set — recoverable rather than deleted, which is what makes the supersession link worth storing.

Candidates are staged, with reasons. An observation becomes a memory_candidate carrying a scope, a sensitivity, an ACL, a provenance document, an expiry and — if it is turned down — a rejection_reason, under a status transition guarded by a compare-and-set on pending. Promotion into memories takes an approving principal.

What that queue is not, yet, is a human-review gate: the approving principal is a string, and nothing found establishes it as a person rather than a label — the same gap this atlas noted in Edda's verdicts. And a rejected candidate's dedupe_key is not consulted when the same observation arrives again: the rejection is recorded, and re-offering the content produces a new candidate rather than meeting the old refusal.

The remaining caveats are ordinary. confidence and importance are continuous and feed the ranking, so a low-confidence memory is ranked down rather than withheld; provenance_json is carried and not gated on; and the surface is broad for a personal store — desktop, MCP, HTTP, CLI, OAuth connectors, fleet and community modules — with one auto-run surface and three unpinned dependency surfaces at this pin.

2. Mental Model

A capability is off until somebody turns it on, one at a time.

A memory is active, within its window, and on your access list — or it is not in the answer.

A malformed access list protects nothing, so it grants nothing.

A correction writes a new row and points back.

Diagram — one query gates status, both ends of the validity window, scope, the owner-global flag, and the ACL — validating the ACL's shape before matching it, so a malformed list fails closed
Diagram source
%% caption: one query gates status, both ends of the validity window, scope, the owner-global flag, and the ACL — validating the ACL's shape before matching it, so a malformed list fails closed
flowchart TB
    OBS["an observation from an authorised source"] --> CAND[("memory_candidates: scope · sensitivity ·<br/>acl_json · provenance_json · confidence ·<br/>importance · expires_at · rejection_reason ·<br/>created_by")]
    CAND --> DEC{"status transition,<br/>guarded on 'pending'"}
    DEC -->|"rejected"| REJ["status='rejected' + a reason<br/>— the dedupe_key is NOT consulted<br/>when the same content returns"]
    DEC -->|"approved by a principal"| MEM[("memories: status · scope · acl_json ·<br/>provenance_json · observed_at ·<br/>valid_from · valid_until ·<br/>supersedes_id · created_at")]
    CORR["a correction"] --> NEW["a new row carrying supersedes_id"]
    NEW --> MEM
    NEW -.->|"the superseded row keeps its place<br/>and leaves the active set"| MEM
    Q["search(query, moment, principals, flags)"] --> G1["m.status = 'active'"]
    G1 --> G2["m.valid_from <= moment AND<br/>(m.valid_until IS NULL OR valid_until > moment)"]
    G2 --> G3["project · kind · content_type ·<br/>retention_tier · scope filters"]
    G3 --> G4["?7 OR m.scope <> 'owner-global'<br/>— a flag the caller must set to reach<br/>owner-global rows at all"]
    G4 --> SHAPE{"is the row's ACL SHAPED like an ACL?<br/>json_valid · json_type='array' ·<br/>no element whose type is not text"}
    SHAPE -->|"no"| CLOSED["excluded — a malformed ACL matches<br/>nothing rather than degrading to<br/>'empty means unrestricted'"]
    SHAPE -->|"yes"| MATCH{"empty ACL, or a principal match<br/>against the caller's list (or '*')"}
    MATCH -->|"no"| CLOSED
    MATCH -->|"yes"| RANK["bm25 × importance × confidence × recency"]
    POSTURE["a new installation starts query-only;<br/>eight capabilities are eight separate<br/>explicit decisions"] -.-> OBS

3. Architecture

File Role
src/store.rs The schema, the gated search, the candidate lifecycle (11,171 lines)
src/api.rs, src/mcp.rs The HTTP and MCP surfaces
src/consolidation.rs, src/derived.rs Consolidation jobs and derived state
src/knowledge_graph.rs, src/context.rs The graph and context compilation
src/auth.rs, src/device_identity.rs Principals and device identity
apps/desktop/ The Tauri application

4. Essential Implementation Paths

src/store.rs:2076-2115 — one query, and everything it refuses to assume.

src/store.rs:366-412 — memories and their staging table, side by side.

src/store.rs:3273 — a rejection that records its reason under a compare-and-set.

5. Memory Data Model

Both tables carry acl_json and provenance_json from the candidate stage onward, so an observation's access list and its provenance exist before it is promoted rather than being attached afterwards. retention_tier and content_type sit beside kind, separating how long something is kept from what kind of thing it is — two questions frequently collapsed into one field.

6. Retrieval Mechanics

BM25 over the FTS index, ordered by score then importance, confidence and recency, behind the gates above. The ordering puts the lexical match first and uses the stored weights to break ties, which keeps a high-importance memory from dominating an unrelated query.

7. Write Mechanics

Staged, expiring candidates promoted by an approving principal, with corrections writing new rows. The expiry on a candidate is the detail worth noting: an observation nobody rules on does not sit in the queue indefinitely.

8. Agent Integration

Four surfaces, and shared-agent access as one of the eight decisions rather than a consequence of installing.

9. Reliability, Safety, and Trust

The ACL shape check is the strongest single thing here, and the query-only default is the posture that makes the rest coherent. The gap is the identity behind an approval.

10. Tests, Evals, and Benchmarks

605 test functions, with an eval/ directory beside the source. Nothing was built or run for this reading.

11. For Your Own Build

Validate the access list's shape where you match it. A lenient parse turns a malformed ACL into an empty one, and an empty one usually means everybody.

Gate both ends of a validity window. Checking only the end lets a memory that has not started applying answer a question about today.

Keep the superseded row. A supersedes_id and a status change cost one column each and make a correction inspectable.

Start query-only, and make each capability its own decision. Eight decisions is more friction than one; ingesting somebody's calendar because they installed a note-taker is worse.

And expire the candidates nobody ruled on. A review queue without an expiry becomes a backlog, and a backlog becomes a default.

12. Open Questions

Whether an approving principal is ever established as a person. The column and the compare-and-set are there; the identity check was not found.

Whether a rejection is ever consulted again. dedupe_key and rejection_reason are both stored and no write path reads the pair.

What provenance_json is used for. It is carried from candidate to memory and no read gates on it.

Appendix: File Index

Path What to read it for
src/store.rs:2076-2115 An ACL validated before it is trusted
src/store.rs:366-412 Three clocks, an ACL, and a staging table
src/store.rs:3273 A rejection with a reason, guarded

History

2026-09-166b76a1db… — first reading, at a commit dated 16 September 2026. Screened before opening, from a shallow clone: eighteen files scanned, one auto-run surface, one build-time execution point, three unpinned surfaces and ten dependency files inside the seven-day cooldown. Nothing was installed, built or run.