Add explicit cross-identity proposal acceptance
This commit is contained in:
parent
377cca0531
commit
7b21541ab3
10 changed files with 247 additions and 8 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue