1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add explicit cross-identity proposal acceptance

This commit is contained in:
Andraxion 2026-08-01 02:53:15 -04:00
parent 377cca0531
commit 7b21541ab3
10 changed files with 247 additions and 8 deletions

View file

@ -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`

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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]] = []

View file

@ -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,
)

View file

@ -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,

View file

@ -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)

View file

@ -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))