Skip to content

fix(messaging): one agent, two spellings, two inboxes (D14) - #295

Merged
kevintseng merged 5 commits into
mainfrom
fix/message-scope-identity
Sep 4, 2026
Merged

fix(messaging): one agent, two spellings, two inboxes (D14)#295
kevintseng merged 5 commits into
mainfrom
fix/message-scope-identity

Conversation

@kevintseng

Copy link
Copy Markdown
Contributor

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) vs root (25): SAME agent. Repaired.

  • Every row of both spellings 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. One caller's convention changed; it is not
    two agents.
  • Both appear across the same four projects, interleaved on the same days, and
    every receipt on both has actor == recipient — 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.

Pair B — session_X vs claude-code:session_X: NOT the same. Deliberately
left apart.

The brief's hypothesis ("a prefixing convention that changed") is falsified:
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 in this product, so there is nothing to normalise
against. Corroborating: they were used with different target_kind (which is
part 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:reviewer with a different agent named
reviewer.

Pair C — memesh (38) vs memesh-llm-memory (28): SAME project, and the
repair still does not merge it.

gh api repos/PCIRCLE-AI/memesh-llm-memory answers PCIRCLE-AI/memesh
GitHub's own redirect proves one repository, renamed. Layer 1 of
getProjectName yields memesh (this checkout's remote.origin.url), layer 2
yields the repo-root basename memesh-llm-memory; both are outputs of one
function 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 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
— 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): provably
unreachable.
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/reviewer is still accepted), and the one list of columns that hold a
routing identity.

Not in paths.ts, and that is the point: every MessageSchema variant
requires project and executeAgentMessageAction passes it straight
through. Messaging never derives a project from getProjectName — which is
also why pair C is unfixable there.

The scope set is project, recipient, actorthe 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 previously 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 exactly why the pair-C split survives. It now 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.

Repair, on a copy of the snapshot

rewrote 96 filesystem-path message scope value(s)
  (/Users/ktseng/Developer/Projects/memesh-llm-memory → memesh-llm-memory, /root → root);
96 joined an identity that already existed; 2 duplicate poll cursor(s) dropped.

Differential proof, repaired copy ATTACHed to a pristine one:

check result
deliveries whose (project,recipient) changed the /root → root and abs-path rewrites only — and nothing else
deliveries vanished / appeared 0
messages with changed sender, payload, or created_at 0
deliveries with changed target_kind or message_id 0
receipts changed only the same triples
pair C untouched memesh 38 → 38
pair B untouched 14 → 14, 17 → 17
cursors 65 → 63; for each dropped token the canonical row already existed

Those two cursors are the one user-visible consequence: an agent still holding
them gets an explicit scope error on its next poll --cursor rather than a
silent restart. They are dropped because the unique index is on
(project, recipient, event_sequence) and the path-spelled token is unreachable
poll now refuses the spelling it is bound to. Deliberate asymmetry: the
unattended repair drops such a cursor; the owner-driven rename-project leaves
a colliding row in place and counts it.

Verification, at 2d2293ee

node scripts/run-tests-isolated.mjs   -> exit=0
  Test Files  229 passed | 1 skipped (230)
  Tests  3297 passed | 11 skipped (3308)
npm run verify:release                -> exit=0   (all nine steps)
invariant vs unrepaired snapshot      -> exit=1   (names /root ×20 and the abs-path project across 4 tables)
invariant vs repaired copy            -> exit=0

Break-tests — mutate, run, restore, byte-identical restore asserted:

Mutation Result
isFilesystemPathScopeId stops refusing absolute paths exit=1 → restored exit=0
canonicalAgentScopeId strips a claude-code: prefix exit=1 → restored exit=0

The 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 /root now gets an explicit error naming the
field 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

  • Pair C ships unrepaired by design. The largest split stays; an owner
    closes it with rename-project, which now moves the messages as well as the
    tags.
  • rename-project does not move agent_principals.project, so a renamed
    project needs its host config reissued. Documented, not fixed.
  • The router and presence tables are outside the repair and the invariant
    (agent_principals, agent_session_instances, agent_session_connections,
    agent_presence_facts, agent_dispatch_attempts). Their project comes
    from an owner-written host config; all five hold only memesh-llm-memory on
    the real graph, so there is nothing to repair, and the entry point is gated
    instead. Validation is a deliberate superset of repair.
  • Pair A's verdict is strong circumstantial evidence — same host, paired
    sender names, overlapping projects, self-acknowledging receipts — not a
    recorded identity claim. No code path ever emitted either string.
  • NFC canonicalisation is forward-looking only: no non-NFC or untrimmed
    scope value exists in the measured graph.

Two gate side-effects, both with a reason

scripts/mcp-doc-contract.json was recertified — the 11 tool names and
one-line descriptions are unchanged; the source contract changed only in three
inputSchema field descriptions of memesh_message. One
scripts/audit/baseline.json entry (C1 tests/core/project-tags.test.ts) was
pruned because the new tests added toHaveLength size pins, which is exactly
the anti-vacuity pin C1 looks for.

@kevintseng
kevintseng force-pushed the fix/message-scope-identity branch 4 times, most recently from b27389a to b451cbe Compare September 4, 2026 08:59
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
kevintseng force-pushed the fix/message-scope-identity branch from b451cbe to 4eb4fba Compare September 4, 2026 10:58
@kevintseng
kevintseng merged commit 976e17f into main Sep 4, 2026
28 of 35 checks passed
@kevintseng
kevintseng deleted the fix/message-scope-identity branch September 5, 2026 13:06
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.

1 participant