From 8f432a00084100a0b81fefc623638ab8c8742f1b Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:07 +0200 Subject: [PATCH] feat(docs): refuse doc_type=user_facing with roboco-website guidance (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the docs-site split: DocsService.write_doc only ever wrote into docs//... team buckets, which are excluded from the published site — so an agent reaching for write_doc to publish a user-facing page failed silently into an unpublished bucket. doc_type="user_facing" is now a recognized DocType member that DocsService refuses up front with guidance naming the roboco-website project and the 3-edit pattern (MDX + route wrapper + nav.ts entry), instead of the generic "Unknown doc_type" error. The roboco_docs_write MCP tool docstring and input-schema description are updated so documenter LLMs see the scope boundary before calling it. Co-authored-by: Renn F --- roboco/api/schemas/docs.py | 4 ++++ roboco/mcp/docs_server.py | 13 +++++++++++++ roboco/mcp/schemas/__init__.py | 7 ++++++- roboco/services/docs.py | 27 +++++++++++++++++++++++++- tests/integration/test_docs_routes.py | 27 ++++++++++++++++++++++++++ tests/integration/test_docs_service.py | 23 ++++++++++++++++++++++ 6 files changed, 99 insertions(+), 2 deletions(-) diff --git a/roboco/api/schemas/docs.py b/roboco/api/schemas/docs.py index f14604f6..df2af638 100644 --- a/roboco/api/schemas/docs.py +++ b/roboco/api/schemas/docs.py @@ -24,6 +24,10 @@ class DocType(StrEnum): CHANGELOG = "changelog" # /docs/{team}/ ARCHITECTURE = "architecture" # /docs/{team}/architecture/ DESIGN = "design" # /docs/{team}/design/ (UX/UI) + # Recognized but refused by DocsService.write_doc with actionable guidance + # (see REFUSED_DOC_TYPES) — this store's buckets never publish, so a + # user-facing page belongs in the roboco-website project instead. + USER_FACING = "user_facing" # ============================================================================= diff --git a/roboco/mcp/docs_server.py b/roboco/mcp/docs_server.py index aa30f48d..3b2557f7 100644 --- a/roboco/mcp/docs_server.py +++ b/roboco/mcp/docs_server.py @@ -175,6 +175,19 @@ def create_docs_mcp_server(agent_id: str) -> FastMCP: """ Write or update documentation for your current task. + SCOPE — TEAM-FACING DOCS ONLY: this tool writes into docs//... + (api/qa/guide/readme/changelog/architecture/design), which is NEVER + published to the docs site. It is for internal notes future agents + need, not for anything a user should read. + + User-facing docs (anything meant to ship at docs.roboco.tech) do NOT + go through this tool at all — author them as a normal task in the + 'roboco-website' project instead: MDX under src/content/docs/, a + route wrapper under src/app/docs/, and a src/content/docs/nav.ts + entry (the 3-edit pattern; a CI check fails the PR if any of the + three is missing). Calling this tool with doc_type="user_facing" is + refused with this same guidance rather than silently accepted. + SMART DEDUPLICATION: Before creating a new doc, RAG searches for existing documentation with similar CONTENT (not just title). If a high-similarity match is found, the existing doc is updated diff --git a/roboco/mcp/schemas/__init__.py b/roboco/mcp/schemas/__init__.py index 9e0d16df..4fc9c933 100644 --- a/roboco/mcp/schemas/__init__.py +++ b/roboco/mcp/schemas/__init__.py @@ -21,7 +21,12 @@ class WriteDocInput(BaseModel): ) doc_type: str = Field( ..., - description="Type: api, qa, guide, readme, changelog, architecture, design", + description=( + "Type: api, qa, guide, readme, changelog, architecture, design. " + "These are team-facing docs only — never published. Do NOT pass " + "'user_facing': it is refused (see roboco_docs_write's docstring) " + "in favor of a documentation task on the roboco-website project." + ), ) title: str = Field( ..., diff --git a/roboco/services/docs.py b/roboco/services/docs.py index 4c13e19b..eaef87a3 100644 --- a/roboco/services/docs.py +++ b/roboco/services/docs.py @@ -72,6 +72,26 @@ TYPE_SUBFOLDERS: dict[str, str] = { "design": "design", } +# doc_type values that are recognized (not an "Unknown doc_type") but this +# store structurally cannot serve: it only writes team-facing docs into +# docs//... (all `exclude_docs`, never published — see the 2026-07-03 +# docs-site-split spec, Phase 2). Checked before the TYPE_SUBFOLDERS lookup so +# an agent gets this actionable guidance instead of a generic error and +# instead of quietly reaching for a nearby valid type (e.g. "guide") that +# silently lands the write in an unpublished bucket. +REFUSED_DOC_TYPES: dict[str, str] = { + "user_facing": ( + "doc_type='user_facing' is refused: this store only writes " + "team-facing docs into docs//... — excluded from the published " + "site. User-facing docs ship at docs.roboco.tech and are authored as " + "normal tasks in the 'roboco-website' project: MDX under " + "src/content/docs/, a route wrapper under src/app/docs/, and a " + "src/content/docs/nav.ts entry (the 3-edit pattern, PR-reviewed like " + "any other change). Open or claim a documentation task on " + "roboco-website instead of calling write_doc with this doc_type." + ), +} + # Roles that can write documentation WRITE_ROLES: frozenset[str] = frozenset({"documenter", "cell_pm"}) @@ -200,7 +220,12 @@ class DocsService(BaseService): doc_type = req.doc_type - # 2. Validate doc_type + # 2. Validate doc_type — a recognized-but-refused type (e.g. + # "user_facing") short-circuits with actionable guidance before the + # generic "unknown type" branch below. + if doc_type in REFUSED_DOC_TYPES: + raise ValidationError(REFUSED_DOC_TYPES[doc_type], field="doc_type") + if doc_type not in TYPE_SUBFOLDERS: valid_types = list(TYPE_SUBFOLDERS.keys()) raise ValidationError( diff --git a/tests/integration/test_docs_routes.py b/tests/integration/test_docs_routes.py index a0da95b2..1b99c5b2 100644 --- a/tests/integration/test_docs_routes.py +++ b/tests/integration/test_docs_routes.py @@ -92,6 +92,33 @@ async def test_write_doc_validation_error(docs_client: AsyncClient) -> None: assert response.status_code == HTTPStatus.BAD_REQUEST +@pytest.mark.asyncio +async def test_write_doc_user_facing_refused_is_400_not_422( + docs_client: AsyncClient, +) -> None: + """doc_type='user_facing' is a recognized DocType enum member, so Pydantic + accepts it at the HTTP boundary (no 422) and the service's actionable + refusal (400 with roboco-website guidance) is what the agent sees.""" + with patch("roboco.api.routes.docs.get_docs_service") as mock_get: + mock_service = AsyncMock() + mock_service.write_doc = AsyncMock( + side_effect=ValidationError("...roboco-website project...") + ) + mock_get.return_value = mock_service + response = await docs_client.post( + "/api/docs/write", + json={ + "task_id": str(uuid4()), + "filename": "test.md", + "doc_type": "user_facing", + "title": "Test", + "content": "Some content", + }, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + + @pytest.mark.asyncio async def test_write_doc_unauthorized(docs_client: AsyncClient) -> None: """Service raises UnauthorizedError → 403.""" diff --git a/tests/integration/test_docs_service.py b/tests/integration/test_docs_service.py index 54a0efb4..b69f3565 100644 --- a/tests/integration/test_docs_service.py +++ b/tests/integration/test_docs_service.py @@ -160,6 +160,29 @@ async def test_write_doc_invalid_doc_type(docs_setup: dict) -> None: ) +@pytest.mark.asyncio +async def test_write_doc_user_facing_refused(docs_setup: dict) -> None: + """doc_type='user_facing' is a recognized value (not a generic 'Unknown + doc_type') but is structurally refused: this store's buckets are all + excluded from the published site. The guidance names the roboco-website + project and the 3-edit pattern instead of silently landing an + unpublished write (docs-site-split Phase 2).""" + svc = docs_setup["svc"] + with pytest.raises(ValidationError, match="roboco-website") as exc_info: + await svc.write_doc( + agent_id="be-doc", + req=WriteDocInput( + task_id=docs_setup["task_id"], + filename="x.md", + doc_type="user_facing", + title="Title", + content="Content", + ), + ) + assert "Unknown doc_type" not in str(exc_info.value) + assert "docs.roboco.tech" in str(exc_info.value) + + @pytest.mark.asyncio async def test_write_doc_path_traversal_in_filename(docs_setup: dict) -> None: svc = docs_setup["svc"]