From 34cd5f74c1878af959d9a83c2aa4822370df72e2 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Wed, 29 Jul 2026 06:26:40 -0400 Subject: [PATCH] Add versioned effective policy --- DEVELOPMENT_NOTES.md | 44 ++++++ README.md | 5 + docs/COMPATIBILITY.md | 7 + docs/MCP_CONTRACT.md | 25 +++- docs/USER_MANUAL.md | 18 +++ schemas/policy.schema.json | 62 +++++++++ src/docforge/mcp_server.py | 246 +++++++++++++++++++++++++--------- src/docforge/policy.py | 166 +++++++++++++++++++++++ tests/test_mcp_server.py | 39 ++++++ tests/test_policy.py | 161 ++++++++++++++++++++++ tests/test_public_contract.py | 5 + 11 files changed, 708 insertions(+), 70 deletions(-) create mode 100644 schemas/policy.schema.json create mode 100644 src/docforge/policy.py create mode 100644 tests/test_policy.py diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md index d57fc78..e81adc2 100644 --- a/DEVELOPMENT_NOTES.md +++ b/DEVELOPMENT_NOTES.md @@ -344,3 +344,47 @@ These are notes, not commitments: - Cursor authentication remains deliberately absent. If read cursors ever carry authority rather than bounded positions, they will need a different versioned security contract and persisted key lifecycle. + +## Milestone 2 — active: agent retrieval and MCP experience + +### Audit reconciliation + +Three independent read-only audits covered effective policy and bootstrap, task-shaped retrieval +and context capsules, and generation diffs plus client configuration and doctor checks. + +They agreed on these boundaries: + +- Keep the project descriptor at schema version 1. Process capability and client configuration are + machine-specific bindings, not canonical project content. +- Preserve the legacy adapter-policy payload, no-AST shorthand, tool names, default tool ordering, + one-method adapters, and custom context provider. +- Add one versioned effective-policy authority and derive bootstrap, contract, instructions, and + access reporting from it. +- Add one task-context operation with a closed task-kind vocabulary and one immutable, + generation-pinned retrieval plan. Do not create a tool for every task kind. +- Produce evidence gaps only from declared plan requirements and completed bounded checks. Never + infer missing facts from arbitrary project naming. +- Record only the latest bounded generation transition as disposable evidence. Do not add a + history database. +- Preview client configuration by default. Any write must be explicit, atomic, merge-preserving, + and backed by a verified client-format driver. +- Keep doctor strictly read-only. It must not bootstrap, synchronize, build, render, start a + viewer, or rewrite client configuration. + +### Versioned effective policy and session contract + +The binding now composes an immutable version-1 policy containing capability mode, adapter +evolution, AST and Logic behavior, synchronization and integrity levels, render and viewer +behavior, profiling, blocked tools, prohibitions, and explicit precedence. `--no-ast` is a +restrictive override. The exact legacy `adapter_policy` response remains a projection of the new +object. + +Bootstrap reuses the identity already proven by synchronization and no longer reloads the complete +project. Its additive version-1 session contract reports binding, generation, effective policy, +actual registered surfaces and mutation access, render policies, first operation, filtered +workflow, and prohibitions. Read mode does not recommend proposals. Proposal mode recommends +registration and review only with writer access. Application is recommended only when the +exact-hash applier is enabled. + +Existing factory defaults and tool order remain unchanged. Explicit application mode fails closed +without an applier. Operator mode is reserved and currently adds no tools. diff --git a/README.md b/README.md index 1fe2f62..39c074e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ AST, Tree-sitter, compiler-AST, or function-Logic extraction, blocks the Logic t nonempty Logic publication. Complete-projection adapters continue unchanged, and non-AST incremental fingerprinting and caching remain allowed. +DocForge2 bindings may also declare +`--capability-mode read|proposal|application|operator`. Bootstrap returns one versioned effective +policy and the actual startup-gated capabilities. Existing tool surfaces and the legacy no-AST +payload remain compatible. + ## Graph views The browser presents the primary architecture graph through three complementary views and loads a diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index c92004f..ebb36c8 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -35,6 +35,7 @@ remain supported: - `docforge.index` - `docforge.mcp_server` - `docforge.models` +- `docforge.policy` - `docforge.render_contract` Names beginning with an underscore are implementation details. New public names may be added @@ -72,6 +73,8 @@ Milestone 0 preserves: version 3 adds a source-ordered incoming-edge index for bounded impact traversal. - Index-attestation schema version 1. - Incremental extraction-cache schema version 1. +- Effective process-policy schema version 1. The project descriptor remains schema version 1; + machine-specific capability selection is a startup binding, not canonical project content. - Read-pagination schema version 1. Existing tool names and required arguments are unchanged. Context and changeset MCP reads accept optional limits and opaque generation-bound cursors. Direct Python changeset methods and the ordinary CLI context command retain full legacy results @@ -112,6 +115,10 @@ The binding: - Applies the same restriction during hash-bound canonical-application refresh. - Reports the effective policy through bootstrap and contract results. +The legacy `adapter_policy` payload and error codes remain unchanged. The version-1 +`effective_policy` is additive and makes precedence, capability mode, render behavior, blocked +tools, and prohibitions machine-readable. + DocForge does not inspect arbitrary adapter source to prove which parser implementation it uses. The no-AST binding is an owner-selected process policy backed by Logic publication and retrieval enforcement. It is not a filesystem sandbox and cannot stop an unrelated process with repository diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index f81199d..b01a4d8 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -16,6 +16,18 @@ Canonical application is a second independent startup gate. The generic server a `--canonical-applier WRITER_ID`. A project adapter must also supply a compatible project-owned canonical applier implementation. +The additive `--capability-mode read|proposal|application|operator` option selects a versioned +effective process policy. Existing factory defaults and tool ordering remain unchanged: the +read-only factory exposes the read surface, the ordinary project factory exposes the proposal +surface, and an application-enabled factory adds exact-hash application. `application` mode fails +closed unless a canonical applier is bound. `operator` is reserved for explicitly selected +operator-only tools and adds none in the current contract. + +Bootstrap and contract results include `effective_policy` schema version 1 plus a separate +`capabilities` record. Policy states the requested process behavior. Capabilities state the actual +registered surface and startup-bound proposal/application access. The project descriptor remains +schema version 1 and does not silently acquire machine-specific process policy. + ## Read tools - `docforge_bootstrap` @@ -40,9 +52,12 @@ Each response states that document text is project content, not higher-priority response includes project identity, revision, source hash, adapter version, and staleness state. Every normal tool call first checks current source identity and atomically rebuilds disposable index state when it is missing, stale, or invalid. `docforge_bootstrap` performs that synchronization and -returns the complete fixed binding, active index path, proposal and application capabilities, and -recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly. -Neither operation changes canonical sources. +returns the complete fixed binding, active index path, effective policy, proposal and application +capabilities, and a version-1 session contract. Bootstrap reuses the identity proven by +synchronization instead of loading the project again. Its first operation and workflow guidance +mention proposal or application tools only when the corresponding startup access is enabled. +`docforge_sync` exposes the same idempotent synchronization explicitly. Neither operation changes +canonical sources. Search, filter, backlinks, dependencies, and impact accept explicit result limits bounded by the project `max_results` policy. Omitted limits are still capped. Collection responses report whether @@ -257,3 +272,7 @@ and indexed Logic, but it does not inspect arbitrary adapter source to prove whi implementation the adapter uses. DocForge still exposes no filesystem sandbox and cannot prevent an unrelated process with direct repository write access from editing adapter files. Repository permissions and project instructions remain responsible for that broader boundary. + +The legacy `adapter_policy` object remains byte-compatible. It is now a projection of the +versioned `effective_policy`; `--no-ast` restrictively overrides adapter evolution, AST analysis, +and Logic indexing without widening any other capability. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index f161c56..166e477 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -513,6 +513,19 @@ docforge-mcp \ Omit `--proposal-writer` when the MCP client should not create or append proposals. +Select the session's declared surface explicitly when useful: + +```bash +docforge-mcp \ + --project-root /absolute/path/MyProject \ + --capability-mode read +``` + +Supported modes are `read`, `proposal`, `application`, and `operator`. Existing startup defaults +remain compatible. Capability mode describes the registered surface; bootstrap separately reports +whether a configured writer or applier actually grants mutation access. Application mode refuses +startup without a canonical applier. Operator mode is reserved and currently adds no tools. + Add `--diagnostics` when profiling a development or benchmark session. Each MCP response then includes bounded stage timings and compiler-work counters. The same flag is available on `docforge`. Diagnostics are disabled by default, record no project content or paths, and never @@ -530,6 +543,11 @@ 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. +Call `docforge_bootstrap` first. Its version-1 `session_contract` contains the fixed binding, +current graph generation, effective policy, actual capabilities, render policies, prohibitions, +and a recommended first operation. Workflow guidance does not recommend registration or +application when those startup capabilities are unavailable. + Example MCP client configuration: ```json diff --git a/schemas/policy.schema.json b/schemas/policy.schema.json new file mode 100644 index 0000000..4260a32 --- /dev/null +++ b/schemas/policy.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/policy-v1.json", + "title": "DocForge effective process policy", + "type": "object", + "required": [ + "schema_version", + "capability_mode", + "capability_source", + "adapter_evolution", + "ast_analysis", + "logic_indexing", + "synchronization", + "integrity", + "manual_render", + "graph_render", + "live_viewer", + "profiling", + "blocked_tools", + "prohibitions", + "precedence" + ], + "properties": { + "schema_version": { "const": 1 }, + "capability_mode": { + "enum": ["read", "proposal", "application", "operator"] + }, + "capability_source": { + "enum": ["factory_default", "explicit"] + }, + "adapter_evolution": { "enum": ["allowed", "preserve"] }, + "ast_analysis": { "enum": ["allowed", "forbidden"] }, + "logic_indexing": { "enum": ["full", "off"] }, + "synchronization": { "const": "automatic" }, + "integrity": { "const": "validated" }, + "manual_render": { "enum": ["auto", "explicit", "disabled"] }, + "graph_render": { "const": "disabled" }, + "live_viewer": { "const": "on-demand" }, + "profiling": { "enum": ["enabled", "disabled"] }, + "blocked_tools": { + "type": "array", + "maxItems": 64, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "prohibitions": { + "type": "array", + "maxItems": 64, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "precedence": { + "const": [ + "core_safety", + "explicit_binding", + "no_ast_shorthand", + "resource_availability" + ] + } + }, + "additionalProperties": false +} diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index e4c721a..44a1b95 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -18,6 +18,7 @@ from .errors import DocForgeError from .index import ProjectIndex from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt +from .policy import CapabilityMode, capability_mode, compose_effective_policy from .project import Project, project_root_fingerprint from .rendering import RenderService from .telemetry import request, stage @@ -129,50 +130,87 @@ class DocForgeService: binding_metadata: Mapping[str, object] | None = None, no_ast: bool = False, diagnostics: bool = False, + capability_mode_name: str | None = None, ) -> None: self.project = project - self.index = ProjectIndex(self.project, allow_logic=not no_ast) - self.changesets = ChangesetStore(self.project, proposal_writer) + default_mode: CapabilityMode = ( + "application" if canonical_applier is not None else "proposal" + ) + selected_mode = capability_mode(capability_mode_name, default=default_mode) + application_enabled = ( + canonical_applier_id is not None + and canonical_applier is not None + and selected_mode in {"application", "operator"} + ) + self.policy = compose_effective_policy( + selected_mode=selected_mode, + capability_source=("factory_default" if capability_mode_name is None else "explicit"), + no_ast=no_ast, + diagnostics=diagnostics, + render_configured=project.descriptor.render is not None, + application_enabled=application_enabled, + ) + self.index = ProjectIndex(self.project, allow_logic=not self.policy.no_ast) + self.changesets = ChangesetStore( + self.project, + proposal_writer if selected_mode != "read" else None, + ) self.rendering = RenderService(self.project, self.changesets) self.application = CanonicalApplicationService( self.project, - applier_id=canonical_applier_id, - applier=canonical_applier, + applier_id=canonical_applier_id if application_enabled else None, + applier=canonical_applier if application_enabled else None, index=self.index, ) self.visualization = ViewerManagerClient(self.index) self.context_provider = context_provider self.binding_metadata = dict(binding_metadata or {}) - self.no_ast = no_ast + self.no_ast = self.policy.no_ast self.diagnostics = diagnostics - self.tool_surface = tool_surface or ( - *ALL_TOOLS, - *(APPLICATION_TOOLS if self.application.enabled else ()), + default_surface = ( + READ_TOOLS + if selected_mode == "read" + else ( + *ALL_TOOLS, + *( + APPLICATION_TOOLS + if self.application.enabled and selected_mode in {"application", "operator"} + else () + ), + ) ) + self.tool_surface = tool_surface or default_surface def adapter_policy(self) -> dict[str, object]: """Return the immutable adapter-evolution policy for this MCP binding.""" - if not self.no_ast: - return { - "mode": "standard", - "ast_analysis": "allowed", - "logic_projection": "allowed", - "incremental_extraction": "allowed", - "adapter_rewrite": "not_requested", - } + return self.policy.adapter_policy() + + def capabilities(self) -> dict[str, object]: + """Return the registered surfaces separately from startup-bound authority.""" + + proposal_access = self.changesets.access() + application_access = self.application.access() return { - "mode": "preserve-no-ast", - "ast_analysis": "forbidden", - "logic_projection": "forbidden", - "incremental_extraction": "allowed", - "adapter_rewrite": "forbidden", - "blocked_tools": ["docforge_get_logic"], - "instruction": ( - "Preserve the existing adapter extraction strategy. Do not add Python AST, " - "Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental " - "fingerprinting and caching remain allowed." - ), + "schema_version": 1, + "mode": self.policy.capability_mode, + "registered_tools": list(self.tool_surface), + "read": { + "enabled": True, + "tools": [tool for tool in READ_TOOLS if tool in self.tool_surface], + }, + "proposal": { + "surface_enabled": any(tool in self.tool_surface for tool in PROPOSAL_TOOLS), + "mutation_access": proposal_access, + }, + "application": { + "surface_enabled": any(tool in self.tool_surface for tool in APPLICATION_TOOLS), + "mutation_access": application_access, + }, + "operator": { + "enabled": self.policy.capability_mode == "operator", + "tools": [], + }, } def invoke( @@ -507,15 +545,15 @@ class DocForgeService: def bootstrap(self) -> dict[str, object]: def operation() -> dict[str, object]: synchronized = self.index.synchronize() - snapshot = self.project.load() - root = snapshot.descriptor.root + descriptor = self.project.descriptor + root = descriptor.root binding = { "project_root": str(root), - "descriptor_path": str(snapshot.descriptor.descriptor_path), - "adapter": snapshot.descriptor.adapter, - "cache_root": str(snapshot.descriptor.cache_root), - "index_path": str(snapshot.descriptor.index_path), - "changeset_root": str(snapshot.descriptor.changeset_root), + "descriptor_path": str(descriptor.descriptor_path), + "adapter": descriptor.adapter, + "cache_root": str(descriptor.cache_root), + "index_path": str(descriptor.index_path), + "changeset_root": str(descriptor.changeset_root), **self.binding_metadata, "adapter_policy": self.adapter_policy(), } @@ -523,11 +561,19 @@ class DocForgeService: "docforge_get_context or targeted read tools", "make and verify one coherent implementation slice", "docforge_sync", - "docforge_register_changes", - "docforge_get_changeset_diff", - "docforge_apply_changeset", - "docforge_bootstrap", ] + proposal_access = self.changesets.access() + application_access = self.application.access() + if proposal_access["enabled"] and "docforge_register_changes" in self.tool_surface: + recommended_workflow.extend( + ( + "docforge_register_changes", + "docforge_get_changeset_diff", + ) + ) + if application_access["enabled"] and "docforge_apply_changeset" in self.tool_surface: + recommended_workflow.append("docforge_apply_changeset") + recommended_workflow.append("docforge_bootstrap") if self.no_ast: recommended_workflow.insert( 1, @@ -536,19 +582,55 @@ class DocForgeService: "compiler-AST, or function-Logic extraction" ), ) + if descriptor.profiles: + recommended_first_operation: dict[str, object] = { + "tool": "docforge_get_context", + "arguments": {"profile": descriptor.profiles[0].profile_id}, + "reason": "Begin with one configured bounded context profile.", + } + else: + recommended_first_operation = { + "tool": "docforge_project_info", + "arguments": dict[str, object](), + "reason": "Confirm the fixed binding before targeted retrieval.", + } + capabilities = self.capabilities() + effective_policy = self.policy.as_dict() + session_contract: dict[str, object] = { + "schema_version": 1, + "binding": binding, + "generation": { + "revision": synchronized["revision"], + "source_hash": synchronized["source_hash"], + "freshness": "current", + }, + "effective_policy": effective_policy, + "capabilities": capabilities, + "render_policies": { + "manual": effective_policy["manual_render"], + "graph": effective_policy["graph_render"], + "live_viewer": effective_policy["live_viewer"], + }, + "recommended_first_operation": recommended_first_operation, + "recommended_workflow": recommended_workflow, + "prohibitions": effective_policy["prohibitions"], + } return { "status": "ok", - "project_id": snapshot.descriptor.project_id, + "project_id": descriptor.project_id, "project_root_fingerprint": project_root_fingerprint(root), - "title": snapshot.descriptor.title, - "adapter": snapshot.descriptor.adapter, - "revision": snapshot.revision, - "source_hash": snapshot.source_hash, + "title": descriptor.title, + "adapter": descriptor.adapter, + "revision": synchronized["revision"], + "source_hash": synchronized["source_hash"], "binding": binding, - "canonical_paths": [str(path) for path in snapshot.descriptor.content_roots], + "canonical_paths": [str(path) for path in descriptor.content_roots], "adapter_policy": self.adapter_policy(), - "proposal_access": self.changesets.access(), - "canonical_application_access": self.application.access(), + "effective_policy": effective_policy, + "capabilities": capabilities, + "session_contract": session_contract, + "proposal_access": proposal_access, + "canonical_application_access": application_access, "synchronization": synchronized["synchronization"], "recommended_workflow": recommended_workflow, } @@ -606,6 +688,8 @@ class DocForgeService: "Canonical project files own facts; DocForge results are derived." ), "adapter_policy": self.adapter_policy(), + "effective_policy": self.policy.as_dict(), + "capabilities": self.capabilities(), "canonical_paths": [ *(relative(path) for path in snapshot.descriptor.content_roots), *(relative(path) for path in snapshot.descriptor.authority_files), @@ -638,14 +722,20 @@ class DocForgeService: ], "allowed_tools": list(self.tool_surface), "excluded_operations": list( - EXCLUDED_OPERATIONS - + ( - ("canonical_writes", "canonical_changeset_application") - if not self.application.enabled - else () + dict.fromkeys( + EXCLUDED_OPERATIONS + + ( + ("canonical_writes", "canonical_changeset_application") + if not self.application.enabled + else () + ) + + ( + READ_ONLY_EXCLUDED_OPERATIONS + if self.policy.capability_mode == "read" + else () + ) + + tuple(self.policy.prohibitions) ) - + (READ_ONLY_EXCLUDED_OPERATIONS if self.tool_surface == READ_TOOLS else ()) - + (("adapter_ast_upgrade", "function_logic_extraction") if self.no_ast else ()) ), "proposal_access": self.changesets.access(), "canonical_application_access": self.application.access(), @@ -907,19 +997,20 @@ class DocForgeService: def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMCP: - capability = ( - "Read validated documentation for exactly one configured project." - if read_only - else ( - "Read validated documentation and write isolated proposal changesets and previews for " - "exactly one configured project" - + ( - ", with hash-bound canonical application enabled." - if service.application.enabled - else "." - ) - ) - ) + capability = { + "read": "Read validated documentation for exactly one configured project.", + "proposal": ( + "Read validated documentation and use startup-gated isolated proposal changesets and " + "previews for exactly one configured project." + ), + "application": ( + "Read validated documentation, use startup-gated isolated proposals, and apply one " + "exact validated changeset hash for exactly one configured project." + ), + "operator": ( + "Operate the fixed validated documentation binding for exactly one configured project." + ), + }[service.policy.capability_mode] server = FastMCP( "DocForge", instructions=( @@ -1492,6 +1583,7 @@ def create_server( canonical_applier_id: str | None = None, no_ast: bool = False, diagnostics: bool = False, + capability_mode: str | None = None, ) -> FastMCP: project = Project.open(project_root) return create_project_server( @@ -1507,6 +1599,7 @@ def create_server( }, no_ast=no_ast, diagnostics=diagnostics, + capability_mode=capability_mode, ) @@ -1520,6 +1613,7 @@ def create_project_server( binding_metadata: Mapping[str, object] | None = None, no_ast: bool = False, diagnostics: bool = False, + capability_mode: str | None = None, ) -> FastMCP: """Create the full fixed MCP surface for one explicitly configured project service.""" @@ -1532,8 +1626,12 @@ def create_project_server( binding_metadata=binding_metadata, no_ast=no_ast, diagnostics=diagnostics, + capability_mode_name=capability_mode, + ) + return _create_bound_server( + service, + read_only=service.policy.capability_mode == "read", ) - return _create_bound_server(service, read_only=False) def create_read_only_server( @@ -1543,6 +1641,7 @@ def create_read_only_server( binding_metadata: Mapping[str, object] | None = None, no_ast: bool = False, diagnostics: bool = False, + capability_mode: str | None = None, ) -> FastMCP: """Create an adapter-capable MCP server exposing only the fixed read tool surface.""" @@ -1553,7 +1652,14 @@ def create_read_only_server( binding_metadata=binding_metadata, no_ast=no_ast, diagnostics=diagnostics, + capability_mode_name="read" if capability_mode is None else capability_mode, ) + if service.policy.capability_mode != "read": + raise DocForgeError( + "invalid_capability_mode", + "Read-only server factory accepts only read capability mode", + capability_mode=service.policy.capability_mode, + ) return _create_bound_server(service, read_only=True) @@ -1575,6 +1681,11 @@ def main() -> None: action="store_true", help="Attach bounded request-local stage timings and counters", ) + parser.add_argument( + "--capability-mode", + choices=("read", "proposal", "application", "operator"), + help="Expose the versioned project-bound capability surface", + ) arguments = parser.parse_args() create_server( arguments.project_root, @@ -1582,6 +1693,7 @@ def main() -> None: canonical_applier_id=arguments.canonical_applier, no_ast=arguments.no_ast, diagnostics=arguments.diagnostics, + capability_mode=arguments.capability_mode, ).run(transport="stdio") diff --git a/src/docforge/policy.py b/src/docforge/policy.py new file mode 100644 index 0000000..c8b0009 --- /dev/null +++ b/src/docforge/policy.py @@ -0,0 +1,166 @@ +"""Versioned immutable policy composition for one project-bound server.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .errors import DocForgeError + +CapabilityMode = Literal["read", "proposal", "application", "operator"] +CAPABILITY_MODES: tuple[CapabilityMode, ...] = ( + "read", + "proposal", + "application", + "operator", +) + +POLICY_PRECEDENCE = ( + "core_safety", + "explicit_binding", + "no_ast_shorthand", + "resource_availability", +) + + +def capability_mode(value: str | None, *, default: CapabilityMode) -> CapabilityMode: + """Validate one additive capability-mode selection.""" + + selected = default if value is None else value + if selected not in CAPABILITY_MODES: + raise DocForgeError( + "invalid_capability_mode", + "Capability mode is unsupported", + capability_mode=selected, + allowed=list(CAPABILITY_MODES), + ) + return selected # type: ignore[return-value] + + +@dataclass(frozen=True) +class EffectivePolicyV1: + """One fully composed process policy shared by every public projection.""" + + capability_mode: CapabilityMode + capability_source: Literal["factory_default", "explicit"] + adapter_evolution: Literal["allowed", "preserve"] + ast_analysis: Literal["allowed", "forbidden"] + logic_indexing: Literal["full", "off"] + synchronization: Literal["automatic"] + integrity: Literal["validated"] + manual_render: Literal["auto", "explicit", "disabled"] + graph_render: Literal["disabled"] + live_viewer: Literal["on-demand"] + profiling: Literal["enabled", "disabled"] + blocked_tools: tuple[str, ...] + prohibitions: tuple[str, ...] + + @property + def no_ast(self) -> bool: + return self.ast_analysis == "forbidden" + + def as_dict(self) -> dict[str, object]: + return { + "schema_version": 1, + "capability_mode": self.capability_mode, + "capability_source": self.capability_source, + "adapter_evolution": self.adapter_evolution, + "ast_analysis": self.ast_analysis, + "logic_indexing": self.logic_indexing, + "synchronization": self.synchronization, + "integrity": self.integrity, + "manual_render": self.manual_render, + "graph_render": self.graph_render, + "live_viewer": self.live_viewer, + "profiling": self.profiling, + "blocked_tools": list(self.blocked_tools), + "prohibitions": list(self.prohibitions), + "precedence": list(POLICY_PRECEDENCE), + } + + def adapter_policy(self) -> dict[str, object]: + """Preserve the exact legacy adapter-policy projection.""" + + if not self.no_ast: + return { + "mode": "standard", + "ast_analysis": "allowed", + "logic_projection": "allowed", + "incremental_extraction": "allowed", + "adapter_rewrite": "not_requested", + } + return { + "mode": "preserve-no-ast", + "ast_analysis": "forbidden", + "logic_projection": "forbidden", + "incremental_extraction": "allowed", + "adapter_rewrite": "forbidden", + "blocked_tools": ["docforge_get_logic"], + "instruction": ( + "Preserve the existing adapter extraction strategy. Do not add Python AST, " + "Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental " + "fingerprinting and caching remain allowed." + ), + } + + +def compose_effective_policy( + *, + selected_mode: CapabilityMode, + capability_source: Literal["factory_default", "explicit"], + no_ast: bool, + diagnostics: bool, + render_configured: bool, + application_enabled: bool, +) -> EffectivePolicyV1: + """Compose fixed defaults with restrictive compatibility shorthands.""" + + if selected_mode == "application" and not application_enabled: + raise DocForgeError( + "capability_unavailable", + "Application capability requires a startup-bound canonical applier", + capability_mode=selected_mode, + required="canonical_applier", + ) + prohibitions = [ + "arbitrary_file_access", + "arbitrary_renderer_execution", + "shell_execution", + "git_mutation", + "deployment", + "publication", + "project_switching", + ] + blocked_tools: tuple[str, ...] = () + if no_ast: + prohibitions.extend( + ( + "adapter_ast_upgrade", + "tree_sitter_upgrade", + "compiler_ast_upgrade", + "function_logic_extraction", + ) + ) + blocked_tools = ("docforge_get_logic",) + manual_render: Literal["auto", "explicit", "disabled"] + if not render_configured: + manual_render = "disabled" + elif application_enabled and selected_mode in {"application", "operator"}: + manual_render = "auto" + else: + manual_render = "explicit" + return EffectivePolicyV1( + capability_mode=selected_mode, + capability_source=capability_source, + adapter_evolution="preserve" if no_ast else "allowed", + ast_analysis="forbidden" if no_ast else "allowed", + logic_indexing="off" if no_ast else "full", + synchronization="automatic", + integrity="validated", + manual_render=manual_render, + graph_render="disabled", + live_viewer="on-demand", + profiling="enabled" if diagnostics else "disabled", + blocked_tools=blocked_tools, + prohibitions=tuple(prohibitions), + ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b945b29..005114b 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -17,12 +17,14 @@ from mcp.client.stdio import stdio_client from mcp.shared.memory import create_connected_server_and_client_session from docforge.changesets import ChangesetStore +from docforge.errors import DocForgeError from docforge.index import ProjectIndex from docforge.mcp_server import ( ALL_TOOLS, APPLICATION_TOOLS, CONTENT_WARNING, PROPOSAL_TOOLS, + READ_TOOLS, DocForgeService, _create_bound_server, create_server, @@ -103,6 +105,43 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): ) ) + async def test_explicit_capability_modes_preserve_surfaces_and_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + ProjectIndex(Project.open(root)).build() + async with create_connected_server_and_client_session( + create_server(root, capability_mode="read"), + raise_exceptions=True, + ) as session: + read_names = tuple(tool.name for tool in (await session.list_tools()).tools) + read_bootstrap = await session.call_tool("docforge_bootstrap", {}) + + self.assertEqual(READ_TOOLS, read_names) + self.assertEqual( + "read", + read_bootstrap.structuredContent["effective_policy"]["capability_mode"], + ) + self.assertNotIn( + "docforge_register_changes", + read_bootstrap.structuredContent["recommended_workflow"], + ) + + async with create_connected_server_and_client_session( + create_server( + root, + "alpha-editor", + canonical_applier_id="alpha-editor", + capability_mode="application", + ), + raise_exceptions=True, + ) as session: + application_names = tuple(tool.name for tool in (await session.list_tools()).tools) + self.assertEqual((*ALL_TOOLS, *APPLICATION_TOOLS), application_names) + + with self.assertRaises(DocForgeError) as unavailable: + create_server(root, capability_mode="application") + self.assertEqual("capability_unavailable", unavailable.exception.code) + async def test_factory_diagnostics_are_additive_through_real_mcp(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..9d7a334 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from jsonschema import Draft202012Validator + +from docforge.application import GenericCanonicalApplier +from docforge.errors import DocForgeError +from docforge.index import ProjectIndex +from docforge.mcp_server import ( + ALL_TOOLS, + APPLICATION_TOOLS, + READ_TOOLS, + DocForgeService, +) +from docforge.policy import ( + POLICY_PRECEDENCE, + capability_mode, + compose_effective_policy, +) +from docforge.project import Project + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" +POLICY_SCHEMA = json.loads((ROOT / "schemas" / "policy.schema.json").read_text(encoding="utf-8")) + + +class EffectivePolicyTests(unittest.TestCase): + def copy_fixture(self, destination: Path) -> Path: + root = destination / "alpha" + shutil.copytree(FIXTURES / "alpha", root) + return root + + def test_policy_schema_and_legacy_adapter_projection_are_exact(self) -> None: + standard = compose_effective_policy( + selected_mode="proposal", + capability_source="factory_default", + no_ast=False, + diagnostics=False, + render_configured=True, + application_enabled=False, + ) + preserve = compose_effective_policy( + selected_mode="read", + capability_source="explicit", + no_ast=True, + diagnostics=True, + render_configured=False, + application_enabled=False, + ) + validator = Draft202012Validator(POLICY_SCHEMA) + validator.validate(standard.as_dict()) + validator.validate(preserve.as_dict()) + self.assertEqual(list(POLICY_PRECEDENCE), preserve.as_dict()["precedence"]) + self.assertEqual( + { + "mode": "standard", + "ast_analysis": "allowed", + "logic_projection": "allowed", + "incremental_extraction": "allowed", + "adapter_rewrite": "not_requested", + }, + standard.adapter_policy(), + ) + self.assertEqual("preserve-no-ast", preserve.adapter_policy()["mode"]) + self.assertEqual(["docforge_get_logic"], preserve.adapter_policy()["blocked_tools"]) + self.assertEqual("off", preserve.as_dict()["logic_indexing"]) + self.assertEqual("enabled", preserve.as_dict()["profiling"]) + + def test_invalid_or_unavailable_capability_fails_closed(self) -> None: + with self.assertRaises(DocForgeError) as invalid: + capability_mode("admin", default="read") + self.assertEqual("invalid_capability_mode", invalid.exception.code) + with self.assertRaises(DocForgeError) as unavailable: + compose_effective_policy( + selected_mode="application", + capability_source="explicit", + no_ast=False, + diagnostics=False, + render_configured=True, + application_enabled=False, + ) + self.assertEqual("capability_unavailable", unavailable.exception.code) + + def test_modes_preserve_default_surfaces_and_narrow_authority(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + + read = DocForgeService( + project, + proposal_writer="alpha-editor", + capability_mode_name="read", + ) + proposal = DocForgeService( + project, + proposal_writer="alpha-editor", + capability_mode_name="proposal", + ) + application = DocForgeService( + project, + proposal_writer="alpha-editor", + canonical_applier_id="alpha-editor", + canonical_applier=GenericCanonicalApplier(project), + capability_mode_name="application", + ) + operator = DocForgeService( + project, + proposal_writer="alpha-editor", + capability_mode_name="operator", + ) + + self.assertEqual(READ_TOOLS, read.tool_surface) + self.assertFalse(read.changesets.access()["enabled"]) + self.assertEqual(ALL_TOOLS, proposal.tool_surface) + self.assertTrue(proposal.changesets.access()["enabled"]) + self.assertFalse(proposal.application.enabled) + self.assertEqual((*ALL_TOOLS, *APPLICATION_TOOLS), application.tool_surface) + self.assertTrue(application.application.enabled) + self.assertEqual(ALL_TOOLS, operator.tool_surface) + self.assertTrue(operator.capabilities()["operator"]["enabled"]) + + def test_bootstrap_reuses_synchronized_identity_and_filters_workflow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + ProjectIndex(project).build() + read = DocForgeService(project, capability_mode_name="read") + with mock.patch.object( + project, + "load", + side_effect=AssertionError("bootstrap must not reload after synchronization"), + ): + result = read.bootstrap() + + self.assertEqual("ok", result["status"]) + self.assertEqual(1, result["session_contract"]["schema_version"]) + self.assertEqual("read", result["effective_policy"]["capability_mode"]) + self.assertEqual( + result["effective_policy"], result["session_contract"]["effective_policy"] + ) + self.assertNotIn("docforge_register_changes", result["recommended_workflow"]) + self.assertNotIn("docforge_apply_changeset", result["recommended_workflow"]) + self.assertEqual( + "docforge_get_context", + result["session_contract"]["recommended_first_operation"]["tool"], + ) + + proposal = DocForgeService( + project, + proposal_writer="alpha-editor", + capability_mode_name="proposal", + ).bootstrap() + self.assertIn("docforge_register_changes", proposal["recommended_workflow"]) + self.assertIn("docforge_get_changeset_diff", proposal["recommended_workflow"]) + self.assertNotIn("docforge_apply_changeset", proposal["recommended_workflow"]) diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 6524824..cb2beb3 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -77,6 +77,11 @@ PUBLIC_IMPORTS = { "ProjectService", "ProjectSnapshot", ), + "docforge.policy": ( + "EffectivePolicyV1", + "capability_mode", + "compose_effective_policy", + ), "docforge.render_contract": ( "GenericHtmlRenderer", "PreparedRender",