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
|
- 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
|
than bounded positions, they will need a different versioned security contract and persisted key
|
||||||
lifecycle.
|
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
|
nonempty Logic publication. Complete-projection adapters continue unchanged, and non-AST
|
||||||
incremental fingerprinting and caching remain allowed.
|
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
|
## Graph views
|
||||||
|
|
||||||
The browser presents the primary architecture graph through three complementary views and loads a
|
The browser presents the primary architecture graph through three complementary views and loads a
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ remain supported:
|
||||||
- `docforge.index`
|
- `docforge.index`
|
||||||
- `docforge.mcp_server`
|
- `docforge.mcp_server`
|
||||||
- `docforge.models`
|
- `docforge.models`
|
||||||
|
- `docforge.policy`
|
||||||
- `docforge.render_contract`
|
- `docforge.render_contract`
|
||||||
|
|
||||||
Names beginning with an underscore are implementation details. New public names may be added
|
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.
|
version 3 adds a source-ordered incoming-edge index for bounded impact traversal.
|
||||||
- Index-attestation schema version 1.
|
- Index-attestation schema version 1.
|
||||||
- Incremental extraction-cache 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.
|
- 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.
|
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
|
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.
|
- Applies the same restriction during hash-bound canonical-application refresh.
|
||||||
- Reports the effective policy through bootstrap and contract results.
|
- 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.
|
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
|
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
|
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 WRITER_ID`. A project adapter must also supply a compatible project-owned
|
||||||
canonical applier implementation.
|
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
|
## Read tools
|
||||||
|
|
||||||
- `docforge_bootstrap`
|
- `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.
|
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
|
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
|
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
|
returns the complete fixed binding, active index path, effective policy, proposal and application
|
||||||
recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly.
|
capabilities, and a version-1 session contract. Bootstrap reuses the identity proven by
|
||||||
Neither operation changes canonical sources.
|
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
|
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
|
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
|
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
|
an unrelated process with direct repository write access from editing adapter files. Repository
|
||||||
permissions and project instructions remain responsible for that broader boundary.
|
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.
|
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
|
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
|
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
|
`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
|
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. 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:
|
Example MCP client configuration:
|
||||||
|
|
||||||
```json
|
```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 .index import ProjectIndex
|
||||||
from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject
|
from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject
|
||||||
from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
|
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 .project import Project, project_root_fingerprint
|
||||||
from .rendering import RenderService
|
from .rendering import RenderService
|
||||||
from .telemetry import request, stage
|
from .telemetry import request, stage
|
||||||
|
|
@ -129,50 +130,87 @@ class DocForgeService:
|
||||||
binding_metadata: Mapping[str, object] | None = None,
|
binding_metadata: Mapping[str, object] | None = None,
|
||||||
no_ast: bool = False,
|
no_ast: bool = False,
|
||||||
diagnostics: bool = False,
|
diagnostics: bool = False,
|
||||||
|
capability_mode_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.project = project
|
self.project = project
|
||||||
self.index = ProjectIndex(self.project, allow_logic=not no_ast)
|
default_mode: CapabilityMode = (
|
||||||
self.changesets = ChangesetStore(self.project, proposal_writer)
|
"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.rendering = RenderService(self.project, self.changesets)
|
||||||
self.application = CanonicalApplicationService(
|
self.application = CanonicalApplicationService(
|
||||||
self.project,
|
self.project,
|
||||||
applier_id=canonical_applier_id,
|
applier_id=canonical_applier_id if application_enabled else None,
|
||||||
applier=canonical_applier,
|
applier=canonical_applier if application_enabled else None,
|
||||||
index=self.index,
|
index=self.index,
|
||||||
)
|
)
|
||||||
self.visualization = ViewerManagerClient(self.index)
|
self.visualization = ViewerManagerClient(self.index)
|
||||||
self.context_provider = context_provider
|
self.context_provider = context_provider
|
||||||
self.binding_metadata = dict(binding_metadata or {})
|
self.binding_metadata = dict(binding_metadata or {})
|
||||||
self.no_ast = no_ast
|
self.no_ast = self.policy.no_ast
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
self.tool_surface = tool_surface or (
|
default_surface = (
|
||||||
*ALL_TOOLS,
|
READ_TOOLS
|
||||||
*(APPLICATION_TOOLS if self.application.enabled else ()),
|
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]:
|
def adapter_policy(self) -> dict[str, object]:
|
||||||
"""Return the immutable adapter-evolution policy for this MCP binding."""
|
"""Return the immutable adapter-evolution policy for this MCP binding."""
|
||||||
|
|
||||||
if not self.no_ast:
|
return self.policy.adapter_policy()
|
||||||
return {
|
|
||||||
"mode": "standard",
|
def capabilities(self) -> dict[str, object]:
|
||||||
"ast_analysis": "allowed",
|
"""Return the registered surfaces separately from startup-bound authority."""
|
||||||
"logic_projection": "allowed",
|
|
||||||
"incremental_extraction": "allowed",
|
proposal_access = self.changesets.access()
|
||||||
"adapter_rewrite": "not_requested",
|
application_access = self.application.access()
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
"mode": "preserve-no-ast",
|
"schema_version": 1,
|
||||||
"ast_analysis": "forbidden",
|
"mode": self.policy.capability_mode,
|
||||||
"logic_projection": "forbidden",
|
"registered_tools": list(self.tool_surface),
|
||||||
"incremental_extraction": "allowed",
|
"read": {
|
||||||
"adapter_rewrite": "forbidden",
|
"enabled": True,
|
||||||
"blocked_tools": ["docforge_get_logic"],
|
"tools": [tool for tool in READ_TOOLS if tool in self.tool_surface],
|
||||||
"instruction": (
|
},
|
||||||
"Preserve the existing adapter extraction strategy. Do not add Python AST, "
|
"proposal": {
|
||||||
"Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental "
|
"surface_enabled": any(tool in self.tool_surface for tool in PROPOSAL_TOOLS),
|
||||||
"fingerprinting and caching remain allowed."
|
"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(
|
def invoke(
|
||||||
|
|
@ -507,15 +545,15 @@ class DocForgeService:
|
||||||
def bootstrap(self) -> dict[str, object]:
|
def bootstrap(self) -> dict[str, object]:
|
||||||
def operation() -> dict[str, object]:
|
def operation() -> dict[str, object]:
|
||||||
synchronized = self.index.synchronize()
|
synchronized = self.index.synchronize()
|
||||||
snapshot = self.project.load()
|
descriptor = self.project.descriptor
|
||||||
root = snapshot.descriptor.root
|
root = descriptor.root
|
||||||
binding = {
|
binding = {
|
||||||
"project_root": str(root),
|
"project_root": str(root),
|
||||||
"descriptor_path": str(snapshot.descriptor.descriptor_path),
|
"descriptor_path": str(descriptor.descriptor_path),
|
||||||
"adapter": snapshot.descriptor.adapter,
|
"adapter": descriptor.adapter,
|
||||||
"cache_root": str(snapshot.descriptor.cache_root),
|
"cache_root": str(descriptor.cache_root),
|
||||||
"index_path": str(snapshot.descriptor.index_path),
|
"index_path": str(descriptor.index_path),
|
||||||
"changeset_root": str(snapshot.descriptor.changeset_root),
|
"changeset_root": str(descriptor.changeset_root),
|
||||||
**self.binding_metadata,
|
**self.binding_metadata,
|
||||||
"adapter_policy": self.adapter_policy(),
|
"adapter_policy": self.adapter_policy(),
|
||||||
}
|
}
|
||||||
|
|
@ -523,11 +561,19 @@ class DocForgeService:
|
||||||
"docforge_get_context or targeted read tools",
|
"docforge_get_context or targeted read tools",
|
||||||
"make and verify one coherent implementation slice",
|
"make and verify one coherent implementation slice",
|
||||||
"docforge_sync",
|
"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:
|
if self.no_ast:
|
||||||
recommended_workflow.insert(
|
recommended_workflow.insert(
|
||||||
1,
|
1,
|
||||||
|
|
@ -536,19 +582,55 @@ class DocForgeService:
|
||||||
"compiler-AST, or function-Logic extraction"
|
"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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"project_id": snapshot.descriptor.project_id,
|
"project_id": descriptor.project_id,
|
||||||
"project_root_fingerprint": project_root_fingerprint(root),
|
"project_root_fingerprint": project_root_fingerprint(root),
|
||||||
"title": snapshot.descriptor.title,
|
"title": descriptor.title,
|
||||||
"adapter": snapshot.descriptor.adapter,
|
"adapter": descriptor.adapter,
|
||||||
"revision": snapshot.revision,
|
"revision": synchronized["revision"],
|
||||||
"source_hash": snapshot.source_hash,
|
"source_hash": synchronized["source_hash"],
|
||||||
"binding": binding,
|
"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(),
|
"adapter_policy": self.adapter_policy(),
|
||||||
"proposal_access": self.changesets.access(),
|
"effective_policy": effective_policy,
|
||||||
"canonical_application_access": self.application.access(),
|
"capabilities": capabilities,
|
||||||
|
"session_contract": session_contract,
|
||||||
|
"proposal_access": proposal_access,
|
||||||
|
"canonical_application_access": application_access,
|
||||||
"synchronization": synchronized["synchronization"],
|
"synchronization": synchronized["synchronization"],
|
||||||
"recommended_workflow": recommended_workflow,
|
"recommended_workflow": recommended_workflow,
|
||||||
}
|
}
|
||||||
|
|
@ -606,6 +688,8 @@ class DocForgeService:
|
||||||
"Canonical project files own facts; DocForge results are derived."
|
"Canonical project files own facts; DocForge results are derived."
|
||||||
),
|
),
|
||||||
"adapter_policy": self.adapter_policy(),
|
"adapter_policy": self.adapter_policy(),
|
||||||
|
"effective_policy": self.policy.as_dict(),
|
||||||
|
"capabilities": self.capabilities(),
|
||||||
"canonical_paths": [
|
"canonical_paths": [
|
||||||
*(relative(path) for path in snapshot.descriptor.content_roots),
|
*(relative(path) for path in snapshot.descriptor.content_roots),
|
||||||
*(relative(path) for path in snapshot.descriptor.authority_files),
|
*(relative(path) for path in snapshot.descriptor.authority_files),
|
||||||
|
|
@ -638,14 +722,20 @@ class DocForgeService:
|
||||||
],
|
],
|
||||||
"allowed_tools": list(self.tool_surface),
|
"allowed_tools": list(self.tool_surface),
|
||||||
"excluded_operations": list(
|
"excluded_operations": list(
|
||||||
EXCLUDED_OPERATIONS
|
dict.fromkeys(
|
||||||
+ (
|
EXCLUDED_OPERATIONS
|
||||||
("canonical_writes", "canonical_changeset_application")
|
+ (
|
||||||
if not self.application.enabled
|
("canonical_writes", "canonical_changeset_application")
|
||||||
else ()
|
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(),
|
"proposal_access": self.changesets.access(),
|
||||||
"canonical_application_access": self.application.access(),
|
"canonical_application_access": self.application.access(),
|
||||||
|
|
@ -907,19 +997,20 @@ class DocForgeService:
|
||||||
|
|
||||||
|
|
||||||
def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMCP:
|
def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMCP:
|
||||||
capability = (
|
capability = {
|
||||||
"Read validated documentation for exactly one configured project."
|
"read": "Read validated documentation for exactly one configured project.",
|
||||||
if read_only
|
"proposal": (
|
||||||
else (
|
"Read validated documentation and use startup-gated isolated proposal changesets and "
|
||||||
"Read validated documentation and write isolated proposal changesets and previews for "
|
"previews for exactly one configured project."
|
||||||
"exactly one configured project"
|
),
|
||||||
+ (
|
"application": (
|
||||||
", with hash-bound canonical application enabled."
|
"Read validated documentation, use startup-gated isolated proposals, and apply one "
|
||||||
if service.application.enabled
|
"exact validated changeset hash for exactly one configured project."
|
||||||
else "."
|
),
|
||||||
)
|
"operator": (
|
||||||
)
|
"Operate the fixed validated documentation binding for exactly one configured project."
|
||||||
)
|
),
|
||||||
|
}[service.policy.capability_mode]
|
||||||
server = FastMCP(
|
server = FastMCP(
|
||||||
"DocForge",
|
"DocForge",
|
||||||
instructions=(
|
instructions=(
|
||||||
|
|
@ -1492,6 +1583,7 @@ def create_server(
|
||||||
canonical_applier_id: str | None = None,
|
canonical_applier_id: str | None = None,
|
||||||
no_ast: bool = False,
|
no_ast: bool = False,
|
||||||
diagnostics: bool = False,
|
diagnostics: bool = False,
|
||||||
|
capability_mode: str | None = None,
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
project = Project.open(project_root)
|
project = Project.open(project_root)
|
||||||
return create_project_server(
|
return create_project_server(
|
||||||
|
|
@ -1507,6 +1599,7 @@ def create_server(
|
||||||
},
|
},
|
||||||
no_ast=no_ast,
|
no_ast=no_ast,
|
||||||
diagnostics=diagnostics,
|
diagnostics=diagnostics,
|
||||||
|
capability_mode=capability_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1520,6 +1613,7 @@ def create_project_server(
|
||||||
binding_metadata: Mapping[str, object] | None = None,
|
binding_metadata: Mapping[str, object] | None = None,
|
||||||
no_ast: bool = False,
|
no_ast: bool = False,
|
||||||
diagnostics: bool = False,
|
diagnostics: bool = False,
|
||||||
|
capability_mode: str | None = None,
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""Create the full fixed MCP surface for one explicitly configured project service."""
|
"""Create the full fixed MCP surface for one explicitly configured project service."""
|
||||||
|
|
||||||
|
|
@ -1532,8 +1626,12 @@ def create_project_server(
|
||||||
binding_metadata=binding_metadata,
|
binding_metadata=binding_metadata,
|
||||||
no_ast=no_ast,
|
no_ast=no_ast,
|
||||||
diagnostics=diagnostics,
|
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(
|
def create_read_only_server(
|
||||||
|
|
@ -1543,6 +1641,7 @@ def create_read_only_server(
|
||||||
binding_metadata: Mapping[str, object] | None = None,
|
binding_metadata: Mapping[str, object] | None = None,
|
||||||
no_ast: bool = False,
|
no_ast: bool = False,
|
||||||
diagnostics: bool = False,
|
diagnostics: bool = False,
|
||||||
|
capability_mode: str | None = None,
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""Create an adapter-capable MCP server exposing only the fixed read tool surface."""
|
"""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,
|
binding_metadata=binding_metadata,
|
||||||
no_ast=no_ast,
|
no_ast=no_ast,
|
||||||
diagnostics=diagnostics,
|
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)
|
return _create_bound_server(service, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1575,6 +1681,11 @@ def main() -> None:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Attach bounded request-local stage timings and counters",
|
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()
|
arguments = parser.parse_args()
|
||||||
create_server(
|
create_server(
|
||||||
arguments.project_root,
|
arguments.project_root,
|
||||||
|
|
@ -1582,6 +1693,7 @@ def main() -> None:
|
||||||
canonical_applier_id=arguments.canonical_applier,
|
canonical_applier_id=arguments.canonical_applier,
|
||||||
no_ast=arguments.no_ast,
|
no_ast=arguments.no_ast,
|
||||||
diagnostics=arguments.diagnostics,
|
diagnostics=arguments.diagnostics,
|
||||||
|
capability_mode=arguments.capability_mode,
|
||||||
).run(transport="stdio")
|
).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 mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
|
||||||
from docforge.changesets import ChangesetStore
|
from docforge.changesets import ChangesetStore
|
||||||
|
from docforge.errors import DocForgeError
|
||||||
from docforge.index import ProjectIndex
|
from docforge.index import ProjectIndex
|
||||||
from docforge.mcp_server import (
|
from docforge.mcp_server import (
|
||||||
ALL_TOOLS,
|
ALL_TOOLS,
|
||||||
APPLICATION_TOOLS,
|
APPLICATION_TOOLS,
|
||||||
CONTENT_WARNING,
|
CONTENT_WARNING,
|
||||||
PROPOSAL_TOOLS,
|
PROPOSAL_TOOLS,
|
||||||
|
READ_TOOLS,
|
||||||
DocForgeService,
|
DocForgeService,
|
||||||
_create_bound_server,
|
_create_bound_server,
|
||||||
create_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:
|
async def test_factory_diagnostics_are_additive_through_real_mcp(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture("alpha", Path(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",
|
"ProjectService",
|
||||||
"ProjectSnapshot",
|
"ProjectSnapshot",
|
||||||
),
|
),
|
||||||
|
"docforge.policy": (
|
||||||
|
"EffectivePolicyV1",
|
||||||
|
"capability_mode",
|
||||||
|
"compose_effective_policy",
|
||||||
|
),
|
||||||
"docforge.render_contract": (
|
"docforge.render_contract": (
|
||||||
"GenericHtmlRenderer",
|
"GenericHtmlRenderer",
|
||||||
"PreparedRender",
|
"PreparedRender",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue