diff --git a/CHANGELOG.md b/CHANGELOG.md index db7448c..d6a7db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ remains in `docs/MILESTONE_*_BASELINE.md` and `docs/MILESTONE_*_CLOSEOUT.md`. ## Unreleased +- Project-owned MCP servers can explicitly authorize a canonical applier to accept exact-hash + changesets from additional configured proposal writers. The default remains same-identity + application, contributor processes receive no application tool, and application receipts record + both the proposal creator and applier. + ## 1.4.0 - 2026-07-29 Version `1.4.0` is the first additive DocForge2 successor release. Annotated tag `v1.4.0` diff --git a/docs/CANONICAL_APPLICATION.md b/docs/CANONICAL_APPLICATION.md index 52608d7..3e9baf3 100644 --- a/docs/CANONICAL_APPLICATION.md +++ b/docs/CANONICAL_APPLICATION.md @@ -13,7 +13,11 @@ files. - CLI requires `apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID`. - MCP registers `docforge_apply_changeset` only when the server starts with an explicit canonical applier identity and compatible applier implementation. -- The changeset creator and applier identity must match a configured proposal writer. +- The changeset creator must be a configured proposal writer. +- Application defaults to changesets created by the applier identity. A project-owned server may + explicitly bind additional configured proposal writers that its applier is authorized to accept. +- Cross-identity acceptance does not let the applier edit the contributor's proposal and does not + give the contributor an application tool. - The exact final changeset hash is required. Any proposal mutation invalidates an earlier approval. @@ -33,6 +37,7 @@ The application boundary requires: - exact changeset-hash approval; - startup-bound applier identity; +- an explicit accepted-writer allowlist for any cross-identity application; - project-owned serializers for custom adapters; - canonical path and symbolic-link confinement; - rollback and semantic round-trip verification; diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index d1c345d..fde17a5 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -191,7 +191,10 @@ An explicit project integration may construct the full fixed surface only after confined proposal policy and startup-bound writer. Adapter proposal validators may narrow the writer's declared operations further. They cannot add arbitrary tools or weaken core changeset validation. The fixed application tool is registered only through the separate canonical applier -gate. +gate. Application accepts only changesets created by the applier identity unless the project-owned +factory explicitly supplies `accepted_proposal_writers`. Every accepted identity must already be a +configured proposal writer. This allowlist permits review and acceptance across process identities; +it does not grant proposal mutation or application tools to a contributor process. ## Isolated proposal tools diff --git a/docs/POLICY_PRECEDENCE.md b/docs/POLICY_PRECEDENCE.md index 2b275b1..015330d 100644 --- a/docs/POLICY_PRECEDENCE.md +++ b/docs/POLICY_PRECEDENCE.md @@ -43,9 +43,10 @@ Capability modes are: - `operator`: reserved; it currently adds no tools. Mode describes the maximum registered surface. Actual authority can be narrower. A descriptor must -declare the selected writer, including allowed families and operation types. Application requires -the matching configured writer, changeset creator, and canonical-applier identity. A mode name -cannot create a missing descriptor grant. +declare the selected writer, including allowed families and operation types. Application defaults +to a matching configured writer, changeset creator, and canonical-applier identity. A project-owned +server may explicitly authorize its applier to accept changesets from additional configured writers. +A mode name cannot create a missing descriptor grant or extend that accepted-writer allowlist. Generic generated client fragments default to read mode. Other construction paths preserve their documented compatible factory defaults. Treat `docforge_bootstrap.session_contract` and its actual diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 1db7367..25a6043 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -815,7 +815,14 @@ docforge-mcp \ ``` Without `--canonical-applier`, `docforge_apply_changeset` is not registered. The flag is an -identity, not a command. The changeset creator, configured writer, and canonical applier must agree. +identity, not a command. Generic CLI and MCP application require the changeset creator, configured +writer, and canonical applier to agree. + +A project-owned adapter server can separately pass `accepted_proposal_writers` to +`create_project_server`. This explicit allowlist lets its startup-bound applier accept an exact +reviewed changeset from another configured contributor identity. The default remains the applier +identity only. Accepted contributors retain their original proposal permissions and do not receive +canonical application authority. Call `docforge_bootstrap` first. Its version-1 `session_contract` contains the fixed binding, current graph generation, effective policy, actual capabilities, render policies, prohibitions, diff --git a/src/docforge/application.py b/src/docforge/application.py index d6130cc..039454e 100644 --- a/src/docforge/application.py +++ b/src/docforge/application.py @@ -1204,12 +1204,39 @@ class CanonicalApplicationService: *, applier_id: str | None, applier: CanonicalApplier | None, + accepted_proposal_writers: tuple[str, ...] = (), index: ProjectIndex | None = None, manual_policy: ManualProjectionMode = "auto", ) -> None: self.project = project self.applier_id = applier_id self.applier = applier + if accepted_proposal_writers and (applier_id is None or applier is None): + raise DocForgeError( + "invalid_application_policy", + "Accepted proposal writers require an enabled canonical applier", + ) + configured_writers = frozenset( + writer.writer_id for writer in project.descriptor.proposal_writers + ) + selected_writers = ( + accepted_proposal_writers + if accepted_proposal_writers + else ((applier_id,) if applier_id is not None else ()) + ) + if len(set(selected_writers)) != len(selected_writers): + raise DocForgeError( + "invalid_application_policy", + "Accepted proposal writer identities must be unique", + ) + unknown_writers = sorted(set(selected_writers) - configured_writers) + if unknown_writers: + raise DocForgeError( + "invalid_application_policy", + "Accepted proposal writers must be configured for this project", + writers=unknown_writers, + ) + self.accepted_proposal_writers = tuple(sorted(selected_writers)) self.changesets = ChangesetStore(project, applier_id) self.index = index or ProjectIndex(project) self.manual_policy = validate_manual_projection_mode(manual_policy) @@ -1227,6 +1254,9 @@ class CanonicalApplicationService: return { "enabled": self.enabled, "applier": self.applier_id if self.enabled else None, + "accepted_proposal_writers": ( + list(self.accepted_proposal_writers) if self.enabled else [] + ), } def apply(self, changeset_id: str, expected_changeset_hash: str) -> dict[str, object]: @@ -1239,6 +1269,7 @@ class CanonicalApplicationService: changeset_id=changeset_id, expected_changeset_hash=expected_changeset_hash, applier_id=self.applier_id, + accepted_creator_ids=frozenset(self.accepted_proposal_writers), application=self.applier.apply, ) refresh_errors: list[dict[str, object]] = [] diff --git a/src/docforge/changesets.py b/src/docforge/changesets.py index 9885c44..f23c2f4 100644 --- a/src/docforge/changesets.py +++ b/src/docforge/changesets.py @@ -598,6 +598,7 @@ class ChangesetStore: changeset_id: str, expected_changeset_hash: str, applier_id: str, + accepted_creator_ids: frozenset[str] | None = None, application: Callable[ [ProjectSnapshot, ProjectSnapshot, tuple[Mapping[str, object], ...]], dict[str, object], @@ -628,13 +629,17 @@ class ChangesetStore: expected=expected_changeset_hash, actual=actual_hash, ) - if document["creator"] != applier_id: + accepted_creators = ( + frozenset({applier_id}) if accepted_creator_ids is None else accepted_creator_ids + ) + if document["creator"] not in accepted_creators: raise DocForgeError( "changeset_owner_conflict", - "Canonical applier does not own this changeset", + "Canonical applier is not authorized to accept this changeset creator", changeset_id=changeset_id, owner=document["creator"], applier=applier_id, + accepted_creators=sorted(accepted_creators), ) projected = ProjectSnapshot( descriptor=snapshot.descriptor, @@ -680,6 +685,8 @@ class ChangesetStore: lifecycle=lifecycle, applied_from_revision=snapshot.revision, applied_from_source_hash=snapshot.source_hash, + proposal_creator=document["creator"], + applied_by=applier_id, **payload, ) diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index d98afe0..6c49b47 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -133,6 +133,7 @@ class DocForgeService: *, canonical_applier_id: str | None = None, canonical_applier: CanonicalApplier | None = None, + accepted_proposal_writers: tuple[str, ...] = (), context_provider: ContextProvider = compile_context, tool_surface: tuple[str, ...] | None = None, binding_metadata: Mapping[str, object] | None = None, @@ -188,6 +189,7 @@ class DocForgeService: self.project, applier_id=canonical_applier_id if application_enabled else None, applier=canonical_applier if application_enabled else None, + accepted_proposal_writers=(accepted_proposal_writers if application_enabled else ()), index=self.index, manual_policy=self.projection_policy.manual, ) @@ -2108,6 +2110,7 @@ def create_project_server( proposal_writer: str | None = None, canonical_applier_id: str | None = None, canonical_applier: CanonicalApplier | None = None, + accepted_proposal_writers: tuple[str, ...] = (), context_provider: ContextProvider = compile_context, binding_metadata: Mapping[str, object] | None = None, no_ast: bool = False, @@ -2124,6 +2127,7 @@ def create_project_server( proposal_writer, canonical_applier_id=canonical_applier_id, canonical_applier=canonical_applier, + accepted_proposal_writers=accepted_proposal_writers, context_provider=context_provider, binding_metadata=binding_metadata, no_ast=no_ast, diff --git a/tests/test_changesets.py b/tests/test_changesets.py index c956990..e2f47b5 100644 --- a/tests/test_changesets.py +++ b/tests/test_changesets.py @@ -328,6 +328,103 @@ class DocForgeChangesetTests(unittest.TestCase): self.assertFalse((root / ".docforge/changesets/.state/update-race.json").exists()) self.assertFalse(tuple(target.parent.glob(".docforge-apply-*"))) + def test_explicit_applier_accepts_a_configured_contributor_changeset(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + descriptor = root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + + """ + +[[changesets.writers]] +id = "contributor" +families = ["guide"] +operations = ["update"] +""", + encoding="utf-8", + ) + project = Project.open(root) + proposal = ChangesetStore(project, "contributor").register( + "contributor-update", + [ + { + "operation": "update", + "node_id": "guide.workflow", + "metadata": {"summary": "Accepted from a configured contributor."}, + "rationale": "Prove explicit cross-identity acceptance.", + } + ], + ) + default_service = CanonicalApplicationService( + project, + applier_id="alpha-editor", + applier=GenericCanonicalApplier(project), + ) + + with self.assertRaises(DocForgeError) as denied: + default_service.apply( + "contributor-update", + str(proposal["changeset_hash"]), + ) + + self.assertEqual("changeset_owner_conflict", denied.exception.code) + service = CanonicalApplicationService( + project, + applier_id="alpha-editor", + applier=GenericCanonicalApplier(project), + accepted_proposal_writers=("alpha-editor", "contributor"), + ) + result = service.apply( + "contributor-update", + str(proposal["changeset_hash"]), + ) + + self.assertTrue(result["applied"]) + self.assertEqual("contributor", result["proposal_creator"]) + self.assertEqual("alpha-editor", result["applied_by"]) + self.assertEqual( + ["alpha-editor", "contributor"], + service.access()["accepted_proposal_writers"], + ) + workflow = next( + node for node in project.load().nodes if node.node_id == "guide.workflow" + ) + self.assertEqual( + "Accepted from a configured contributor.", + workflow.summary, + ) + + def test_application_rejects_unknown_or_duplicate_accepted_writers(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + + with self.assertRaises(DocForgeError) as unknown: + CanonicalApplicationService( + project, + applier_id="alpha-editor", + applier=GenericCanonicalApplier(project), + accepted_proposal_writers=("missing-writer",), + ) + with self.assertRaises(DocForgeError) as duplicate: + CanonicalApplicationService( + project, + applier_id="alpha-editor", + applier=GenericCanonicalApplier(project), + accepted_proposal_writers=("alpha-editor", "alpha-editor"), + ) + with self.assertRaises(DocForgeError) as disabled: + CanonicalApplicationService( + project, + applier_id=None, + applier=None, + accepted_proposal_writers=("alpha-editor",), + ) + + self.assertEqual("invalid_application_policy", unknown.exception.code) + self.assertEqual("invalid_application_policy", duplicate.exception.code) + self.assertEqual("invalid_application_policy", disabled.exception.code) + def test_canonical_create_and_delete_races_preserve_foreign_targets(self) -> None: with tempfile.TemporaryDirectory() as directory: parent = Path(directory) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 8c65dd2..56420f7 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -17,6 +17,7 @@ from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from mcp.shared.memory import create_connected_server_and_client_session +from docforge.application import GenericCanonicalApplier from docforge.changesets import ChangesetStore from docforge.errors import DocForgeError from docforge.index import ProjectIndex @@ -29,6 +30,7 @@ from docforge.mcp_server import ( SERVER_VERSION, DocForgeService, _create_bound_server, + create_project_server, create_server, ) from docforge.project import Project, project_root_fingerprint @@ -1446,6 +1448,83 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): self.assertEqual("Applied through the gated MCP tool.", workflow.summary) self.assertTrue((root / ".docforge/rendered/manual.html").is_file()) + async def test_project_server_accepts_an_explicit_contributor_without_granting_apply( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + descriptor = root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + + """ + +[[changesets.writers]] +id = "contributor" +families = ["guide"] +operations = ["update"] +""", + encoding="utf-8", + ) + project = Project.open(root) + ProjectIndex(project).build() + async with create_connected_server_and_client_session( + create_server( + root, + "contributor", + capability_mode="proposal", + ), + raise_exceptions=True, + ) as contributor: + contributor_tools = tuple( + tool.name for tool in (await contributor.list_tools()).tools + ) + registered = await contributor.call_tool( + "docforge_register_changes", + { + "changeset_id": "accepted-contribution", + "operations": [ + { + "operation": "update", + "node_id": "guide.workflow", + "metadata": {"summary": "Accepted through a separate applier."}, + "rationale": "Prove explicit contributor acceptance over MCP.", + } + ], + }, + ) + + async with create_connected_server_and_client_session( + create_project_server( + project, + proposal_writer="alpha-editor", + canonical_applier_id="alpha-editor", + canonical_applier=GenericCanonicalApplier(project), + accepted_proposal_writers=("contributor",), + capability_mode="application", + ), + raise_exceptions=True, + ) as developer: + contract = await developer.call_tool("docforge_get_contract", {}) + applied = await developer.call_tool( + "docforge_apply_changeset", + { + "changeset_id": "accepted-contribution", + "expected_changeset_hash": registered.structuredContent["changeset_hash"], + }, + ) + + self.assertEqual(ALL_TOOLS, contributor_tools) + self.assertNotIn("docforge_apply_changeset", contributor_tools) + self.assertEqual( + ["contributor"], + contract.structuredContent["canonical_application_access"][ + "accepted_proposal_writers" + ], + ) + self.assertTrue(applied.structuredContent["applied"]) + self.assertEqual("contributor", applied.structuredContent["proposal_creator"]) + self.assertEqual("alpha-editor", applied.structuredContent["applied_by"]) + async def test_stdio_transport_serves_the_same_project_bound_contract(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("beta", Path(directory))