Skip to content

coord: note staleness, endorsement by human owners, and notes in context packs (ADR 0098) - #284

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

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

Conversation

@BHAWESHBHASKAR

Copy link
Copy Markdown
Collaborator

Summary

This is the second notes PR of coordination Phase 2, following #283. Three changes:

  • Staleness: a note is judged stale once the file it was written against has changed.
  • Endorsement: a person who owns a note's path can endorse it, so it ranks first and is shown in full.
  • Context pack: the pack gains a notes section, so an agent's first call carries what earlier agents and people recorded about its files.

Why

A note is only useful if the next agent sees it at the moment it starts, and can tell an owner-vouched fact from an agent's guess. Staleness keeps outdated notes from misleading agents. Endorsement by human owners makes vouching a deliberate act by a person, and the maintainer decided agents cannot do it.

Changes

One view for listings and packs: Repo::notes_for (leep-derive/src/notes_view.rs) serves both GET /v1/notes and the context pack, so the two always give the same staleness and endorsement answer.

Staleness

  • A note is stale when a tree lookup at the head finds a blob different from the note's at_blob. A changeset note is never stale. A directory note is stale once the directory is gone.
  • If the at_changeset is missing or a tree can't be read, the answer is "unknown", never an error. An unknown note is never served as endorsed.
  • Bounds:
    • 32 checks per pack and 100 per listing page;
    • 8 checks at once;
    • a deadline of half the section bound.
  • Unfinished work is reported as omitted {notes, bound|timeout}.
  • GET /v1/notes and GET /v1/notes/{id} take ?ref=; a ref that doesn't exist is 404.
  • Write and retract answers carry no stale.

Endorsement: POST /v1/notes/{id}/endorse, or leep note endorse ID in the CLI. There is deliberately no MCP tool, and a test asserts that none offers it.

  • Who may endorse:

    • a token with kind: human that may review;
    • named in OWNERS at the default ref's head, so a branch cannot grant ownership; for a directory note, the directory's own OWNERS;
    • not the note's writer.

    Anyone else gets 403.

  • Refusals:

    • a closed, expired, stale or unknown note is 409;
    • a changeset note is 400.
  • Lifetime: endorsing sets expires to endorsed_at + 365 days. Endorsing again renews it. An endorsement lapses at read time once the note goes stale.

  • Operator undo: leep-server notes unendorse --id N | --by P gives each note back the life it had before it was endorsed. OPERATIONS has a runbook for a person's token that was removed, re-kinded, leaked or dropped from OWNERS.

Reports: a human owner's report now hides a human or endorsed note; anyone else's report is still only counted. unreport restores the right life.

Context pack

  • A seventh section, notes, takes 10% of the budget (files go from 25 to 20%, history from 20 to 15%).
  • Spare budget goes first to ref_health, so notes never displace it.
  • Ranking: endorsed, then human, then agent; then fresh, unknown, stale; then nearest anchor; then newest.
  • Stale agent notes are dropped from the pack.
  • Endorsed notes and fresh human notes carry their body, cut to 1 KiB; every other note carries its title, cut to 80 bytes.
  • All note text is under untrusted. MCP fences it and the CLI escapes it.
  • The store probe returns endorsed, then human, then agent notes before its 256-row cap, so 300 newer agent notes on a busy path cannot push an older endorsed note out.

Demo: claim 11: an agent's note, endorsed by a human owner, reaches a later agent's leep_task_start pack.

Docs: ADR 0098 §12 and an amendment to ADR 0097; FORMATS, ARCHITECTURE §12, CLI.md, MCP.md and OPERATIONS (metrics and the unendorse runbook); changelog.d/coord-notes-endorse.md.

Testing

  • Lint and checks: fmt check, clippy -D warnings, check-version.sh and changelog-assemble.sh --check pass.
  • Full suite: cargo test --workspace against Postgres 16: 2,057 passed, 0 failed. The demo passes on SQLite and Postgres.
  • New tests:
    • Pack behaviour:
      • four identical pack asks are byte-identical with notes present;
      • notes never displace ref health, at three budgets.
    • Staleness:
      • a changed blob, a deleted directory, another ref, and a file restored to its old content;
      • a missing at_changeset or an unreadable head gives unknown, not an error.
    • Endorsement:
      • refused for a non-owner, an agent-kind owner, the author, a token that can't review, and a human read-only token;
      • refused for a closed, stale or changeset note, and for read-only or no-tokens access;
      • a subdirectory owner is refused on a parent-directory note;
      • endorse racing retract and supersede, 16 rounds;
      • an endorsed note lives a year, then is swept.
    • Bounds:
      • the 32/100/256 bounds, each reporting bound;
      • a passed deadline, on a paused clock.
    • Crowding: contract test on memory, SQLite and Postgres with 300 newer agent notes plus older endorsed and human notes.
    • Injection fixtures: newline, ANSI, OSC, bidi, tag characters, U+2028, backtick fences and "ignore previous instructions" in titles, bodies and anchors, written through a raw store; they never appear outside untrusted.
  • No wall-clock asserts: the pack tests run on a paused clock.

Fable reviewed the change: "ship after must-fixes". The must-fix is included: endorsed notes were evicted by crowding before ranking. All should-fixes and nits are included too:

  • the unendorse command;
  • yearly renewable expiry;
  • staleness no longer reported on write answers;
  • a partial marker;
  • the missing tests.

Compatibility


Generated by Claude Code

claude added 30 commits November 2, 2026 11:18
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
The first land's answer arrives before its batch task ends, and the
leader keeps that batch as the one ahead until the task returns. A land
arriving in that window replayed speculatively behind the finished
commit and was recorded as speculated, so the test saw an extra
([a], true) entry (seen once on CI). Wait for the leader to go idle, as
the other held-store tests have since 67f789d.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
A server restarted while background merges waited for their fence
searched and merged the replaced packs again, since "replaced; delete
after the fence" lived only in process memory (the Waiting queue and
PackSet::supersede). Each restart copied the merged objects once more;
one store grew from 1.5 GB to 9.3 GB.

A repack now appends the packs it replaced to packs/retired (a bucket's
packs/.retired, then packs/.gen) after the replacing index is published.
Views read the record before tombstones and the directory, and its stamp
joins the ADR 0070 stamps, so no process searches or plans a retired
pack again. retire_sweep deletes retired packs past the fence, temp
files and index-less packs under the repack lock (lease); it runs at
server start, on each background tick and at leep-server repack start.
A repack's temp files are removed on its error paths. ADR 0080.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Pack ids are content hashes, so a merge can reproduce a pack that is
retired and still inside its fence (A+B into N retires A; GC deletes
B's objects; a full repack writes A again and retires N). Left retired,
the republished pack would hide the only copy of its objects and a
sweep would delete it. The repack that publishes a pack now takes its
id out of the retired record in the same write that retires the
sources (a rewrite by rename on a filesystem), and out of its process's
superseded set.

The bucket record is rewritten with a conditional PUT on the etag it
read, retried after a lease check on a conflict, so a writer that lost
the lease cannot drop another holder's lines; an emptied record stays,
empty. Docs: restoring a raw copy drops packs/retired; ADR 0080 notes
clock skew and the sweep's write.lock at each tick.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
Performance regressions were caught only by manual benchmarks on a noisy
box. #253's first version gained 60% land-only and regressed the
1,000-agent swarm badly through changes of resource shape: a create
making two metadata calls at once, and a skiplist plan asking the
four-connection land pool for five or six. Those are counts, visible in
process on any machine; wall time on shared runners is not. See ADR 0081.

Operation budgets: leep_testkit::budget::Budget wraps both stores and
records calls, keys and bytes per method and derived kind, and the most
calls in flight overall, per lane and per actor. budget_tests.rs checks
create, eight creates at once, a land batch of k, log 100, a cold blame,
a search and uploads against exact lines (the flows run on one thread, so
counts are deterministic), failing over budget and more than 20% under.
Reapplying either half of #253's first version fails them (create 2 in
flight; land lane 5 in flight against a budget of 4). deep_blame_tests
and the create concurrency test move onto Budget.

Ratio bench: a perf job on pull requests builds the merge base's server
and this change's, and scripts/perf-ratio.sh runs them A B A B under a
short leep-land-bench and a 200-agent leep-swarm (about 2.7 minutes of
runs). leep-perf-compare gates hard on counts (lands not admitted, 409s,
stalled agents, CAS losses, discarded attempts, shed reads, pool peaks)
and on statements and bytes per land, and only on collapses of wall time;
a check whose two A runs disagree is inconclusive. New counter
leep_meta_statements_total{op, pool} in the Postgres and SQLite stores;
leep-land-bench gains --json, leep-swarm the new fields.

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
…o packs

One function, Repo::notes_for (leep-derive/src/notes_view.rs), answers
GET /v1/notes and the context pack's new notes section, so they never
disagree. Staleness is a bounded tree lookup at the ref's head against
the note's at_blob; a missing at_changeset or unreadable tree is unknown,
never an error. POST /v1/notes/{id}/endorse and `leep note endorse` let
only a human owner (kind: human, may review, named by OWNERS, not the
writer) endorse an open, fresh path note; an endorsement lapses while the
note is stale. An owner's report now hides human and endorsed notes.
Packs rank notes endorsed > human > agent, fresh before stale, nearest
anchor, newest; stale agent notes are dropped, agent notes come as
titles only, and every word stays under untrusted (fenced in MCP, quoted
in the CLI). SECTIONS goes to 7 with a 10% notes quota taken from files
and history.

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

notes_at ordered by id alone, so 256 newer agent notes on a hot path
pushed every older endorsed or human note out of every pack. All three
stores now answer endorsed, then human, then agent notes, each newest
first, before the cap; the contract checks it with 300 agent notes and an
older endorsed one. An endorsement now sets expires a year on (renewed by
endorsing again) instead of clearing it, so a note left stale for good is
not kept for good, and CoordStore::unendorse_notes gives operators an
undo by note or by endorser.

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

A pack now lists notes as omitted with reason bound (the probe hit its
cap, or notes went unchecked past the 32-check budget) or timeout (checks
ran out of time) beside the notes it has, and a store whose schema
predates notes is off, not error. Checks never start past their deadline,
closed notes are never judged, and endorse bounds its tree read and
answers 409 when staleness is unknown. Write and retract answers carry no
stale (a note is fresh at its `at`); GET /v1/notes/{id} takes ref= as
the listing does. Adds `leep-server notes unendorse --by P | --id N`.
Tests: the 32/100/256 bounds, a passed deadline on a paused clock, a
subdirectory owner refused on a parent note, endorse racing retract and
supersede, a person's read-only token, and the pack tests on a paused
clock.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpET1BMBSd4aNPHPVkQEbD
The notes core on this branch reached main as 96b1500, the same tree
as f5a7452; 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
@BHAWESHBHASKAR
BHAWESHBHASKAR merged commit 5714845 into main Nov 10, 2026
5 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