feat(mcp): extend dedup gate to create_rule / create_project_rule
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 52s

Completes the Phase 5 follow-up: rules now get the same update-over-create
gate. Title-based only (rules aren't a semantic-retrieval/RAG surface), scoped
to the same topic (rulebook rule) or same project (project rule). force=true
overrides; fail-open like the note/task gate.

Deferred-item decisions (operator): REST/web gating SKIPPED (kept MCP-only —
humans rarely double-create and a hard block needs UI affordance); orphan scope
kept orphan↔orphan (no change). So this rule gate is the only remaining build.

- services/dedup.py: find_duplicate_rule(title, topic_id|project_id).
- create_rule + create_project_rule: force param + gate.
- tests: rule title match, scope-required guard, tool gate (block + force).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:43:17 -04:00
parent 5102ffb558
commit dd1fc2d506
4 changed files with 105 additions and 1 deletions
+28
View File
@@ -94,6 +94,34 @@ async def test_create_rule_passes_required_fields():
assert kwargs["statement"] == "Work directly on dev"
@pytest.mark.asyncio
async def test_create_rule_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x")
assert out["duplicate"] is True
assert out["existing_id"] == 47
assert "update_rule" in out["message"]
create_mock.assert_not_called()
@pytest.mark.asyncio
async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=_fake_rule(id=5))):
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
assert out["id"] == 5
find_mock.assert_not_called()
@pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields():
rule = _fake_rule()
+29 -1
View File
@@ -3,7 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.dedup import DuplicateMatch, duplicate_response, find_duplicate_note
from scribe.services.dedup import (
DuplicateMatch,
duplicate_response,
find_duplicate_note,
find_duplicate_rule,
)
def _session_returning(note):
@@ -74,6 +79,29 @@ async def test_semantic_match_of_other_note_type_is_ignored():
assert dup is None # type mismatch must not block
@pytest.mark.asyncio
async def test_rule_title_match_in_topic():
rule = _fake_note(id=47, title="Honor the multi-user sharing ACL")
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(rule)):
dup = await find_duplicate_rule(
"honor the multi-user sharing acl", topic_id=7,
)
assert dup is not None
assert dup.id == 47
assert dup.reason == "title"
@pytest.mark.asyncio
async def test_rule_requires_a_scope():
# No topic_id and no project_id → nothing to scope to → no match, no query.
sess = AsyncMock()
with patch("scribe.services.dedup.async_session", return_value=sess):
dup = await find_duplicate_rule("anything")
assert dup is None
sess.__aenter__.assert_not_called()
def test_duplicate_response_shape():
dm = DuplicateMatch(id=5, title="Foo", similarity=1.0, reason="title")
r = duplicate_response(dm, "task")