Add versioned effective policy
This commit is contained in:
parent
a9a75c5c27
commit
34cd5f74c1
11 changed files with 708 additions and 70 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
schemas/policy.schema.json
Normal file
62
schemas/policy.schema.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
default_surface = (
|
||||
READ_TOOLS
|
||||
if selected_mode == "read"
|
||||
else (
|
||||
*ALL_TOOLS,
|
||||
*(APPLICATION_TOOLS if self.application.enabled else ()),
|
||||
*(
|
||||
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 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": "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."
|
||||
),
|
||||
"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",
|
||||
]
|
||||
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",
|
||||
"docforge_apply_changeset",
|
||||
"docforge_bootstrap",
|
||||
]
|
||||
)
|
||||
)
|
||||
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(
|
||||
dict.fromkeys(
|
||||
EXCLUDED_OPERATIONS
|
||||
+ (
|
||||
("canonical_writes", "canonical_changeset_application")
|
||||
if not self.application.enabled
|
||||
else ()
|
||||
)
|
||||
+ (READ_ONLY_EXCLUDED_OPERATIONS if self.tool_surface == READ_TOOLS else ())
|
||||
+ (("adapter_ast_upgrade", "function_logic_extraction") if self.no_ast else ())
|
||||
+ (
|
||||
READ_ONLY_EXCLUDED_OPERATIONS
|
||||
if self.policy.capability_mode == "read"
|
||||
else ()
|
||||
)
|
||||
+ tuple(self.policy.prohibitions)
|
||||
)
|
||||
),
|
||||
"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,6 +1652,13 @@ 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")
|
||||
|
||||
|
||||
|
|
|
|||
166
src/docforge/policy.py
Normal file
166
src/docforge/policy.py
Normal file
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
161
tests/test_policy.py
Normal file
161
tests/test_policy.py
Normal file
|
|
@ -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"])
|
||||
|
|
@ -77,6 +77,11 @@ PUBLIC_IMPORTS = {
|
|||
"ProjectService",
|
||||
"ProjectSnapshot",
|
||||
),
|
||||
"docforge.policy": (
|
||||
"EffectivePolicyV1",
|
||||
"capability_mode",
|
||||
"compose_effective_policy",
|
||||
),
|
||||
"docforge.render_contract": (
|
||||
"GenericHtmlRenderer",
|
||||
"PreparedRender",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue