fix(#4016): records that cite each other are created together, and a guessed id is refused
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 1m2s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 39s

Sessions predicted the ids their next creates would get and wrote them into
plan bodies and reference notes before the records existed. The database
never collides; the sequence is shared by every session and user, so any
concurrent create took the guessed numbers and the references pointed at
someone else's records.

- create_records (new MCP tool) and start_planning(body=, steps=) create
  their records in ONE transaction: insert, flush for the real ids, rewrite
  {{ref:N}} / {{ref:milestone}} placeholders as #id "title", commit. No
  prediction, no waiting, no stub records left behind when a batch fails.
  Ids need not be consecutive and nothing depends on it.
- Every MCP create/update of a note, task or milestone refuses a #N sitting
  just above the highest assigned id (within 50): that can only be a guess.
  Refusal, not warning. Numbers far above the max (PRs, forge issues) pass.
- notes.build_note splits validation out of create_note so the batch
  validates records exactly as a single create does.
- writing-plans and using-scribe say to pass steps up front and never write
  an unassigned id; plugin version minted.

Integration test runs six concurrent batches and checks each resolves its
placeholders to its own records, and that a failing batch writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-14 08:33:56 -04:00
co-authored by Claude Opus 5
parent 2e8d8461cc
commit 441a1ac31d
15 changed files with 1038 additions and 63 deletions
+3
View File
@@ -16,6 +16,7 @@ from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
from scribe.services.record_refs import refuse_guessed_ids
async def list_milestones(project_id: int) -> dict:
@@ -83,6 +84,7 @@ async def create_milestone(
status: active (default) or done.
"""
uid = current_user_id()
await refuse_guessed_ids(title, description, body)
milestone = await milestones_svc.create_milestone(
uid,
project_id=project_id,
@@ -127,6 +129,7 @@ async def update_milestone(
fields["status"] = status
if order_index >= 0:
fields["order_index"] = order_index
await refuse_guessed_ids(title, description, body)
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
+8
View File
@@ -22,6 +22,7 @@ from scribe.services import supersession as supersession_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids
async def list_notes(
@@ -169,6 +170,11 @@ async def create_note(
instead (no duplicate bloat / no stale RAG copies). Set true only
when you're sure this is a genuinely distinct note.
AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. A body citing a `#N` that has
not been assigned yet is refused — every session and user draws from one
sequence, so an expected id is taken by whoever creates next. Records that
must cite each other: create_records, with {{ref:N}} placeholders.
Returns the created note object including its assigned id, OR — when a
near-duplicate is found and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created. A tagged
@@ -177,6 +183,7 @@ async def create_note(
create the missing System, or deliberately leave it untagged.
"""
uid = current_user_id()
await refuse_guessed_ids(title, body)
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
@@ -269,6 +276,7 @@ async def update_note(
fields["verify_with"] = verify_with
if expires_when:
fields["expires_when"] = expires_when
await refuse_guessed_ids(title, body)
note = await notes_svc.update_note(
uid, note_id, clear=clear or (), **fields
)
+153 -6
View File
@@ -29,11 +29,13 @@ from scribe.services import notes as notes_svc
# guard into a MagicMock that approves anything.
from scribe.services.notes import minted_kind
from scribe.services import planning as planning_svc
from scribe.services import record_batch as batch_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids
async def list_tasks(
@@ -174,6 +176,13 @@ async def create_task(
BLOCKED and the existing task's id is returned so you update it
instead. Set true only for a genuinely distinct task.
AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. Never write the id you expect
a record to get: every session and user draws from one sequence, so the
number is taken by whoever creates next. A body citing a `#N` that has not
been assigned yet is refused. Creating several records that cite each
other? Use create_records (or start_planning(steps=...)) and write
{{ref:N}} — the real ids are filled in as they are created.
Returns the created task, OR — when a near-duplicate is found and force is
false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
created). A tagged record shows its `systems`; created untagged in a
@@ -189,6 +198,7 @@ async def create_task(
"milestone + seeds the design), then create each step as its own "
"task with create_task(milestone_id=<that milestone>)."
)
await refuse_guessed_ids(title, body)
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
@@ -279,6 +289,7 @@ async def update_task(
fields["arose_from_id"] = arose_from_id
if kind:
fields["task_kind"] = minted_kind(kind)
await refuse_guessed_ids(title, body)
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
@@ -324,7 +335,117 @@ async def add_task_log(task_id: int, content: str) -> dict:
return data
async def start_planning(project_id: int, title: str) -> dict:
_ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "system_ids"}
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
"""Parse the door's plain dicts into BatchItems, refusing unknown keys.
Strict for the same reason StrictArgsFastMCP is (#2709): a misspelt key
silently dropped — `milestone` for a per-record milestone, `desc` for body —
creates a record that looks right and is missing what the caller sent.
"""
items: list[batch_svc.BatchItem] = []
for i, raw in enumerate(records or [], start=1):
if not isinstance(raw, dict):
raise ValueError(f"{what} {i} must be an object with at least a title")
unknown = set(raw) - _ITEM_KEYS
if unknown:
raise ValueError(
f"{what} {i} has unknown field(s) {sorted(unknown)}; "
f"allowed: {sorted(_ITEM_KEYS)}"
)
rtype = raw.get("type") or "task"
if rtype not in ("task", "note"):
raise ValueError(f"{what} {i}: type must be 'task' or 'note', got {rtype!r}")
items.append(batch_svc.BatchItem(
title=raw.get("title") or "",
body=raw.get("body") or "",
is_task=rtype == "task",
status=raw.get("status") or "todo",
priority=raw.get("priority") or None,
task_kind=raw.get("kind") or "work",
tags=list(raw.get("tags") or []),
system_ids=list(raw.get("system_ids") or []),
))
return items
async def _first_duplicate(uid: int, items: list, project_id: int | None) -> dict | None:
"""The duplicate gate over a whole batch — the first hit blocks all of it."""
for i, item in enumerate(items, start=1):
dup = await dedup_svc.find_duplicate_note(
uid, item.title, item.body, project_id=project_id,
is_task=item.is_task, note_type="note",
)
if dup is not None:
payload = dedup_svc.duplicate_response(dup, "task" if item.is_task else "note")
payload["record"] = i
payload["message"] = f"Record {i} of the batch: {payload['message']} Nothing in the batch was created."
return payload
return None
async def create_records(
records: list[dict],
project_id: int = 0,
milestone_id: int = 0,
force: bool = False,
) -> dict:
"""Create several tasks and/or notes in ONE call, so they can cite each other.
Reach for this whenever records you are about to create need to reference
one another — a set of tasks that name their siblings, a reference note
listing the tasks it indexes. Writing the ids you EXPECT them to get is
always wrong: every session and user draws from one sequence, so another
create takes those numbers, and a body citing an unassigned `#N` is refused.
A plan (a milestone plus its steps) is start_planning(steps=...), which
uses the same mechanism. One record with nothing to cite is create_task or
create_note.
In any record's body write {{ref:N}} where the Nth record's id belongs
(1-based, in the order listed). It is replaced with `#<id> "<title>"`
once the ids exist. Everything is created in one transaction: a bad
placeholder, an invalid field or a duplicate creates NOTHING.
Args:
records: The records, in order. Each is an object with `title`
(required) and optionally `body`, `type` ('task', the default, or
'note'), and for tasks `status`, `priority` and `kind`
('work' | 'issue' | 'spike'); plus `tags` and `system_ids`.
project_id: The project every record belongs to (0 = none, or taken
from milestone_id).
milestone_id: File every record under this existing milestone (0 = none).
force: Bypass the near-duplicate gate for the whole batch. By default
the first record that near-duplicates an existing one BLOCKS the
batch, and its existing id comes back so you can update it instead.
Returns {"ids": [...], "records": [...]} in the order given — the ids
cite-able from here on — OR a duplicate payload naming which `record`
matched, with nothing created. The ids are the real ones and are not
necessarily consecutive: other sessions keep creating meanwhile, and
nothing here depends on the numbers being adjacent.
"""
uid = current_user_id()
items = _batch_items(records)
await refuse_guessed_ids(*[t for item in items for t in (item.title, item.body)])
if not force:
dup = await _first_duplicate(uid, items, project_id or None)
if dup is not None:
return dup
_ms, notes = await batch_svc.create_batch(
uid, items, project_id=project_id or None, milestone_id=milestone_id or None,
)
return {"ids": [n.id for n in notes], "records": [n.to_dict() for n in notes]}
async def start_planning(
project_id: int,
title: str,
body: str = "",
steps: list[dict] | None = None,
force: bool = False,
) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Reach for this when the work has an ARC — several steps toward one goal,
@@ -341,11 +462,17 @@ async def start_planning(project_id: int, title: str) -> dict:
context. The milestone is the plan container — the individual steps live as
first-class child tasks under it, not as checkboxes in the body.
Afterwards:
- Edit the plan/design with update_milestone(milestone_id, body=...).
- Create each step as its own task with create_task(milestone_id=<this id>);
track it with status + add_task_log. Do NOT put steps as checkboxes in the
milestone body.
PASS THE STEPS HERE when you already know them. The milestone and every
step are then created in one transaction, and the plan body and the steps
can cite each other with placeholders that become real ids: {{ref:N}} is
the Nth step (1-based) and {{ref:milestone}} is this milestone. Never write
the ids you expect records to get — other sessions and users draw from the
same sequence, and a body citing an unassigned `#N` is refused.
Without steps: edit the design afterwards with update_milestone(
milestone_id, body=...), and add steps with create_records(milestone_id=
<this id>, ...) — or create_task for a single one. Track each with status
+ add_task_log. Do NOT put steps as checkboxes in the milestone body.
(kind=plan tasks are retired — use this instead. Existing historical
plan-tasks remain readable but new planning goes through milestones.)
@@ -353,10 +480,29 @@ async def start_planning(project_id: int, title: str) -> dict:
Args:
project_id: The project this plan is for.
title: A short title for the plan/milestone.
body: The plan's design (markdown). Omit to seed the Goal/Approach/
Verification template.
steps: The plan's step-tasks, in order — each an object with `title`
(required) and optionally `body`, `status`, `priority`, `kind`
('work' | 'issue' | 'spike'), `tags`, `system_ids`.
force: Bypass the near-duplicate gate on the steps. By default a step
that near-duplicates an existing task blocks the whole plan, and
nothing — milestone included — is created.
Returns the milestone, the project's applicable rules and brief context,
plus `steps` (the created tasks, in order) when steps were given — OR a
duplicate payload naming the `record` that matched, with nothing created.
"""
uid = current_user_id()
items = _batch_items(steps or [], what="step")
await refuse_guessed_ids(body, *[t for item in items for t in (item.title, item.body)])
if items and not force:
dup = await _first_duplicate(uid, items, project_id or None)
if dup is not None:
return dup
return await planning_svc.start_planning(
user_id=uid, project_id=project_id, title=title,
body=body or None, steps=items or None,
)
@@ -388,6 +534,7 @@ def register(mcp) -> None:
list_tasks,
get_task,
create_task,
create_records,
update_task,
add_task_log,
start_planning,
+91 -42
View File
@@ -155,6 +155,77 @@ async def create_note(
verify_with: str | None = None,
expires_when: str | None = None,
) -> Note:
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from scribe.models.milestone import Milestone
async with async_session() as lookup:
result = await lookup.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
ms = result.scalars().first()
if ms is not None:
project_id = ms.project_id
note = build_note(
user_id,
title=title,
body=body,
description=description,
tags=tags,
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
note_type=note_type,
task_kind=task_kind,
arose_from_id=arose_from_id,
data=data,
verify_with=verify_with,
expires_when=expires_when,
)
async with async_session() as session:
session.add(note)
await session.commit()
await session.refresh(note)
embed_note(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
return note
def build_note(
user_id: int,
title: str = "",
body: str = "",
description: str | None = None,
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
recurrence_rule: dict | None = None,
note_type: str = "note",
task_kind: str = "work",
arose_from_id: int | None = None,
data: dict | None = None,
verify_with: str | None = None,
expires_when: str | None = None,
) -> Note:
"""A validated, UNSAVED Note — every check create_note makes, no write.
Split out of create_note so a caller that must add several records in one
transaction (services/record_batch.py) validates them exactly as a single
create would, instead of carrying a second copy of the rules that drifts.
Raises before anything is built, so nothing illegal reaches a session.
"""
# Empty means empty (NULLABLE_NOTE_TEXT), then the invariant. Both run
# before anything is written, so an illegal shape never reaches the table.
verify_with = verify_with or None
@@ -176,48 +247,26 @@ async def create_note(
except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from scribe.models.milestone import Milestone
async with async_session() as lookup:
result = await lookup.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
ms = result.scalars().first()
if ms is not None:
project_id = ms.project_id
async with async_session() as session:
note = Note(
user_id=user_id,
title=title,
body=body,
description=description,
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
note_type=note_type,
task_kind=task_kind,
arose_from_id=arose_from_id,
data=data,
verify_with=verify_with,
expires_when=expires_when,
)
session.add(note)
await session.commit()
await session.refresh(note)
embed_note(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
return note
return Note(
user_id=user_id,
title=title,
body=body,
description=description,
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
note_type=note_type,
task_kind=task_kind,
arose_from_id=arose_from_id,
data=data,
verify_with=verify_with,
expires_when=expires_when,
)
async def get_note(user_id: int, note_id: int) -> Note | None:
+43 -9
View File
@@ -6,13 +6,19 @@ The milestone IS the plan: its `body` holds the design/intent (Goal/Approach/
Verification), and the individual steps live as first-class child tasks
(milestone_id) rather than checkboxes crammed into one body. The legacy
kind=plan task is retired going forward — start_planning never creates one.
Steps passed up front are created in the SAME transaction as the milestone
(services/record_batch.py), so the plan body and the steps can cite each other
through `{{ref:N}}` placeholders instead of predicted ids (#4016).
"""
from __future__ import annotations
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import record_batch as batch_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.record_refs import placeholder_keys
# The plan body template — design only. Steps are NOT checkboxes here; each
# step becomes its own child task under this milestone (status, work-logs,
@@ -25,10 +31,19 @@ PLAN_TEMPLATE = """## Goal
"""
async def start_planning(user_id: int, project_id: int, title: str) -> dict:
async def start_planning(
user_id: int,
project_id: int,
title: str,
body: str | None = None,
steps: list[batch_svc.BatchItem] | None = None,
) -> dict:
"""Create a milestone seeded as a plan container and return it with the
project's applicable rules + brief context.
`body` replaces the seeded template; `steps` creates the step-tasks with
the milestone, atomically.
Returns:
{
"milestone": <milestone dict>,
@@ -37,19 +52,35 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"applicable_rules_truncated": bool,
"project_goal": str,
"open_task_count": int,
"steps": [<task dict>, ...], # only when steps were given
}
"""
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
milestone = await milestones_svc.create_milestone(
user_id,
project_id=project_id,
title=title,
body=PLAN_TEMPLATE,
status="active",
)
plan_body = body or PLAN_TEMPLATE
step_notes = []
if steps:
milestone, step_notes = await batch_svc.create_batch(
user_id, steps, project_id=project_id,
milestone=batch_svc.BatchMilestone(title=title, body=plan_body),
)
else:
# A placeholder with no batch behind it has nothing to resolve to, and
# storing `{{ref:1}}` verbatim would be a reference to nothing.
if placeholder_keys(plan_body):
raise ValueError(
"the plan body uses {{ref:...}} placeholders but no steps were "
"given — pass steps=[...] so they have records to name"
)
milestone = await milestones_svc.create_milestone(
user_id,
project_id=project_id,
title=title,
body=plan_body,
status="active",
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=user_id,
@@ -58,9 +89,12 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
user_id, is_task=True, status="todo", project_id=project_id, limit=1,
)
return {
result = {
"milestone": milestone.to_dict(),
**rulebooks_svc.rules_payload(applicable, user_id=user_id, source="start_planning"),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
if steps:
result["steps"] = [n.to_dict() for n in step_notes]
return result
+193
View File
@@ -0,0 +1,193 @@
"""Create several records at once, so they can cite each other without guessing ids.
Why this exists is in services/record_refs.py (#4016): a session that needs
records to reference one another used to create them one by one and predict
the ids of the ones not yet made — and any concurrent create, from any session
or any user, took those numbers.
Here the whole batch is ONE transaction: insert every record, flush so the
sequence assigns the real ids, rewrite each `{{ref:N}}` with the id and title
it names, commit. Other sessions keep creating throughout and cannot interfere
— the sequence never hands out the same number twice, so another session's
insert just takes a different one. The ids a batch receives are therefore NOT
guaranteed consecutive, and nothing here needs them to be: the placeholders
are filled with whatever ids came back. Forcing consecutive ids would take a
table lock that stalls every user's writes; nobody needs that.
All or nothing: a bad placeholder, an invalid status, or a failure mid-insert
leaves no partial batch behind — which is also why there are no "reserved"
stub records to clean up after a session that dies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.services import access as access_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services.record_refs import placeholder_keys, resolve_placeholders
# A batch is a plan's steps or a handful of linked records, and every item runs
# the near-duplicate gate (an embedding search) before the transaction. 50 is
# far past any real plan and keeps one call from becoming a bulk import, which
# is a different job with a different door.
MAX_BATCH = 50
@dataclass
class BatchItem:
"""One record in a batch. `is_task` False makes it a plain note."""
title: str
body: str = ""
is_task: bool = True
status: str = "todo"
priority: str | None = None
task_kind: str = "work"
tags: list[str] = field(default_factory=list)
system_ids: list[int] = field(default_factory=list)
@dataclass
class BatchMilestone:
"""The milestone start_planning creates in the same transaction as its steps."""
title: str
body: str
description: str | None = None
def check_batch(items: list[BatchItem], milestone: BatchMilestone | None = None) -> None:
"""Refuse a batch that cannot be written in full, before any write.
Every placeholder must name something the batch will actually create;
otherwise the rewrite has nothing to put there, and the alternatives —
leaving `{{ref:9}}` in a stored body, or dropping it — both store a
reference that points nowhere.
"""
if not items and milestone is None:
raise ValueError("a batch needs at least one record")
if len(items) > MAX_BATCH:
raise ValueError(f"a batch holds at most {MAX_BATCH} records; got {len(items)}")
for i, item in enumerate(items, start=1):
if not (item.title or "").strip():
raise ValueError(f"record {i} has no title")
valid = {str(i) for i in range(1, len(items) + 1)}
if milestone is not None:
valid.add("milestone")
texts = [item.body for item in items] + ([milestone.body] if milestone else [])
unknown = placeholder_keys(*texts) - valid
if unknown:
named = ", ".join("{{ref:%s}}" % k for k in sorted(unknown))
raise ValueError(
f"{named} names no record in this batch. {{{{ref:N}}}} is the Nth "
f"record listed (1 to {len(items)})"
+ (", and {{ref:milestone}} is the milestone being created." if milestone else ".")
)
async def create_batch(
user_id: int,
items: list[BatchItem],
*,
project_id: int | None = None,
milestone_id: int | None = None,
milestone: BatchMilestone | None = None,
) -> tuple[Milestone | None, list[Note]]:
"""Create `items` (and optionally a new milestone they belong to) atomically.
`milestone_id` files the items under an EXISTING milestone the caller owns;
`milestone` creates a new one alongside them and files them there. Passing
both is refused. Returns (the new milestone or None, the notes in input
order). Near-duplicate gating is the door's job, exactly as for a single
create.
"""
if milestone is not None and milestone_id:
raise ValueError("pass milestone_id (an existing milestone) or a new milestone, not both")
check_batch(items, milestone)
if milestone_id:
async with async_session() as session:
existing = (await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.deleted_at.is_(None),
)
)).scalars().first()
if existing is None:
raise ValueError(f"milestone {milestone_id} not found")
if project_id and project_id != existing.project_id:
raise ValueError(
f"milestone {milestone_id} belongs to project {existing.project_id}, not {project_id}"
)
project_id = existing.project_id
if milestone is not None and not project_id:
raise ValueError("a new milestone needs a project_id")
# Share-aware (rule 78): a collaborator with write access to a shared
# project may plan in it; a bare owner filter would refuse them.
if project_id and not await access_svc.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found")
async with async_session() as session:
# Validate every record before adding any: build_note raises on a bad
# status/priority, and raising here writes nothing.
notes = [
notes_svc.build_note(
user_id,
title=item.title,
body=item.body,
tags=item.tags,
project_id=project_id,
milestone_id=milestone_id,
status=item.status if item.is_task else None,
priority=item.priority if item.is_task else None,
task_kind=notes_svc.minted_kind(item.task_kind) if item.is_task else "work",
)
for item in items
]
new_ms = None
if milestone is not None:
new_ms = Milestone(
user_id=user_id, project_id=project_id, title=milestone.title,
description=milestone.description, body=milestone.body, status="active",
)
session.add(new_ms)
await session.flush()
for note in notes:
note.milestone_id = new_ms.id
session.add_all(notes)
# The flush is where the sequence assigns ids. Nothing is visible to
# any other session until the commit below, and if anything between
# here and there raises, the context manager rolls it all back.
await session.flush()
refs = {str(i): f'#{n.id} "{n.title}"' for i, n in enumerate(notes, start=1)}
if new_ms is not None:
refs["milestone"] = f'milestone {new_ms.id} "{new_ms.title}"'
new_ms.body = resolve_placeholders(new_ms.body, refs)
for note in notes:
note.body = resolve_placeholders(note.body, refs)
await session.commit()
for note in notes:
await session.refresh(note)
if new_ms is not None:
await session.refresh(new_ms)
# After the commit, as a single create does: embedding and System tags are
# enrichment on records that now exist, and a failure in either must not
# un-create them.
for note, item in zip(notes, items):
notes_svc.embed_note(note)
if item.system_ids:
await systems_svc.set_record_systems(user_id, note.id, item.system_ids)
if project_id is not None:
await notes_svc._maybe_reactivate_project(project_id)
return new_ms, notes
+131
View File
@@ -0,0 +1,131 @@
"""Record references written into prose: refusing guessed ids, resolving placeholders.
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 `&#123;` entities,
# URL fragments (`page#12`) and doubled `##`; `\b` after the digits keeps out a
# hex colour like `#123abc`.
REF_RE = re.compile(r"(?<![\w&#/])#(\d+)\b")
# How far above the highest existing id a number still reads as a guess. It
# only has to cover how far ahead a session predicts, which is the size of the
# batch it is planning — a plan's steps, a handful of cross-linked notes. 50 is
# generous for that and still narrow enough that an unrelated number (a PR, an
# issue on a forge) landing inside it is rare. Widening it trades exactly that.
GUESS_WINDOW = 50
# `{{ref:3}}` names the third record of the same batch; `{{ref:milestone}}` names
# the milestone a start_planning call creates alongside its steps.
PLACEHOLDER_RE = re.compile(r"\{\{\s*ref:\s*(\d+|milestone)\s*\}\}")
def cited_ids(*texts: str | None) -> 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)