fix(messaging): one agent, two spellings, two inboxes (D14) - #295
Merged
Conversation
kevintseng
force-pushed
the
fix/message-scope-identity
branch
4 times, most recently
from
September 4, 2026 08:59
b27389a to
b451cbe
Compare
Measured on the real graph: `/root` (20 deliveries) and `root` (25) are the same agent. Every row of both carries `sender_host = 'codex-mcp-client'`; the `sender` field flipped in the same transition under exactly the `/root/` prefix (`/root/full-board-scan-luna` <-> `full_board_candidate_scan_luna`); both spellings appear across the same four projects, interleaved on the same days; and every receipt on both has `actor == recipient`, so the same agent received and acknowledged under each. `/root` is an absolute path, and `getProjectName` cannot return one at any of its three layers — it is a caller spelling its own home directory where its name belonged. Its messages went to a second inbox nobody read. `src/core/agent-scope-id.ts` now owns the canonical form (NFC + trim), the absolute-path refusal (POSIX, Windows drive, UNC — `team/reviewer` is still accepted), and the one list of columns that hold a routing identity. Not in `paths.ts`: every `MessageSchema` variant REQUIRES `project` and `executeAgentMessageAction` passes it through, so messaging never derives a project from `getProjectName` at all. The scope set is `project`, `recipient`, `actor` — the same set the boundary refuses, the repair rewrites and the invariant watches. `sender` is excluded everywhere: it is provenance, it keys no inbox, and it keys `agent_message_idempotency (project, sender, idempotency_key)`. Gated at the boundary (`MessageSchema` and `BriefingSchema`), again in core before SQL, and at `memesh agent setup --project/--principal` — the fourth producer of a routing identity, which wrote the host config unvalidated. `briefing` counts the same inbox key and now asks in canonical form; without that an NFD recipient would be told `0 unread` while `poll` returned messages. `memesh kg rename-project` now moves the message scopes too. A project identity is half an inbox key as well as an entity tag, and renaming only the tags is why a split survives; it counts and moves the `project` column of the durable message tables in the same transaction, still dry-run by default, still backing up before `--apply`, and no longer stops at "no entities carry project:<x>" when only messages do. Two hypotheses from the brief were tested and REJECTED, and the code does not implement either: - `claude-code:session_X` vs `session_X` is NOT a prefixing convention that changed. `grep -rn 'claude-code:'` over src, dist, hooks, skills, docs and tests returns nothing, and `git log -S 'claude-code:' --all` returns no commits — the convention never existed here. They were also used with different `target_kind` (part of the fetch key), and neither carries a single receipt, so the split-inbox symptom is not even demonstrable for the pair. Stripping the prefix would merge a legitimate `claude-code:reviewer` with a different agent named `reviewer`. - `memesh` vs `memesh-llm-memory` IS one project (GitHub's own redirect proves the repository was renamed), and the repair still does not merge it. The evidence is a network call against one owner's account, while a `runOnceMigration` fires unattended from whatever directory the user's first post-upgrade open happens to be in — resolving it from `getProjectName(process.cwd())` would be mechanical and wrong, differing per user by accident. The owner-driven path is made to work instead: `memesh kg rename-project --from memesh-llm-memory --to memesh --apply`. Known limits, not fixed: `rename-project` does not move `agent_principals.project`, so a renamed project needs its host config reissued. The router and presence tables are outside the repair and the invariant — their `project` comes from an owner-written host config, all five hold only one value on the real graph, and the entry point is gated instead, so validation is deliberately a superset of repair. [Verified-By: node scripts/run-tests-isolated.mjs > /tmp/f14.log 2>&1; echo exit=$? -> exit=0; "Test Files 229 passed | 1 skipped (230)", "Tests 3297 passed | 11 skipped (3308)", no Errors line] [Verified-By: npm run verify:release > /tmp/v14.log 2>&1; echo exit=$? -> exit=0, ending "consumer install has no high-or-worse advisories"] [Verified-By: repair on a COPY of the snapshot, differential against a pristine copy via ATTACH — deliveries whose (project,recipient) changed: only the /root->root and abs-path->basename rewrites, 20 rows, nothing else; deliveries vanished/appeared 0; messages with changed sender/payload/created_at 0; receipts changed only the same triples; pair C untouched (memesh 38->38); pair B untouched (14->14, 17->17); cursors 65->63, and for each dropped token the canonical row already existed] [Verified-By: break-tests, mutate -> run -> restore, byte-identical restore asserted by reading the file back: isFilesystemPathScopeId stops refusing absolute paths -> exit=1, restored 0 canonicalAgentScopeId strips a `claude-code:` prefix -> exit=1, restored 0 the second is the dangerous direction — a canonicaliser that merges two genuinely different agents — not the easy one]
`leaves \`claude-code:session_X\` and \`session_X\` apart` reads as though it covers the whole property. It does not: it seeds through raw SQL, so it never calls `canonicalAgentScopeId` and cannot see a write-path canonicaliser that starts stripping the prefix. Measured — applying that exact mutation leaves this file green and turns `tests/core/agent-messaging.test.ts` red. The coverage is real, it just lives in two files. Saying so in the comment stops a future reader from deleting the other half as a duplicate. [Verified-By: mutation `canonicalAgentScopeId` -> `.replace(/^claude-code:/,'')`, then node scripts/run-tests-isolated.mjs on each file separately: tests/core/agent-messaging.test.ts -> exit=1, tests/storage/graph-repairs.test.ts -> exit=0; file restored byte-identical, confirmed with diff] [Verified-By: node scripts/run-tests-isolated.mjs tests/storage/graph-repairs.test.ts > /tmp/gr295.log 2>&1; echo exit=$? -> exit=0, "Tests 20 passed (20)"]
…name-project --to gated too The SQL that mirrors isFilesystemPathScopeId's drive-letter check never required a letter before the colon, so a value like "1:/agent" passed the write path's own rejection but matched the invariant's path-shaped pattern anyway -- and the repair reuses the same JS check, so it can never rewrite that value away. The invariant would go red forever on any such recipient. kg rename-project's --to was the one scope-id producer this fix didn't gate: it names the destination every entity and durable-message row gets rewritten into, the same identity send already refuses as a path-shaped recipient. Worse than the path case (which the invariant catches loudly), an NFD-spelled --to silently produces a scope neither its NFC nor its NFD spelling can ever poll -- the exact split-inbox failure this repair exists to close. --from is deliberately left unvalidated and uncanonicalized: it must match an existing, possibly-broken row's exact byte spelling, which may itself be the value being repaired away.
kevintseng
force-pushed
the
fix/message-scope-identity
branch
from
September 4, 2026 10:58
b451cbe to
4eb4fba
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found dogfooding 4.8.3 against the real graph: the same agent was receiving
messages under two different spellings of its own name, into two inboxes, and
only one of them was ever read.
The verdict per pair — three of the four brief hypotheses were wrong
Pair A —
/root(20 deliveries) vsroot(25): SAME agent. Repaired.sender_host = 'codex-mcp-client'.senderfield flipped in the same transition, under exactly the/root/prefix:/root/full-board-scan-luna↔full_board_candidate_scan_luna. One caller's convention changed; it is nottwo agents.
every receipt on both has
actor == recipient— the same agent received andacknowledged under each.
/rootis an absolute path, andgetProjectNamecannot return one at any ofits three layers. It is a caller spelling its own home directory where its
name belonged.
Pair B —
session_Xvsclaude-code:session_X: NOT the same. Deliberatelyleft apart.
The brief's hypothesis ("a prefixing convention that changed") is falsified:
grep -rn 'claude-code:'over src, dist, hooks, skills, docs and tests returnsnothing, and
git log -S 'claude-code:' --allreturns no commits. Theconvention never existed in this product, so there is nothing to normalise
against. Corroborating: they were used with different
target_kind(which ispart of the fetch key), and neither carries a single receipt, so the
split-inbox symptom is not even demonstrable here. Stripping the prefix would
merge a legitimate
claude-code:reviewerwith a different agent namedreviewer.Pair C —
memesh(38) vsmemesh-llm-memory(28): SAME project, and therepair still does not merge it.
gh api repos/PCIRCLE-AI/memesh-llm-memoryanswersPCIRCLE-AI/memesh—GitHub's own redirect proves one repository, renamed. Layer 1 of
getProjectNameyieldsmemesh(this checkout'sremote.origin.url), layer 2yields the repo-root basename
memesh-llm-memory; both are outputs of onefunction for one working directory.
It is left alone on purpose. The evidence that proves it is a network call
against one owner's GitHub account, while a
runOnceMigrationfires unattendedfrom whatever directory the user's first post-upgrade open happens to be in.
Resolving it from
getProjectName(process.cwd())would be mechanical andwrong — the result would differ per user by accident. The owner-driven path
is made to work instead (below).
Pair D —
/Users/ktseng/Developer/Projects/memesh-llm-memory(1): provablyunreachable. Repaired to its last segment.
What changed
src/core/agent-scope-id.ts(new) owns three things: the canonical form (NFC +trim), the absolute-path refusal (POSIX / Windows drive / UNC only —
team/revieweris still accepted), and the one list of columns that hold arouting identity.
Not in
paths.ts, and that is the point: everyMessageSchemavariantrequires
projectandexecuteAgentMessageActionpasses it straightthrough. Messaging never derives a project from
getProjectName— which isalso why pair C is unfixable there.
The scope set is
project,recipient,actor— the same set theboundary refuses, the repair rewrites, and the invariant watches.
senderisexcluded everywhere: it is provenance, it keys no inbox, and it keys
agent_message_idempotency (project, sender, idempotency_key).Gated at the boundary (
MessageSchemaandBriefingSchema), again in corebefore SQL, and at
memesh agent setup --project/--principal— the fourthproducer of a routing identity, which previously wrote the host config
unvalidated.
briefingcounts the same inbox key and now asks in canonicalform; without that an NFD recipient would be told
0 unreadwhilepollreturned messages.
memesh kg rename-projectnow moves the message scopes tooA project identity is half an inbox key as well as an entity tag, and renaming
only the tags is exactly why the pair-C split survives. It now counts and moves
the
projectcolumn of the durable message tables in the same transaction,still dry-run by default, still backing up before
--apply, and no longerstops at "no entities carry
project:<x>" when only messages do.Repair, on a copy of the snapshot
Differential proof, repaired copy
ATTACHed to a pristine one:/root → rootand abs-path rewrites only — and nothing elsememesh38 → 38Those two cursors are the one user-visible consequence: an agent still holding
them gets an explicit scope error on its next
poll --cursorrather than asilent restart. They are dropped because the unique index is on
(project, recipient, event_sequence) and the path-spelled token is unreachable
—
pollnow refuses the spelling it is bound to. Deliberate asymmetry: theunattended repair drops such a cursor; the owner-driven
rename-projectleavesa colliding row in place and counts it.
Verification, at
2d2293eeBreak-tests — mutate, run, restore, byte-identical restore asserted:
isFilesystemPathScopeIdstops refusing absolute pathscanonicalAgentScopeIdstrips aclaude-code:prefixThe second is the dangerous direction — a canonicaliser that merges two
genuinely different agents — not the easy one.
Behaviour change worth knowing
An agent that has been addressing
/rootnow gets an explicit error naming thefield and a valid value, instead of a silently separate inbox. That is the
intent, but it is a change: callers spelling a path where a name belongs will
see a failure they did not see before.
What is not proven, stated plainly
closes it with
rename-project, which now moves the messages as well as thetags.
rename-projectdoes not moveagent_principals.project, so a renamedproject needs its host config reissued. Documented, not fixed.
(
agent_principals,agent_session_instances,agent_session_connections,agent_presence_facts,agent_dispatch_attempts). Theirprojectcomesfrom an owner-written host config; all five hold only
memesh-llm-memoryonthe real graph, so there is nothing to repair, and the entry point is gated
instead. Validation is a deliberate superset of repair.
sender names, overlapping projects, self-acknowledging receipts — not a
recorded identity claim. No code path ever emitted either string.
scope value exists in the measured graph.
Two gate side-effects, both with a reason
scripts/mcp-doc-contract.jsonwas recertified — the 11 tool names andone-line descriptions are unchanged; the source contract changed only in three
inputSchemafield descriptions ofmemesh_message. Onescripts/audit/baseline.jsonentry (C1 tests/core/project-tags.test.ts) waspruned because the new tests added
toHaveLengthsize pins, which is exactlythe anti-vacuity pin C1 looks for.