Skip to content

fix(mention): canonicalize @mention labels server-side#65

Merged
furtherref merged 4 commits into
mainfrom
fix/mention-canonicalize-backend
May 19, 2026
Merged

fix(mention): canonicalize @mention labels server-side#65
furtherref merged 4 commits into
mainfrom
fix/mention-canonicalize-backend

Conversation

@furtherref
Copy link
Copy Markdown
Owner

@furtherref furtherref commented May 19, 2026

Summary

  • Rewrite every mention's visible label to match the resolved entity's current name on the write path (CreateComment, UpdateComment, and the agent task-completion comment path).
  • Defends against the "shows @A, triggers B" class of bug: routing reads only the UUID, so a label/UUID mismatch silently misroutes. Reproduced previously in the squad coordination flow.
  • Layered defense — this is the backend hard-stop. A complementary prompt-level warning to squad leaders already landed in fix(squad): warn leader against label/UUID mismatch in delegations #64.

Behaviour by entity type

  • Agent / squad — resolve the UUID inside the current workspace; replace the label with the current name. If the UUID doesn't resolve (deleted, foreign workspace, never existed), keep the original mention markdown unchanged so ParseMentions still sees it. This preserves the existing top-level "mention-only-others suppresses trigger" semantics — turning unresolvable agent mentions into plain text would break that invariant.
  • Member (human) — resolve via GetMemberByUserAndWorkspace. GetUser is workspace-unscoped and would leak the display name of any user in the system, so it is deliberately not used. If the user isn't a member of this workspace, the mention is stripped to plain text.
  • On every replacement where the original label differed from the canonical label, emit a WARN log with the original label, resolved name, and UUID — a searchable signal whenever the upstream prompt/UI produced a mismatch.

Other care taken

  • Issue-identifier auto-linking (MUL-123 → markdown link) previously re-scanned inside existing mention labels. An agent named "MUL-117 reviewer" would have its label rewritten into nested markdown. findSkipRegions is split into a code-fence-only helper (shared with canonicalization) and expandSkipRegions (also covers link spans, used by issue expansion).
  • Canonical labels are markdown-escaped before splice — a name containing ] or \\ can't escape the surrounding link.

Test plan

  • go test ./internal/mention/... ./internal/handler/... ./internal/util/...
  • go build ./...
  • Manual: post a comment whose mention markdown has a stale label; confirm the persisted comment renders the current name and the warn log fires.
  • Manual: cross-workspace UUID in mention markdown — confirm it does not surface the foreign workspace's member name.

🤖 Generated with Claude Code

The mention markdown `[@Label](mention://type/UUID)` has two parts: the
visible label and the routable UUID. Mention routing reads only the UUID,
but the rendered comment shows only the label, so a label that disagrees
with its UUID silently misroutes — the UI shows "@A" while agent B is
triggered and replies. This already happened in the squad coordination
flow (a leader's delegation paired one roster row's label with another
row's UUID).

Defend against it at write time, in the place every comment passes
through: on `CreateComment` / `UpdateComment` and the agent task-
completion comment path, rewrite each mention's label to the current
name of the entity its UUID resolves to.

- Agent / squad: if the UUID resolves in the workspace, replace the
  label with the entity's current name. If it doesn't resolve, keep the
  mention markdown as-is — `ParseMentions` will still find no live
  trigger target, and the existing top-level "mention only others"
  trigger-suppression keeps working.
- Member: resolve via `GetMemberByUserAndWorkspace`, not `GetUser` —
  the latter is workspace-unscoped and would leak the name of any
  user in the system. If the user is not a member of this workspace,
  strip the mention entirely.
- When canonicalization changes a label, log a warning with the
  original label, resolved entity name, and UUID. This gives a
  searchable signal for the prompt/UI bug that produced the mismatch
  in the first place.

Care taken in the regex layer:
- Issue-identifier expansion (`MUL-123` → markdown link) previously
  re-scanned inside mention labels, so an agent named "MUL-117 reviewer"
  would have its label rewritten into nested markdown. Split skip-region
  detection into `findSkipRegions` (code fences / inline code, shared
  with canonicalization) and `expandSkipRegions` (also covers existing
  link spans, used by issue expansion).
- Canonical labels are markdown-escaped before being spliced back in,
  so a name containing `]` or `\` can't break the surrounding link.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multica-web Ready Ready Preview, Comment May 19, 2026 8:05am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
multica-docs Ignored Ignored Preview May 19, 2026 8:05am

scanMentionAt's "continue on ] not followed by (" branch existed to
support labels with balanced inner brackets like [@david[TF]](...),
but the same branch fired when the ] belonged to a plain bracketed
phrase (e.g. [this]) with no inner [. The scanner kept walking past
whitespace and the next [ until it anchored on an unrelated real
mention's ](mention://...), producing a match whose span covered the
unrelated text in between.

In CanonicalizeMentions that span was replaced wholesale, silently
deleting user content: "check [this] out [@bot](mention://...) please"
was persisted as "check [@bot](mention://...) please".

Track unescaped [ depth opened after the leading [ at start. A ] at
depth > 0 is treated as closing an inner [ (the [@david[TF]] case
still parses). A ] at depth 0 not followed by ( now bails out
instead of continuing the scan, so the outer FindMentionMatches loop
advances past the plain bracketed text and finds the real mention
intact.

Regression tests cover both ParseMentions (positions invisible there,
so the existing test is a no-op safety pin) and CanonicalizeMentions
(where the data-loss actually shows up).
Tighten the backend label round-trip surface so the canonicalize defense
holds against malformed input and pathological names:

- scanMentionAt: keep strict depth-tracking with anchor only at depth 0.
  An earlier attempt to anchor on `](mention://` at depth > 0 (to support
  unescaped unpaired brackets in labels) created a new data-loss path for
  bracketed prefixes like `note [draft [@bob](mention://...)` — the outer
  `[draft ` text would get swallowed into the match. Producer-side escape
  is the right place to handle bracket-bearing names.
- parseMentionAt: reject empty labels after stripping the optional `@`.
  Without this, `[](mention://all/all)` or `[@](mention://all/all)` route
  as @ALL — an invisible markdown link could silently broadcast to the
  whole workspace.
- Add util.EscapeMentionLabel / util.UnescapeMentionLabel as the single
  source of truth for the escape contract. `\` is escaped FIRST so a name
  like `foo\[bar` emits `foo\\\[bar` (not `foo\\[bar`, which the scanner
  would consume as `\\` + raw `[`). Inverse unescapes brackets BEFORE
  collapsing `\\` so a legitimate `\[` pair is not split.
- squad_briefing.formatMention now routes the name through
  EscapeMentionLabel before splicing into markdown.
- mention.CanonicalizeMentions drops its local escapeMentionLabel and uses
  the shared util helper.
- handler.logMentionLabelMismatch uses UnescapeMentionLabel for the
  equality check so a legitimately bracket- or backslash-bearing canonical
  name doesn't false-positive the warning.

Test coverage: empty-label and bare-@ rejection (parse + canonicalize);
non-@ bracketed prefix before mention preserved (no data loss);
unescaped unpaired bracket inside label passes through deterministically
(no character deletion); escape↔unescape inverse contract; full producer→
parser round-trip for plain / bracket / backslash / mixed names.
Mirror the backend's util.EscapeMentionLabel / UnescapeMentionLabel
contract in the two frontend consumers so labels round-trip through the
editor and trigger-summary path without losing or gaining backslashes:

- mention-extension renderMarkdown (safeLabel): escape `\` FIRST, then
  `[` and `]`. Order matters — without leading `\` escape, a name like
  `foo\[bar` emits `foo\\[bar`, which the scanner consumes as `\\` + raw
  `[` and fails to round-trip.
- mention-extension tokenize: unescape `\[`, `\]`, then `\\` (reverse of
  the producer). Also mirror util.parseMentionAt's empty-label guard —
  `[@](mention://all/all)` backtracks into the label group (label = `@`),
  and without rejection here the editor would tokenize it, then
  renderMarkdown would emit `[@@](mention://all/all)`, slipping past the
  backend's non-empty check and routing as @ALL on save.
- strip-mention-markdown: same unescape order so trigger summaries for a
  name like `Ops\Bot` no longer show an extra backslash.

Regression tests pin: label with literal `\`; label with `\` adjacent to
`[` (4-char-into-7-source-char round-trip); `[](...)` and `[@](...)`
rejected by the tokenizer.
@furtherref furtherref merged commit eac3b55 into main May 19, 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.

1 participant