Skip to content

coord: notes, agents' shared findings anchored to files (ADR 0098) - #283

Merged
BHAWESHBHASKAR merged 321 commits into
mainfrom
ccr-94e2aa7d-5xfs9k
Nov 10, 2026
Merged

BHAWESHBHASKAR merged 321 commits into
mainfrom
ccr-94e2aa7d-5xfs9k

Conversation

@BHAWESHBHASKAR

Copy link
Copy Markdown
Collaborator

Summary

This is the first PR of coordination Phase 2. Agents and people can now leave notes: a finding, a gotcha, a decision or a how-to, anchored to a path or a changeset, for the next agent working there. Each note carries:

  • a trust tier taken from the token, with a new kind: agent|human field in the tokens file;
  • caps on size, count and rate, plus expiry;
  • an attributed report flow with an operator undo.

All agent-written text stays inside the untrusted fence.

PR 2, still to come, adds staleness, endorsement by human owners and a notes section in the context pack. The maintainer decided that only human owners may endorse a note, that notes go into backups, and that the session-head GC root is deferred.

Why

Agents keep rediscovering the same facts ("this test needs the cache warmed", "don't touch this file without telling billing"). Notes let them hand those facts to the next agent on the same files. Notes are also the largest new surface for prompt injection, so this PR is mostly limits and fencing.

Changes

Store and migration 0010_notes (memory, SQLite and Postgres; contract-tested)

  • One notes table holding:
    • the kind and the anchor (a path or a changeset);
    • at_changeset and at_blob, and an optional line range;
    • the title and body;
    • the principal, agent_model and session_id;
    • trust, endorsed_by and endorsed_at (PR 2 writes these);
    • state, superseded_by, reported_by and reported_at;
    • created and expires.
  • Indexes on the anchor and the principal, a partial index on open notes per principal, and one on reports.
  • A new note can supersede one of the author's own open notes in the same transaction. Superseding someone else's note answers 403; superseding a note that is no longer open answers 409.

API (additive)

  • POST /v1/notes, which returns a NoteEntry. Everything an agent wrote (title, body, anchor and agent_model) is under untrusted.
  • GET /v1/notes, filtered by path (which matches the path and the directories above it), changeset, principal, mine or state, with paging.
  • GET /v1/notes/{id}; DELETE /v1/notes/{id}, which the author uses to retract.
  • POST /v1/notes/{id}/report.

Limits

  • Size: a title is one line of at most 200 bytes; a body at most 4,096 bytes; a path anchor at most 256 bytes. A listing path over 256 bytes is refused.

  • Characters refused at write: C0 and C1 controls (the body may contain only \n), U+2028/2029, bidi controls and marks, zero-width characters, U+FEFF, U+2060–206F and Unicode tag characters.

  • Per principal:

    • 200 open notes (400 too_many_notes);
    • 30 an hour and 100 a day (429 rate_limited with Retry-After).

    On Postgres the caps are exact under a per-principal advisory lock with a 2 s lock timeout (503, Leep-Refused: note-lock). On SQLite they are exact through BEGIN IMMEDIATE.

  • Expiry: an agent note expires after 90 days and a human note after 365. A closed note is deleted 30 days after it closes. The existing per-minute sweep does this, bounded by HOOK_TIMEOUT.

Trust: human when the writing token has kind: human, otherwise agent. Nothing in a request can set trust. Upgrade every server before a tokens file uses kind, because the file rejects unknown fields. The upgrade order is in OPERATIONS.

Reports

  • Any writer may report a note, which hides an agent note. The report is attributed (reported_by, reported_at) and logged by note id and reporter, never the note text.
  • A reporter can hide at most 30 notes an hour (429).
  • An operator can undo reports with leep-server notes unreport --id N | --reporter P, and remove a principal's notes with notes revoke.

Clients

  • CLI leep note add|list|show|retract|report; agent text and non-plain token names are escaped.
  • MCP leep_note_add and leep_notes (with paging), which brings the tool count to 26. Outside the fence MCP shows only the id, kind, principal, trust, state and supersession.

Backups: notes are included, unlike sessions. Restoring resets the id sequence. A restored note's session_id is informational and may point to a session the restore does not have.

Docs: ADR 0098, ARCHITECTURE §12, FORMATS, CLI.md, MCP.md, DEPLOY.md, OPERATIONS (metrics, migration 10, the order for adopting kind, revoke and unreport, backups, worst-case storage of about 15 MiB per principal) and changelog.d/coord-notes.md.

Also: the wall-clock test a_hanging_coordination_store_changes_no_answer (it asserted under 300 ms against a 250 ms bound and flaked) is split in two:

  • one test checks the answers and ref logs, inside a 10 s liveness timeout;
  • one checks the bound exactly on a paused tokio clock.

Testing

  • Lint and checks: fmt check, clippy -D warnings, check-version.sh and changelog-assemble.sh --check all pass on this head.
  • Full suite: before the merge with main, cargo test --workspace against Postgres 16 passed 2,023 tests with 0 failures. A full run on the merged head is in progress locally, and CI runs it too.
  • New tests:
    • the notes store contract on all three stores, including 16 concurrent supersedes with one winner, every cap, and a bounded sweep;
    • upgrade_from_v9_adds_notes;
    • backup round trips (SQLite, Postgres→SQLite→Postgres, restoring a v9 snapshot);
    • router tests for 403s and every cap, plus a Postgres test that holds the lock from another connection;
    • injection fixtures on the server, MCP and CLI, including a refused agent_model. They strip untrusted and check that no planted string remains, and that refused strings are never echoed;
    • the land-equivalence property test now writes, supersedes, reports, retracts and sweeps notes between lands;
    • CLI end-to-end tests on SQLite and Postgres.
  • No wall-clock asserts.

Fable reviewed the change and returned "ship after must-fixes". The three must-fixes are in:

  • the unbounded listing path;
  • agent_model moved under untrusted;
  • reports attributed, capped and undoable.

So are all the should-fixes and nits.

Compatibility

  • API: additive only; there are new routes and types.
  • Tokens file: kind is optional and absent means agent. A file without it fingerprints the same as before.
  • Migration 10 is additive. A v9 server refuses to start on a v10 database, as with every migration, and the rollback SQL is in OPERATIONS.
  • New refusal reason: Leep-Refused: note-lock.
  • Branch history: this branch carries a merge of main (the pre-flight check from mcp: stop a land for overlaps the agent has not seen (pre-flight) #282) and a no-op merge of its earlier, already squashed history, so the diff holds only this change.

Generated by Claude Code

claude added 30 commits November 2, 2026 02:42
Co-Authored-By: Claude <noreply@anthropic.com>
…head

A cold blame read the same main-chain skiplists one row at a time on
every request: they were cached per walk only. Skiplists read by queries
now go to one process-wide SkipReadCache on the Repo (32 MiB of the
derive cache budget, moved from the filtered log's own skiplist cache,
so the total is unchanged), used by FirstParents::list, plan_stretches
and filtered logs.

A blame answered by the newest changer's cached blame is now stored at
the requested head too, with the encoding a walk stores, so the next
blame there is one lookup. Stretches are planned only once the head walk
has not finished the blame.

On a 4,100-changeset linear history with files of 6 to 25 versions,
metadata calls per blame in a warm process fell from 119 to 95 for a
file's first blame, from 78 to 9 at a head 40 lands later, and from 68
to 2 for a repeated blame there.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
PERFORMANCE.md gains the 2,000-agent swarm runs that found the index-lag
collapse and each fix's effect (ADR 0073, ADR 0074), the agent sweep on
one 2-vCPU server (250, 500 and 1,000 agents pass every criterion; 2,000
is CPU-bound), the blame profile and the skiplist cache's effect, the
warm-blame result, sizing per vCPU, and what is not yet validated: more
than one server, lease handoff under load, a ref beyond about 220 lands
a second, and long histories.

OPERATIONS.md says to restart running servers after a repair rewrites
skiplist rows, since they keep decoded skiplists in memory once read.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The cherry-pick onto main auto-merged them after the self-naming test
that #237 added, inside `mod tests`. The batched read in plan_stretches
also decodes with `decode_of`, so a cached list passes the same
self-naming check as every other read path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
Every read of a tree node deep-copied the cached node: a file lookup copied
each directory on its path to take one entry, and listings, diffs, creates
and the land batch's staged-node memo copied every directory they opened.
At 1,000 agents on one 2-core server, copying and freeing nodes was about a
third of a file read's CPU and a quarter of a land batch's, growing with
directory width. Root trees were read from the metadata store on every
listing and twice per diff, about 12% of all statements, although the
value is fixed by the changeset.

The decoded-node cache, the land memo, lazy-tree derivations and the read
paths now hand out Arc<TreeNode>; lookups borrow the entry they need, and
building a tree copies a node only where it changes it (Arc::make_mut).
read_tree and read_trees keep their types as copies of the new
read_tree_shared and read_trees_shared. Repo::root_tree keeps each root it
reads or a create computes in the ancestry cache, which already held
prefetched roots; verification and repair, which substitute recomputed
rows, read the row every time.

In a 1,000-file directory on memory stores, a warm lookup fell from 49 us
to 0.7 us, a one-file diff from 395 us to 194 us and a listing from 398 us
to 317 us. No persisted format or hash changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
A blame walk found the first-parent ancestor at each changer's
generation by descending skiplists from the nearest known point, one
single-key read per halving of the distance, for every change. Where the
chain has no merge at or above that generation (the step's last_merge-v1
row, read with its index entry), every ancestor that high is on the
chain, so the changer is that ancestor and the walk goes straight to it,
reading its skiplist with its generation. Merge regions keep the
descent.

The blame walker's gathered reads of several derived kinds now go out as
one get_derived_kinds call (one Postgres statement per round instead of
one per kind).

On a 200,000-changeset linear history (memory stores), cold-blame
statements fell from 191 to 77 for a file of 18 versions, 728 to 372 for
75 versions and 9,406 to 3,764 for 14,743 versions. Adds a counting test
on deep chains and an ignored deep_blame_calls measurement.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
A tree that a changeset was created without (ADR 0069) is stored later,
by the land that fast-forwards a ref to it or by the changeset sweep,
nodes first and root last. When that happens after the backup's metadata
snapshot (which already holds the changeset) and while sync_blobs is
listing the source, the listing can pass a node's shard before the node
is written and then reach the root's shard after the root is written.
The pool then holds a root without its whole tree: the closure check
fails with missing_tree (seen in soak_smoke), and a restore taken with
--accept-failed-check breaks "a present root means a whole tree".

sync_blobs now reads, exactly, each directory a copied tree node names
that the destination lacks, and copies it, recursively. Children are
written before their parents, so the source holds them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
At 1M changesets the server held 2.7 GB resident with 1.19 GB in use over
32 arenas. Cap them as the importer does, unless MALLOC_ARENA_MAX is set.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
On a cold restart at 1M changesets the 321 MB pack index load outlasted
the ready timeout, so /v1/ready timed out. Warm the blob store in the
background when the routes are built (new BlobStore::warm) and answer 503
blobs/loading, without probing, until it ends. Documents both this and the
malloc arena limit in OPERATIONS and DEPLOY.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
The changeset sweep (ADR 0059) deletes the original of a rebased land
once its copy's scan window and grace have passed, and keeps the
reachable copy's rebased-origin-v1 row on purpose ("Kept"): it is 64
bytes, and signatures, reviews and land-id answers only compare or look
up the id. verify still required the original to exist, so every soak
that swept such an original failed with bad_rebased_origin.

verify now checks what still holds after a sweep: the copy exists and
its leep.rebased_from (hashed into its id) names the row's original. A
missing original is legal; an unreadable one is still damage. That also
catches a row naming a stored but wrong original, which the old check
missed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
On one 4-vCPU box with the same cores for each: Git agents pushing to one
bare repository (rebase and retry on rejection) against leep-land-bench on
main at #229 and at #246, at 16, 64 and 256 agents, plus the 1,000-agent
mixed swarm before and after.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Uploads to a filesystem store each fsynced their own file before the
rename that publishes it; at swarm load that was a sync per upload on a
CPU-bound server. Puts that arrive while others are in flight now join a
batch that waits up to --blob-fsync-window (default 2ms, 0 for the old
path), is made durable by one syncfs (Linux 5.8+; an fsync per file
elsewhere) and only then renamed into place. A lone put syncs at once.
A blob is still under its final name only once its bytes are durable,
and a put still returns only after that; directory syncs stay deferred
to flush (ADR 0044, 0048). Shared syncs run one at a time and count
failures so a writeback error another batch's syncfs took still fails
the put it belonged to. ADR 0076.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
derive_range found a missing range one frontier at a time: on a linear
chain, the warmer's usual gap since lands rebase, that was a row read and
a changeset read per changeset, all serial and all before anything
derived. A frontier of at most 8 is now followed a first-parent chain at
a time: the chain is listed down the skiplists a level per batched read,
bounded by the first row (or changeset already found) on it, then read in
batches, each changeset kept only while it is the first parent of the one
above. Wider frontiers and side branches walk as before, and the range,
its order and the rows written are unchanged (ADR 0077).

On local Postgres the history index catching up a 4,000-changeset gap
made 138 metadata reads instead of 8,083 and derived 5,000 changesets a
second instead of 1,550.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The maintainer asked for completed milestones to be marked closed. M1 to
M3 have every item done and no further exit criteria. M4's exit criterion
(both indexes derive lazily on a 100k-changeset repository) holds on all
of Linux, 1,484,762 commits, recorded in PERFORMANCE.md. M5 and M6 stay
open: M5 has an unchecked item and a two-week exit window, M6 needs
design partners.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Add leep-history-gen, a resumable bulk writer of a linear monorepo-shaped
history that goes straight through Repo, and use it to put 1,000,000
changesets under the 1,000-agent swarm on the 4-vCPU box. PERFORMANCE.md
gains "One million changesets of history": store growth, index warm-up,
cold single operations, the swarm deep against shallow and a same-tree
control, and a land-only comparison that locates the land tail in replay.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A cold blame of a file changed thousands of times (about 61 s on a
1M-changeset history) never finished: it wrote its answer only at the
end, the request timeout dropped it at 25 s, and every retry started
again from nothing.

The replay now writes the blame of each 256th complete version as an
ordinary blame-v2 row, which a later walk stops at. A blame runs on its
own task, joined by concurrent requests for the same blame, and caches
its answer even after its request has timed out. A detached blame keeps
its read-class permit (ADR 0074), and their number is bounded. No format
change: only more keys of the existing kind. ADR 0078.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
On a deep history each rebased copy built its skiplist by reading about
log2(depth) rows one after another, inside the land leader's replay; the
high levels are a different row for every copy, so no cache helped. A
land batch now reads the rows its copies' skiplists are built from a
level at a time for the whole batch (one get_derived_many per level), on
a task started as soon as the attempt knows its head; a copy waits for it
only to build its list. A create also computes its generations and
skiplist beside its blob checks and tree write instead of after them.
Stored rows are byte for byte unchanged (ADR 0079).

150,000 changesets deep, leep-land-bench --files 2: 143-186 -> 249
lands/s at 16 agents, 204-215 -> 297-327 at 64, replay 2.8 -> 0.53-0.56
ms per land, as fast as on the same tree 1,000 deep.

Test bookkeeping of a batch's lands now records each land as it is
taken, before any answer, so a test holding its answers finds the batch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
7648dc2 let store_changeset read a create's generations and skiplist
beside its blob checks and tree write; on a cold process a client create
then made two metadata calls at once, against ADR 0067's rule. They run
after the tree write again; a land copy's checks are answered in memory,
so the overlap gained it nothing. A new test fails if a create has two
metadata calls in flight (it saw two on 7648dc2).

The skiplist plan now starts after the batch attempt's own reads (fence
flag, recorded lands, prefetch, origins), so it does not compete with
them for the land pool's connections, and at most two plans run in a
process; a batch past that plans nothing and its copies read as before.

New metrics to confirm the swarm's cause: leep_land_batch_seconds,
leep_land_skip_plans_total{outcome}, leep_land_skip_plan_seconds and
leep_land_skip_plan_wait_seconds. ADR 0079, ARCHITECTURE, OPERATIONS,
PERFORMANCE and the changelog are updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A session revived by a commit after its land kept its task key and
target ref, so an inferred session reused from review/a to review/b
kept walking review/a, and a stale task key could call a later land a
duplicate's. A revive now clears both, and while a session is inferred
the ref each commit names (`Leep-Target-Ref`) replaces the last one; a
declared target still stays. ADR 0095 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A session that named no target ref was walked against a fixed `main`,
and its `landed` notice still said "landed on your target ref". The
server gains `--default-ref` (`LEEP_DEFAULT_REF`, default `main`; it had
no such setting), and such a notice carries the new optional
`Notice.landed_on`, so the CLI and MCP tools say "landed on main".
ADR 0095 and 0096 amended.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
FORMATS says a client shows a `via` it does not know as it is, but the
CLI and MCP tools showed only `related` and dropped any other. The CLI
now prints it quoted and escaped, the MCP tools as a plain word or `?`.
ADR 0097 also notes that an older client sees only the first file of a
grouped owners or history item.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Claim 7 now needs a pack line between rex and sol (sol starts first and
lands after rex has started, so it never depends on scheduling) and a
positive "a file related to your task" line in fay's pack, for the
maintainer's live work on docs/auth.md (now written with token.rs),
listed after cy. Claim 9 needs exactly one `carry` explanation in dee's
pack (asked again when shed), and claim 2 needs cy's warning to come from
its own `work start` pack. In e2e, a dead `warned` check goes and the
undeclared agent's `landed` notice is awaited by bounded retries.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The env-example test checks every flag's variable is documented there;
`--default-ref` was missing. Also takes a test's slice by reference, as
clippy asks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The coordination demo on this branch reached main as 74c86f0, the same
tree as e195f97; keeping it as a parent lets the branch move forward
without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Notice::stops_land is the one rule of the MCP pre-flight check (ADR 0096
amendment): possible duplicates, a duplicate's land, a land on a changed
file, a claim on or above a changed file, and another session naming a
changed file itself stop; near, a scope directory and related files do
not. So that the rule can read a notice's paths, the server now names the
file two sessions share rather than a directory holding it, and a landed
notice names a file of mine the land changed before a directory of mine.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
leep_commit_and_land and leep_resolve_and_land read the notices their
create got before landing, and stop, unlanded, on one Notice::stops_land
says to read first and the agent has not been shown. The answer names the
changeset made and the notices (fenced) and says how to go on; force:
true lands anyway, and a retry reuses the stopped changeset instead of
making another. A clean call is still one create and one land.
leep_task_start asks once more for a context pack shed with a short
Retry-After. The demo shows dee stopped and landing with force; ADR 0096
is amended, and MCP.md, ARCHITECTURE.md and the demo notes say how it works.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Notes are shared memory between agents, anchored to a path or a
changeset: the largest new prompt-injection surface, so this first part
is the store, the API and the limits.

- core: Note, NewNote, NoteFilter, NoteChange and seven CoordStore
  methods (add_note, get_note, list_notes, notes_at, change_note,
  revoke_notes, sweep_notes), checked by check_notes_contract on the
  memory store, SQLite and Postgres. Note text is plain: no C0/C1
  controls (newlines in a body), line separators or bidi controls.
- meta: migration 0010 (notes); writes take a per-principal advisory
  lock on Postgres so the open (200) and hourly (30) caps are exact,
  and 16 concurrent supersessions of one note make one winner. Notes
  are backed up and restored, unlike sessions.
- derive: Repo::add_note resolves a path anchor at the session's target
  ref (else the default ref) and records its blob; listings, retract,
  report (hides an agent note at once), revoke and a bounded sweep.
  The land-equivalence property test writes notes between lands.
- server: POST/GET/DELETE /v1/notes, POST /v1/notes/{id}/report; 429
  past the hourly cap, 400 too_many_notes past the open cap. The
  tokens file gains an optional kind (agent|human); a human token's
  notes are trusted as a person's. gen-token --kind, whoami.kind, and
  `leep-server notes revoke --principal P`.

New table and /v1 routes; additive. See ADR 0098.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The CLI side of notes (ADR 0098). A note's title, body and anchor are
printed with {:?}, so another agent's newline, escape sequence, line
separator or bidi control never reaches the terminal as text of its
own; the header carries only what the server vouches for. A
--changeset REV anchor is resolved client-side, as short ids are
everywhere else, and --agent-model defaults to LEEP_AGENT_MODEL.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The MCP side of notes (ADR 0098), 26 tools. Outside the fence the text
says only what the server vouches for (id, kind, principal, trust,
state, endorsement, supersession); every anchor, title, body and model
goes in one JSON row per note inside the ADR 0095 fence, under the
fixed line. Endorsed notes are fenced too. Tool descriptions are fixed
text and quote no note. A unit test runs the ADR 0097 injection
fixtures through titles, bodies and anchors, and an end-to-end test
checks a real server's answers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Writes a note on a file and one on a changeset with the real binaries,
lists by path, shows, supersedes and retracts, and checks that the CLI
prints another agent's newline quoted rather than as a line of its own.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
ADR 0098 records the notes core: the table, the store contract, trust
fixed from the tokens file's kind, the rule that only a human owner may
endorse (the endorsement route comes in the second change), the caps,
expiry, supersede, report and revoke, the injection rules and why notes
are backed up when sessions are not. ARCHITECTURE section 12, FORMATS
(schema, API, error code, tokens file), CLI.md, MCP.md, DEPLOY.md and
OPERATIONS.md (metrics, migration 10, upgrade order for kind, revoke,
backups) follow, with the changelog fragment coord-notes.md.

Also satisfies clippy in the v9 migration fixture.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Said once was keyed by the other session and level, so a session first
heard of through my scope directory (or a claim on another file, or a
land on another file) never stopped my later commit of a file it does
touch. Each notice now records the files it stops a land of, and is said
again when it comes to stop one it did not; every notice that stops comes
in one answer, past the cap of three. Notice::stops_land reads the other
side's path, never which of mine it was paired with, and the server
prefers, per session, the row that stops a land of the request's files
(or the session's, on a notice read); a landed notice names the
request's file the land changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A stop is no longer an error result, and a plain retry of a stopped
changeset (the same call, or the changeset by id) is stopped again with
its notices and any new ones, never landed: only force lands it. Landing
it by id without a ref lands it on the ref the stopped call named, with
its create_ref and request, and the guidance names both. A stopped
changeset is forgotten only once it lands, so a failed forced land keeps
its request's land id. Resolution stops say to send new resolutions. The
shed-pack test no longer sleeps. MCP.md, ADR 0096, ARCHITECTURE.md and the
demo notes describe the rule and its limits.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
`leep note add --at` and leep_note_add's `at` now take a short id, as
--changeset and every other revision argument do; the server resolves
only refs and full ids. Also notes in ADR 0098 that a report is open to
any write token and leaves the note readable for audit, and that
`anonymous` clients share one principal's caps.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
a_hanging_coordination_store_changes_no_answer asserted that a create
took under 300 ms of wall clock against a 250 ms bound, and failed
under load (306 ms in a full workspace run). Split it: the integration
test keeps the equivalence half (answers and ref logs equal with a
hanging store and with none), timed only by a 10 s liveness timeout,
and a unit test drives after_create against a hanging store under
start_paused and requires the elapsed tokio time to equal
CREATE_HOOK_BOUND exactly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
GET /v1/notes?path= took a repository path of any length, and the
stores bound the path and every directory above it, one value each: a
64 KiB a/a/a/... path made about 32,000 binds. A listing's path is now
refused past MAX_PATH_BYTES (256, so at most 128 ancestors), in
Repo::notes and in every store through NoteFilter::check, and
notes_at refuses more than 512 anchors or one past 256 bytes. The
contract checks both on memory, SQLite and Postgres.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The model a note records comes from the request (or the writer's
session), so it is the writer's word, yet NoteEntry carried it beside
trust and principal, checked only as one line of 128 bytes: bidi
controls and line separators passed, and MCP structured output and
`leep --json` showed it with what the server vouches for. It moves to
NoteText (NoteEntry is new in this change, so nothing older reads the
old place), the MCP rows and CLI already fence or quote it, and it is
held to the note text rule. A session's model that would fail that
rule is not copied onto the note. The server injection test now sets
the model in requests and requires it gone once untrusted is stripped.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A report hid an agent's note at once, but nothing recorded who reported
it, nothing limited how many, and nothing could undo it: a hostile
token could silence every agent note unseen. Now:

- migration 0010 (unshipped, edited in place) gains reported_by and
  reported_at, with a partial index on them;
- NoteChange::Report carries its reporter and cap; the store counts the
  reporter's hides in the last hour in the same transaction as the hide
  (Postgres under the per-principal lock) and refuses past 30, which the
  server answers 429 rate_limited with Retry-After;
- every report is logged at INFO with note id, reporter and effect (no
  note text, no principal metric label); NoteEntry gains reported_by;
- unreport_notes and `leep-server notes unreport --id N | --reporter P`
  open reported notes again with an agent note's life;
- closing a note (supersede, retract, report, revoke) never lengthens
  its life: an expired note reported before the sweep stays due.

Backups carry the new columns. Contract, derive and router tests cover
attribution, the cap, the undo and the expiry.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Note text refused controls, separators and the bidi embeddings and
overrides, but let through what people cannot see and models still
read: zero-width spaces and joiners, the LRM/RLM/ALM marks, word
joiners and invisible operators, the BOM, interlinear annotation
controls and the tag characters U+E0000 to U+E007F, in which a whole
instruction can hide. They are refused in titles, bodies, anchors and
models alike; the server injection test now sends them as refused
fixtures instead of allowing U+200F.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The demo friction fixes on this branch reached main as 582a33e, the
same tree as ab82a0d; keeping it as a parent lets the branch move
forward without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The hourly cap alone let a principal write 720 notes a day, each kept
30 days after it closed: about 100 MiB a principal. A daily cap of 100
(429 like the hourly one, metric reason "daily") bounds what one
principal keeps to about 3,200 rows, some 15 MiB in the worst case,
now stated in ADR 0098 and OPERATIONS. Migration 0010 (unshipped,
edited in place) gains notes_open, a partial index on (repo_id,
principal) WHERE state = 'open', so the open cap counts only open rows
instead of every note a principal kept.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A note write waited on its principal's advisory lock with no limit
while holding a pool connection, so one stuck writer could pile up
connections of the same principal. The lock is now taken with SET
LOCAL lock_timeout = 2s; past it the write (or report) is refused
before writing anything, as 503 with Leep-Refused: note-lock, which is
safe to retry. A Postgres test holds the lock from another connection
and requires a refusal, not a hang, while other principals write.

The note sweep shared the heartbeat loop with no timeout; it is now
bounded by HOOK_TIMEOUT (counted as reason "timeout"), tested on a
paused clock. ADR 0098 also says that at_changeset may be unlanded and
is no GC root, so staleness must tolerate it missing, and that a note's
session_id is informational.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Review nits on notes:
- leep note printed the principal and endorser raw; a tokens file may
  hold any name, so they (and the new reporter) are shown as they are
  only when made of letters, digits and `_ - . @`, else quoted and
  escaped.
- DELETE /v1/notes/{id} read the note in the handler, again in
  Repo::retract_note and a third time after the update; it now reads it
  once and answers what the conditional update wrote (a note already
  closed is read again, as it is).
- leep_notes takes `before` and says when more notes follow, so an
  agent can page past the first 100.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD

# Conflicts:
#	crates/leep-mcp/tests/e2e.rs
The pre-flight check on this branch reached main as bfc6656, the same
tree as f3166a7; keeping it as a parent lets the branch move forward
without rewriting what was pushed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD

Copy link
Copy Markdown
Collaborator Author

The first perf run on f5a7452 failed one wall-time check: swarm land p99 was 2.57x (B 255/436 ms vs A 146/123 ms). Every count and work-per-land check passed; statements, bytes and batch size were flat or lower.

Re-running the job once on the same head passed every check. A local A/B against main also passed all 23 checks (0 failed, 1 inconclusive), though that box was loaded and one of its A runs was noisy.

The change adds nothing to the land path. Its only new periodic work is a notes sweep bounded by HOOK_TIMEOUT. I'm treating the first run as runner noise in a wall-time ratio and merging on the green re-run.


Generated by Claude Code

@BHAWESHBHASKAR
BHAWESHBHASKAR merged commit 96b1500 into main Nov 10, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants