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
|
## 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
|
## 1.4.0 - 2026-07-29
|
||||||
|
|
||||||
Version `1.4.0` is the first additive DocForge2 successor release. Annotated tag `v1.4.0`
|
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`.
|
- 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
|
- MCP registers `docforge_apply_changeset` only when the server starts with an explicit canonical
|
||||||
applier identity and compatible applier implementation.
|
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
|
- The exact final changeset hash is required. Any proposal mutation invalidates an earlier
|
||||||
approval.
|
approval.
|
||||||
|
|
||||||
|
|
@ -33,6 +37,7 @@ The application boundary requires:
|
||||||
|
|
||||||
- exact changeset-hash approval;
|
- exact changeset-hash approval;
|
||||||
- startup-bound applier identity;
|
- startup-bound applier identity;
|
||||||
|
- an explicit accepted-writer allowlist for any cross-identity application;
|
||||||
- project-owned serializers for custom adapters;
|
- project-owned serializers for custom adapters;
|
||||||
- canonical path and symbolic-link confinement;
|
- canonical path and symbolic-link confinement;
|
||||||
- rollback and semantic round-trip verification;
|
- 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
|
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
|
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
|
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
|
## Isolated proposal tools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,10 @@ Capability modes are:
|
||||||
- `operator`: reserved; it currently adds no tools.
|
- `operator`: reserved; it currently adds no tools.
|
||||||
|
|
||||||
Mode describes the maximum registered surface. Actual authority can be narrower. A descriptor must
|
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
|
declare the selected writer, including allowed families and operation types. Application defaults
|
||||||
the matching configured writer, changeset creator, and canonical-applier identity. A mode name
|
to a matching configured writer, changeset creator, and canonical-applier identity. A project-owned
|
||||||
cannot create a missing descriptor grant.
|
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
|
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
|
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
|
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,
|
Call `docforge_bootstrap` first. Its version-1 `session_contract` contains the fixed binding,
|
||||||
current graph generation, effective policy, actual capabilities, render policies, prohibitions,
|
current graph generation, effective policy, actual capabilities, render policies, prohibitions,
|
||||||
|
|
|
||||||
|
|
@ -1204,12 +1204,39 @@ class CanonicalApplicationService:
|
||||||
*,
|
*,
|
||||||
applier_id: str | None,
|
applier_id: str | None,
|
||||||
applier: CanonicalApplier | None,
|
applier: CanonicalApplier | None,
|
||||||
|
accepted_proposal_writers: tuple[str, ...] = (),
|
||||||
index: ProjectIndex | None = None,
|
index: ProjectIndex | None = None,
|
||||||
manual_policy: ManualProjectionMode = "auto",
|
manual_policy: ManualProjectionMode = "auto",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.project = project
|
self.project = project
|
||||||
self.applier_id = applier_id
|
self.applier_id = applier_id
|
||||||
self.applier = applier
|
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.changesets = ChangesetStore(project, applier_id)
|
||||||
self.index = index or ProjectIndex(project)
|
self.index = index or ProjectIndex(project)
|
||||||
self.manual_policy = validate_manual_projection_mode(manual_policy)
|
self.manual_policy = validate_manual_projection_mode(manual_policy)
|
||||||
|
|
@ -1227,6 +1254,9 @@ class CanonicalApplicationService:
|
||||||
return {
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"applier": self.applier_id if self.enabled else None,
|
"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]:
|
def apply(self, changeset_id: str, expected_changeset_hash: str) -> dict[str, object]:
|
||||||
|
|
@ -1239,6 +1269,7 @@ class CanonicalApplicationService:
|
||||||
changeset_id=changeset_id,
|
changeset_id=changeset_id,
|
||||||
expected_changeset_hash=expected_changeset_hash,
|
expected_changeset_hash=expected_changeset_hash,
|
||||||
applier_id=self.applier_id,
|
applier_id=self.applier_id,
|
||||||
|
accepted_creator_ids=frozenset(self.accepted_proposal_writers),
|
||||||
application=self.applier.apply,
|
application=self.applier.apply,
|
||||||
)
|
)
|
||||||
refresh_errors: list[dict[str, object]] = []
|
refresh_errors: list[dict[str, object]] = []
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,7 @@ class ChangesetStore:
|
||||||
changeset_id: str,
|
changeset_id: str,
|
||||||
expected_changeset_hash: str,
|
expected_changeset_hash: str,
|
||||||
applier_id: str,
|
applier_id: str,
|
||||||
|
accepted_creator_ids: frozenset[str] | None = None,
|
||||||
application: Callable[
|
application: Callable[
|
||||||
[ProjectSnapshot, ProjectSnapshot, tuple[Mapping[str, object], ...]],
|
[ProjectSnapshot, ProjectSnapshot, tuple[Mapping[str, object], ...]],
|
||||||
dict[str, object],
|
dict[str, object],
|
||||||
|
|
@ -628,13 +629,17 @@ class ChangesetStore:
|
||||||
expected=expected_changeset_hash,
|
expected=expected_changeset_hash,
|
||||||
actual=actual_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(
|
raise DocForgeError(
|
||||||
"changeset_owner_conflict",
|
"changeset_owner_conflict",
|
||||||
"Canonical applier does not own this changeset",
|
"Canonical applier is not authorized to accept this changeset creator",
|
||||||
changeset_id=changeset_id,
|
changeset_id=changeset_id,
|
||||||
owner=document["creator"],
|
owner=document["creator"],
|
||||||
applier=applier_id,
|
applier=applier_id,
|
||||||
|
accepted_creators=sorted(accepted_creators),
|
||||||
)
|
)
|
||||||
projected = ProjectSnapshot(
|
projected = ProjectSnapshot(
|
||||||
descriptor=snapshot.descriptor,
|
descriptor=snapshot.descriptor,
|
||||||
|
|
@ -680,6 +685,8 @@ class ChangesetStore:
|
||||||
lifecycle=lifecycle,
|
lifecycle=lifecycle,
|
||||||
applied_from_revision=snapshot.revision,
|
applied_from_revision=snapshot.revision,
|
||||||
applied_from_source_hash=snapshot.source_hash,
|
applied_from_source_hash=snapshot.source_hash,
|
||||||
|
proposal_creator=document["creator"],
|
||||||
|
applied_by=applier_id,
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ class DocForgeService:
|
||||||
*,
|
*,
|
||||||
canonical_applier_id: str | None = None,
|
canonical_applier_id: str | None = None,
|
||||||
canonical_applier: CanonicalApplier | None = None,
|
canonical_applier: CanonicalApplier | None = None,
|
||||||
|
accepted_proposal_writers: tuple[str, ...] = (),
|
||||||
context_provider: ContextProvider = compile_context,
|
context_provider: ContextProvider = compile_context,
|
||||||
tool_surface: tuple[str, ...] | None = None,
|
tool_surface: tuple[str, ...] | None = None,
|
||||||
binding_metadata: Mapping[str, object] | None = None,
|
binding_metadata: Mapping[str, object] | None = None,
|
||||||
|
|
@ -188,6 +189,7 @@ class DocForgeService:
|
||||||
self.project,
|
self.project,
|
||||||
applier_id=canonical_applier_id if application_enabled else None,
|
applier_id=canonical_applier_id if application_enabled else None,
|
||||||
applier=canonical_applier 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,
|
index=self.index,
|
||||||
manual_policy=self.projection_policy.manual,
|
manual_policy=self.projection_policy.manual,
|
||||||
)
|
)
|
||||||
|
|
@ -2108,6 +2110,7 @@ def create_project_server(
|
||||||
proposal_writer: str | None = None,
|
proposal_writer: str | None = None,
|
||||||
canonical_applier_id: str | None = None,
|
canonical_applier_id: str | None = None,
|
||||||
canonical_applier: CanonicalApplier | None = None,
|
canonical_applier: CanonicalApplier | None = None,
|
||||||
|
accepted_proposal_writers: tuple[str, ...] = (),
|
||||||
context_provider: ContextProvider = compile_context,
|
context_provider: ContextProvider = compile_context,
|
||||||
binding_metadata: Mapping[str, object] | None = None,
|
binding_metadata: Mapping[str, object] | None = None,
|
||||||
no_ast: bool = False,
|
no_ast: bool = False,
|
||||||
|
|
@ -2124,6 +2127,7 @@ def create_project_server(
|
||||||
proposal_writer,
|
proposal_writer,
|
||||||
canonical_applier_id=canonical_applier_id,
|
canonical_applier_id=canonical_applier_id,
|
||||||
canonical_applier=canonical_applier,
|
canonical_applier=canonical_applier,
|
||||||
|
accepted_proposal_writers=accepted_proposal_writers,
|
||||||
context_provider=context_provider,
|
context_provider=context_provider,
|
||||||
binding_metadata=binding_metadata,
|
binding_metadata=binding_metadata,
|
||||||
no_ast=no_ast,
|
no_ast=no_ast,
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,103 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
self.assertFalse((root / ".docforge/changesets/.state/update-race.json").exists())
|
self.assertFalse((root / ".docforge/changesets/.state/update-race.json").exists())
|
||||||
self.assertFalse(tuple(target.parent.glob(".docforge-apply-*")))
|
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:
|
def test_canonical_create_and_delete_races_preserve_foreign_targets(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
parent = Path(directory)
|
parent = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.shared.memory import create_connected_server_and_client_session
|
from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
|
||||||
|
from docforge.application import GenericCanonicalApplier
|
||||||
from docforge.changesets import ChangesetStore
|
from docforge.changesets import ChangesetStore
|
||||||
from docforge.errors import DocForgeError
|
from docforge.errors import DocForgeError
|
||||||
from docforge.index import ProjectIndex
|
from docforge.index import ProjectIndex
|
||||||
|
|
@ -29,6 +30,7 @@ from docforge.mcp_server import (
|
||||||
SERVER_VERSION,
|
SERVER_VERSION,
|
||||||
DocForgeService,
|
DocForgeService,
|
||||||
_create_bound_server,
|
_create_bound_server,
|
||||||
|
create_project_server,
|
||||||
create_server,
|
create_server,
|
||||||
)
|
)
|
||||||
from docforge.project import Project, project_root_fingerprint
|
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.assertEqual("Applied through the gated MCP tool.", workflow.summary)
|
||||||
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
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:
|
async def test_stdio_transport_serves_the_same_project_bound_contract(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture("beta", Path(directory))
|
root = self.copy_fixture("beta", Path(directory))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue