"""Record references written into prose: refusing guessed ids, resolving placeholders, reading the ids a message names. THE DEFECT THIS EXISTS FOR (#4016) A session about to create several records guesses the ids they will get — the last id it saw, plus one, plus two — and writes those guesses into a plan body or a reference note before the records exist. The database never collides: ids come from one Postgres sequence per table, handed out once each. But that sequence is shared by every session and every user, so any concurrent create takes a guessed number, and the reference silently points at someone else's record. More users make it worse, never better. Two halves, and they are not alternatives: - `{{ref:N}}` PLACEHOLDERS (services/record_batch.py) remove the reason to guess. A batch create inserts its records, receives their real ids and rewrites the placeholders inside ONE transaction, so there is no moment at which an id has to be predicted. - REFUSING A GUESSED ID is the backstop for a session that guesses anyway. It is a refusal, not a warning, because a warning is an instruction and the operator's point was exactly that instructions are not always followed. HOW A GUESS IS RECOGNISED A guess is always just ABOVE the highest id that exists — that is the only place "the next id" can be. So a `#N` is refused when it sits in (max_id, max_id + GUESS_WINDOW]. Numbers below the max are real records (or were), and numbers far above it are not Scribe ids at all — a PR, a forge issue — so both pass. What this cannot catch, by construction: a guess that another session has ALREADY used. That reference points at a real record, and nothing in the text distinguishes it from a deliberate one. The placeholders are the fix for that case; this is not. WHERE IT IS ENFORCED At the MCP door (the create/update tools), not in the services. The defect is an agent predicting ids; a person typing `#123` in the web editor has read that number off a record. Enforcing it in `notes.create_note` would also reach restores and internal writers that legitimately carry numbers from elsewhere. """ from __future__ import annotations import re from sqlalchemy import func, select from scribe.models import async_session from scribe.models.note import Note # `#123` as a standalone token. The lookbehind keeps out `{` entities, # URL fragments (`page#12`) and doubled `##`; `\b` after the digits keeps out a # hex colour like `#123abc`. REF_RE = re.compile(r"(? set[int]: """Every `#N` written in these texts.""" found: set[int] = set() for text in texts: if text: found.update(int(m) for m in REF_RE.findall(text)) return found async def _max_note_id() -> int: # Across ALL users and including trashed rows: the sequence is global, so # "the next id" is global too. Only the number is read, never a row. async with async_session() as session: return (await session.execute(select(func.max(Note.id)))).scalar() or 0 async def guessed_ids(*texts: str | None) -> list[int]: """The `#N` references in these texts that can only be predictions.""" cited = cited_ids(*texts) if not cited: return [] ceiling = await _max_note_id() return sorted(n for n in cited if ceiling < n <= ceiling + GUESS_WINDOW) async def refuse_guessed_ids(*texts: str | None) -> None: """Raise if any text cites a note/task id that has not been assigned yet. Raised before anything is written, so the caller's create or update does not happen. The message names the fix, because the agent reading it is mid-turn and can apply it immediately. """ guessed = await guessed_ids(*texts) if not guessed: return listed = ", ".join(f"#{n}" for n in guessed) raise ValueError( f"{listed} does not exist yet — it reads as a guessed id, and a guess " "is taken by whatever any other session creates next. Nothing was " "written. An id exists only once a create call RETURNS it: create the " "records first and cite the ids they come back with, or create them " "together with create_records / start_planning(steps=...) and write " "{{ref:N}} where the Nth record's id belongs. If this number is not a " "Scribe record (a PR, an issue elsewhere), write it without the '#'." ) def placeholder_keys(*texts: str | None) -> set[str]: """Every `{{ref:...}}` key in these texts: "1", "2", …, "milestone".""" keys: set[str] = set() for text in texts: if text: keys.update(PLACEHOLDER_RE.findall(text)) return keys def resolve_placeholders(text: str | None, refs: dict[str, str]) -> str | None: """Rewrite `{{ref:...}}` using `refs` (key -> rendered reference). Callers validate the keys first; an unknown key here is a bug in the caller, so it raises rather than leaving a placeholder in a stored record. """ if not text: return text return PLACEHOLDER_RE.sub(lambda m: refs[m.group(1)], text) # ── Records an operator names in a message (#4796) ───────────────────────── # # "yes go ahead with 4448" says exactly which record is meant, and the prompt # menu could not use it: a number carries no meaning to an embedding, so the # menu filled with whatever resembled the words AROUND it. These ids are looked # up directly instead (plugin_context.build_autoinject_hint). # # A guard against a false READ, not a false lookup. A number that is not a # record id still has to resolve to a record this user can open before anything # is shown, and the line says it was found by number — but every wrong id is a # record dropped in front of the reader for no reason, so the parser takes a # number only where the sentence makes it a reference. # How many named records one message can bring in. A real message names one or # two; past a handful the numbers are a pasted list or a log, not a reference. NAMED_LIMIT = 5 # A number, with its `#` when it has one. The lookbehind keeps out a number # inside a word, a path or URL (`pulls/198`), a version or date (`1.2`, # `2026-10-03`), money and entities; the lookahead keeps out the same on the # other side, a unit glued on (`300ms`, `40%`) and a thousands separator. _NAMED_RE = re.compile( r"(?-]*") _LIST_JOIN_RE = re.compile(r"\s*(?:,|,?\s*(?:and|or|&|\+|/))\s*", re.I) def _prev_word(before: str) -> str: """The word right before a number, past a space, a quote or a bracket.""" m = _PREV_WORD_RE.search(before) return m.group(1).lower() if m else "" def _next_word(after: str) -> str: m = _NEXT_WORD_RE.match(after) return m.group(1).lower() if m else "" def named_record_ids(prompt: str | None, limit: int = NAMED_LIMIT) -> list[int]: """The record ids a message names, in the order it names them. `#4448` unless the word before it is another numbering ("PR #198"); a bare number of three or more digits when it opens the message ("4417 sounds right"), follows a word that points at something ("go ahead with 4448") or continues a list one of those started ("4448, 4449 and 4450"), and is not a quantity ("for 300 seconds"). Fenced code is skipped: a pasted log is full of numbers and names nothing. Read the operator's OWN words, never a query enriched with the assistant's reply — a number the agent wrote is not one the operator named. """ text = _FENCE_RE.sub("\n", prompt or "") found: list[int] = [] last_end, last_ok = -1, False for m in _NAMED_RE.finditer(text): hashed, digits = bool(m.group(1)), m.group(2) before, after = text[:m.start()], text[m.end():] # A list inherits its head's reading, either way: "PR #198 and #199" # is two PRs, "with 4448, 4449" is two records. if last_end >= 0 and _LIST_JOIN_RE.fullmatch(text[last_end:m.start()]): ok = last_ok elif hashed: ok = _prev_word(before) not in _OTHER_SPACE else: ok = (bool(_OPENS_RE.fullmatch(before)) or _prev_word(before) in _REF_WORDS) last_end, last_ok = m.end(), ok if not ok or digits[0] == "0" or len(digits) < (2 if hashed else 3): continue if not hashed and _next_word(after) in _UNITS: continue nid = int(digits) if nid not in found: found.append(nid) if len(found) >= limit: break return found