diff --git a/ACTIVE_SLICE.md b/ACTIVE_SLICE.md
index 71c0e5e..4d6b119 100644
--- a/ACTIVE_SLICE.md
+++ b/ACTIVE_SLICE.md
@@ -1,19 +1,13 @@
-# Active milestone
+# Milestone state
```text
-Milestone: 2 — agent retrieval and MCP experience
-Goal: Let one project-bound server return compact, task-shaped, explainable context under an explicit effective policy.
-In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks.
-Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases.
-Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass.
-Status: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts,
-logarithmic bounded page packing, deterministic client configuration, and the read-only integration
-doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and
-120 subtests. Three independent adversarial audits found no remaining implementation blocker. The
-clean 1,000-node baseline is recorded against candidate commit
-`fb0df5e4a1c591c2a84788fd4814d98550f11863`, including task/generation reconstruction,
-response-size behavior, zero-hidden-work counters, and isolated memory. No tag or release was
-created, no production integration was repointed, and self-hosting remains out of scope.
+Last completed milestone: 3 — independent projections
+Outcome: Manual output, portable graph artifacts, and the live viewer are independent generation-pinned consumers of the validated graph.
+Evidence: Clean candidate f5dccb5e1c312121f1af63780162f593d9363b98; 281 tests and 272 subtests; 3 accessibility flows; clean 1,000-node ten-sample benchmark; isolated wheel proof; no secret-scan findings.
+Active milestone: None.
+Next directional milestone: 4 — adapter SDK and product documentation.
+Status: Milestone 3 is closed. Milestone 4 has not started.
```
-Milestones 3–5 remain directional context and are not active.
+Milestones 4–5 remain directional context. Do not begin Milestone 4 without a new active-slice
+contract.
diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md
index 8e15aea..2548012 100644
--- a/DEVELOPMENT_NOTES.md
+++ b/DEVELOPMENT_NOTES.md
@@ -345,7 +345,7 @@ These are notes, not commitments:
than bounded positions, they will need a different versioned security contract and persisted key
lifecycle.
-## Milestone 2 — active: agent retrieval and MCP experience
+## Milestone 2 — complete: agent retrieval and MCP experience
### Audit reconciliation
@@ -610,3 +610,177 @@ Milestone 2 is complete. Follow-up ideas stay explicitly later-scope: avoid reco
task-shaped capsule for every continuation page, add authenticated continuation when the threat
model requires it, verify a native Claude timeout representation, and introduce adapter-owned
launcher metadata before generating configurations for custom adapters.
+
+## Milestone 3 — complete: independent projections
+
+Milestone 3 began only after `main` and `dev` were aligned at the verified Milestone 2 closeout.
+Three read-only audits ran before source changes:
+
+- Manual planning, immutable packages, renderer isolation, receipts, preview/application
+ integration, and full/incremental equivalence.
+- Portable graph planning, static artifacts, the live viewer boundary, worker protocol, and static
+ plus interactive accessibility.
+- Packaging, optional dependencies, public contracts, projection policies, performance,
+ incremental fragments, and maintained gates.
+
+The active design constraints are unchanged: renderers consume one validated immutable generation;
+manual and graph plans remain separate; the live viewer is not retrieval authority; core remains
+usable without rendering; status performs no hidden rendering; full rendering remains the recovery
+and equivalence oracle; no storage rewrite is assumed.
+
+### Milestone 3 architecture decision
+
+The three audits converged on one compatibility-first boundary:
+
+- The existing `docforge.render_contract` names, `GenericHtmlRenderer.prepare()` signature,
+ `generic_html` renderer identity, and byte output remain the version-1 compatibility surface.
+ They become adapters over the new manual-planning path rather than being changed in place.
+- New `ManualRenderPlanV1`, `GraphViewPlanV1`, `ProjectionPackageV1`, and
+ `ProjectionReceiptV1` contracts use strict canonical JSON, deterministic ordering, independent
+ item and byte bounds, exact generation and policy binding, and content-derived identities.
+- Plans and packages contain selected graph facts and bounded content. They never contain a
+ project object, SQLite handle, absolute project or index path, arbitrary query, command, or
+ project-provided executable code.
+- The planner owns graph selection and meaning. A renderer may transform only a validated package
+ into declared artifacts and cannot select nodes, invent relationships, crawl the project, choose
+ publication paths, or mutate canonical sources.
+- Manual and portable graph renderers live behind independent import boundaries. Renderer
+ dependencies load lazily. Default installation behavior remains compatible during the initial
+ migration; optional dependency changes require their own verified packaging decision.
+- Portable graph rendering is additive. It does not replace or silently change
+ `docforge_visualize`, `graph-browser@17`, the viewer-manager protocol, or the query-backed live
+ viewer.
+- Effective policy version 1 remains frozen. Milestone 3 introduces a version-2 projection-policy
+ view for manual `auto|explicit|disabled`, portable graph `explicit|disabled`, and live viewer
+ `on-demand|disabled` enforcement, while retaining the version-1 projection for existing clients.
+- Publication commits content-addressed artifacts first, renderer evidence second, and a bounded
+ generation/view manifest last. Status remains receipt-only. Failures after artifact replacement
+ report committed degraded success rather than an ordinary failed mutation.
+- Full planning and rendering remain the recovery and equivalence oracle. Incremental fragments
+ are disposable, keyed from complete plan semantics, and may be reused only when byte-exact
+ artifact equivalence is proven.
+- The live source endpoint must stop reading mutable canonical files behind a pinned graph
+ snapshot. Portable artifacts never inherit that path-bearing behavior.
+
+The first implementation slice freezes existing golden output, adds the four versioned contracts
+and validators, introduces pure manual and graph planners, and makes the legacy manual renderer a
+compatibility wrapper. Publication hardening, detached rendering, incremental fragments, portable
+graph publication, independent policy enforcement, accessibility, and maintained performance
+gates follow on top of that frozen boundary.
+
+### Milestone 3 contract slice
+
+The first slice now implements:
+
+- Strict Draft 2020-12 schemas and runtime canonical-hash validation for manual plans, graph plans,
+ projection packages, and projection receipts.
+- A deterministic manual planner that owns page selection, navigation, cross-references,
+ backlinks, search documents, component assignments, orphan diagnostics, and cycle diagnostics.
+- A deterministic graph planner with exact-root or metadata-only lexical scope, closed filters,
+ explicit node/edge/work bounds, deterministic omissions, path/source-body exclusion, and
+ no-AST Logic exclusion.
+- A separate `docforge_renderers.manual` package. Its renderer accepts only a validated package and
+ has no project, SQLite, publication-path, or filesystem-write API.
+- The frozen `GenericHtmlRenderer` compatibility shim over the new planner/package/renderer
+ pipeline. The alpha artifact remains exactly 2,043 bytes with output SHA-256
+ `81656bb89debc7ad1fbe8bc290e9a3ba90664442b17a6d57e908d30d20c47f77` and legacy render identity
+ `1c0a49c28ba3b0dabf94be36e75def197dee1be3cb73ac405b09875383c8dc5f`.
+- Rejection of project-template scripts, inline event handlers, `javascript:` URLs, embedded
+ browsing contexts, and refresh redirects.
+- Wheel inclusion for both typed packages and every published JSON schema. Importing `docforge`
+ no longer imports `markdown_it` or the manual renderer package.
+- A live-viewer correction: source evidence now comes from the pinned index generation. The
+ viewer no longer reopens mutable canonical files behind an older graph snapshot.
+
+The new repository-native contract target passed 91 tests and 120 subtests at the slice boundary.
+The combined projection, rendering, and live-viewer focus passed with byte-exact compatibility and
+no hidden source/path authority.
+
+### Durable portable graph publication
+
+The portable graph path now has its own declared `graph_render` views, pure plans, fixed
+`portable_graph_html` renderer, content-addressed artifact store, renderer receipts, and one bounded
+generation/view manifest as the publication commit. It supports Nodes, Flow, and Web without
+including Logic. Static HTML contains the complete pre-rendered graph and treats JavaScript as
+progressive enhancement.
+
+Publication revalidates source, view, artifact, receipt, and output identities across replacement.
+Status reads only bounded manifest and receipt evidence. It never plans or renders. Repair may
+restore a declared output from its content-addressed artifact. A post-artifact failure that cannot
+be rolled back returns explicit degraded committed evidence rather than reporting an ordinary
+failed mutation.
+
+### Detached workers and incremental fragments
+
+Manual and portable graph packages execute through one fixed one-request child protocol. The
+parent launches isolated Python from a trusted working directory with a sanitized environment,
+spools stdout to disk, reads one bounded canonical response, and validates the complete artifact
+and receipt identity. The worker accepts only the two built-in renderer identities. Requests are
+bounded by the 24,000,000-byte package contract, actual artifact transfer by 20,000,000 bytes, and
+execution by a 30-second timeout.
+
+Manual fragment records are semantic, versioned, canonical, hash-bound, and stored below a
+dedicated confined cache. The worker independently recomputes the expected page fragment before
+using a record. Corrupt, forged, oversized, stale, or aggregate-oversized records fall back to the
+full detached render. Cold fragment creation is compared byte-for-byte with that full oracle before
+cache publication. The cache retains only the current inventory and is capped at 10,000 entries
+and 64,000,000 bytes.
+
+### Independent policies and accessibility
+
+Projection policy version 2 independently composes manual `auto|explicit|disabled`, portable graph
+`explicit|disabled`, and live viewer `on-demand|disabled`. CLI, MCP, generated client
+configuration, doctor, render services, canonical application, onboarding, and viewer-manager
+entry points enforce their relevant policy. Status remains available when an active operation is
+disabled.
+
+Generated client evidence binds the projection policy, its hash, projection availability, and the
+current descriptor hash into the configuration hash. Validation cross-checks omitted default
+selectors against the bound descriptor so coordinated policy and availability drift fails closed.
+The version-1 effective-policy payload remains unchanged for existing clients.
+
+Pinned Playwright 1.62.0 and axe-core 4.12.1 gates exercise the frozen manual, portable graph, and
+live viewer with selected WCAG A/AA axe tags and keyboard interaction flows. Portable and live
+graph presentation received only the minimal contrast and nested-role corrections needed by those
+gates.
+
+### Scale and runtime hardening
+
+The first 1,000-node full benchmark exposed recursive strongly connected-component traversal in
+manual planning. Cycle detection now uses an iterative two-pass traversal. A regression covers the
+descriptor maximum of 10,000 nodes as both a deep acyclic chain and one strongly connected
+component.
+
+The isolated wheel proof also exposed a Python `runpy` warning when the worker module was imported
+during package initialization before `-m` execution. A private fixed module entrypoint now owns
+child startup. Malformed child input returns code 2 with empty stdout and stderr.
+
+Configured render ceilings above 20,000,000 bytes remain accepted for compatibility, and small
+actual artifacts render normally. The detached protocol still rejects an actual transfer beyond
+its fixed 20,000,000-byte boundary.
+
+### Milestone 3 closeout
+
+Candidate `f5dccb5e1c312121f1af63780162f593d9363b98` passed the complete repository gate: formatting,
+Python and web lint, strict types, compilation, 281 tests and 272 subtests, three accessibility
+flows, lock and dependency checks, package builds, and all milestone smoke benchmarks. The
+maintained projection contract subset passed 142 tests and 236 subtests.
+
+The clean ten-sample 1,000-node benchmark passed every latency, memory, response-size, no-work, and
+equivalence gate. Manual full rendering measured 810.490 ms p95, portable graph full rendering
+323.690 ms p95, and receipt-only status 111.381 ms and 59.331 ms p95 respectively. Direct detached
+worker peaks were 88,580,096 and 89,583,616 bytes. The separately gated production manual worker
+peak was 104,771,584 bytes. Production cold, warm, forced-full, add, change, delete, and reorder
+outputs were byte-identical.
+
+Production warm fragment rendering measured 2,206.540 ms p95 versus 978.870 ms for forced full.
+Milestone 3 therefore closes the fragment isolation, invalidation, equivalence, and recovery
+contract without claiming a throughput win. Later optimization must begin from that evidence.
+
+The exact method and measurements are recorded in `docs/MILESTONE_3_BASELINE.md` and
+`benchmarks/milestone3-2026-07-29.json`. The candidate passed an isolated wheel CLI/MCP/worker
+proof. Gitleaks 8.30.1 found no findings across the six Milestone 3 commits or candidate tree.
+
+Milestone 3 is complete. No tag, release, production integration repointing, WorldForge change,
+ScrapeStation change, storage rewrite, or self-hosting dependency was introduced. Milestone 4
+remains directional and has not started.
diff --git a/Makefile b/Makefile
index ab909e4..64d52c8 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,10 @@ NPM := npm
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
PYTEST_BASETEMP := /tmp/docforge-quality-pytest
-.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
+.PHONY: accessibility benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-m3 benchmark-m3-full benchmark-m3-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
+
+accessibility:
+ $(NPM) run test:accessibility
format-check:
$(PYTHON) -m ruff format --check src tests tools
@@ -25,9 +28,18 @@ contract:
-p no:cacheprovider --basetemp=$(PYTEST_BASETEMP) \
tests/test_public_contract.py \
tests/test_policy.py \
+ tests/test_projection_policy.py \
+ tests/test_projection_policy_integration.py \
+ tests/test_projection_worker.py \
+ tests/test_projection_fragments.py \
tests/test_retrieval.py \
tests/test_generation_diff.py \
tests/test_client_integration.py \
+ tests/test_projection_contract.py \
+ tests/test_projection_schemas.py \
+ tests/test_graph_projection.py \
+ tests/test_graph_rendering.py \
+ tests/test_graph_publication.py \
tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \
@@ -68,4 +80,13 @@ benchmark-m2-smoke:
benchmark-m2:
$(PYTHON) tools/milestone2_benchmark.py --nodes 1000 --samples 10
-gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke
+benchmark-m3-smoke:
+ $(PYTHON) tools/milestone3_benchmark.py --mode smoke \
+ --output /tmp/docforge-milestone3-smoke.json > /dev/null
+
+benchmark-m3:
+ $(PYTHON) tools/milestone3_benchmark.py --mode full
+
+benchmark-m3-full: benchmark-m3
+
+gate: format-check lint type compile contract test accessibility lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke benchmark-m3-smoke
diff --git a/README.md b/README.md
index 16a03a6..20ba862 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,12 @@ declared manuals, visualizes project structure, and manages reviewable documenta
- Detects project-local adapter implementation and configuration changes and requires a fresh
project-bound process before any further MCP work.
- Keeps function-scoped control-flow projections separate from the primary architecture graph.
+- Compiles manuals and portable graph artifacts from separate versioned, generation-pinned plans
+ and immutable packages.
+- Runs built-in manual and graph renderers in fixed detached workers with validated receipts,
+ bounded transfer, and no project-path authority.
+- Enforces independent manual, portable-graph, and live-viewer policy while keeping status
+ receipt-only.
- Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web,
function-scoped Logic, source inspection, and branch-aware node hiding.
- Supports generic documentation projects and project-owned source adapters.
@@ -73,6 +79,63 @@ details, and explicit truncation. Paged results use one top-level cursor and a v
`receipt_header`; `stored_receipt_hash` identifies the complete persisted receipt. The read never
exposes Logic details, loads canonical source, repairs derived state, or invents history.
+## Independent projections
+
+Manual compilation, portable graph rendering, and the live viewer consume the same validated graph
+generation through separate boundaries:
+
+```text
+validated generation
+ ├── ManualRenderPlanV1 → immutable package → detached manual renderer
+ ├── GraphViewPlanV1 → immutable package → detached portable graph renderer
+ └── pinned index → managed read-only live viewer
+```
+
+Plans, packages, and receipts are canonical, versioned, hash-identified, bounded, and contain no
+project object, SQLite handle, absolute project path, command, or caller-selected renderer module.
+Renderers cannot select graph facts, crawl canonical sources, choose publication paths, or mutate
+project state.
+
+Declare portable graph output separately from manual views:
+
+```toml
+[graph_render]
+output_root = ".docforge/portable-graph"
+
+[[graph_render.views]]
+id = "architecture"
+renderer = "portable_graph_html"
+output = "architecture.html"
+title = "Architecture"
+root = "architecture.overview"
+initial_mode = "web"
+depth = 3
+max_nodes = 250
+max_edges = 1000
+max_work = 100000
+include_logic = false
+```
+
+Plan, publish, and inspect it explicitly:
+
+```bash
+.venv/bin/docforge --project-root "$PROJECT" graph-plan architecture
+.venv/bin/docforge --project-root "$PROJECT" graph-render architecture
+.venv/bin/docforge --project-root "$PROJECT" graph-render-status architecture
+```
+
+The version-2 projection policy independently selects manual
+`auto|explicit|disabled`, portable graph `explicit|disabled`, and live viewer
+`on-demand|disabled`. Use `--manual-render-policy`, `--portable-graph-policy`, and
+`--live-viewer-policy` on CLI/MCP startup or generated client configuration. Status remains
+available when the corresponding active operation is disabled.
+
+Manual fragment reuse is disposable. Cold record publication is guarded by byte-exact comparison
+with a full detached render; warm records are independently recomputed and validated inside the
+worker. Full rendering remains the recovery and equivalence oracle. Portable artifacts commit
+content-addressed output and renderer evidence before one bounded generation/view manifest; status
+does not plan or render.
+
## Graph views
The browser presents the primary architecture graph through three complementary views and loads a
@@ -170,10 +233,18 @@ DocForge describes them as a source graph.
performance, memory, rendering and response sizes, bottlenecks, and missing coverage.
- [Milestone 0 closeout](docs/MILESTONE_0_CLOSEOUT.md) — lineage, migration, security scan,
repository state, and fresh-clone proof.
+- [Milestone 1 baseline](docs/MILESTONE_1_BASELINE.md) — warm operation latency, structured work,
+ status, retrieval, and memory measurements.
+- [Milestone 1 closeout](docs/MILESTONE_1_CLOSEOUT.md) — fast-core contracts, adversarial
+ validation, compatibility boundaries, and exact candidate evidence.
- [Milestone 2 baseline](docs/MILESTONE_2_BASELINE.md) — task context, generation diff, client
configuration, doctor, response-size, counter, and memory measurements.
- [Milestone 2 closeout](docs/MILESTONE_2_CLOSEOUT.md) — implemented contracts, adversarial
validation, exclusions, and exact candidate evidence.
+- [Milestone 3 baseline](docs/MILESTONE_3_BASELINE.md) — manual, fragment, worker, portable graph,
+ status, equivalence, response-size, and memory measurements.
+- [Milestone 3 closeout](docs/MILESTONE_3_CLOSEOUT.md) — independent projection contracts,
+ adversarial validation, compatibility boundaries, and exact candidate evidence.
- [MCP contract](docs/MCP_CONTRACT.md) — exact tool and process boundary.
- [Viewer manager](docs/VIEWER_MANAGER.md) — native service setup and lifecycle.
- [Adapter decision](docs/APPLICATION_DECISION.md) — why custom adapters own canonical
@@ -197,6 +268,8 @@ make gate
Focused entry points are available as `make contract`, `make test`, `make type`,
`make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and
`make benchmark-m1`. Milestone 2 adds `make benchmark-m2-smoke` and `make benchmark-m2`.
+Milestone 3 adds `make accessibility`, `make benchmark-m3-smoke`, `make benchmark-m3`, and
+`make benchmark-m3-full`.
The committed 1,000-node baseline and its measurement method are under `benchmarks/`.
diff --git a/SLICE_HISTORY.md b/SLICE_HISTORY.md
index fa7bf77..5b4cd75 100644
--- a/SLICE_HISTORY.md
+++ b/SLICE_HISTORY.md
@@ -1,5 +1,51 @@
# Completed slices
+## DocForge2 Milestone 3 independent projections
+
+### Changed
+
+- Added strict version-1 manual plan, graph plan, projection package, and projection receipt
+ contracts with canonical identities and packaged schemas.
+- Kept the legacy manual API and exact alpha bytes as a compatibility wrapper over a pure planner,
+ immutable package, and independent renderer.
+- Added generation-pinned portable graph planning, detached rendering, content-addressed durable
+ publication, repair, and receipt-only status.
+- Added a fixed isolated worker protocol with bounded request, response, artifact, timeout,
+ environment, and renderer inventory.
+- Added semantic manual fragments with independent worker validation, corruption recovery,
+ full-render equivalence, and bounded cache retention.
+- Added version-2 independent manual, portable-graph, and live-viewer policy with descriptor-bound
+ generated-client evidence while retaining effective policy version 1.
+- Corrected live source reads to use the pinned index generation.
+- Added static and interactive accessibility gates for the manual, portable graph, and live viewer.
+- Replaced recursive cycle planning with an iterative traversal proven at 10,000 nodes.
+
+### Verification
+
+- The complete gate passed 281 tests, 272 subtests, three accessibility flows, dependency checks,
+ builds, and all milestone smoke benchmarks.
+- The maintained projection contract subset passed 142 tests and 236 subtests.
+- The clean ten-sample 1,000-node benchmark passed every latency, memory, response-size, no-work,
+ deterministic-output, and full/incremental equivalence threshold.
+- An isolated wheel passed CLI, MCP, detached manual rendering, and malformed worker startup.
+- Three adversarial review tracks were reconciled, and Gitleaks found no findings in the six
+ milestone commits or candidate tree.
+
+### Limits
+
+- Production fragment reuse is correct and recoverable but is slower than forced full rendering at
+ the maintained 1,000-page fixture. No speedup is claimed.
+- Actual detached artifact transfer is capped at 20,000,000 bytes.
+- Portable graph publication is CLI-owned. MCP exposes read-only plan and status tools.
+- Remote render services, render farms, third-party renderer ecosystems, storage replacement,
+ self-hosting, adapter SDK expansion, and production integration changes remain out of scope.
+- No tag or release was created.
+
+### Next gate
+
+Milestone 4 remains directional and is not active. Create a new active-slice contract before
+starting adapter SDK or product-documentation implementation.
+
## DocForge2 Milestone 0 successor foundation
### Changed
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 9d78eda..2141f1a 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -34,6 +34,14 @@ make benchmark-m2-smoke
make benchmark-m2
```
+Run the Milestone 3 independent-projection smoke and maintained full gates:
+
+```bash
+make benchmark-m3-smoke
+make benchmark-m3
+make benchmark-m3-full
+```
+
The benchmark creates canonical sources, derived state, changesets, rendered output, and caches
only in a disposable temporary directory. It does not read another project, self-host DocForge, or
mutate repository content.
@@ -56,9 +64,16 @@ records whether diagnostics were dropped for response budget, checks all hidden-
and measures isolated-process peak RSS. Its interpretation is in
[`docs/MILESTONE_2_BASELINE.md`](../docs/MILESTONE_2_BASELINE.md).
-The generic fixture exposes whole-source scaling. It does not replace the incremental adapter
-equivalence tests and does not claim to measure a portable graph renderer, because Milestone 0 has
-no portable graph-planning or graph-rendering contract.
+`milestone3-2026-07-29.json` is the clean-tree independent-projection baseline captured from commit
+`f5dccb5e1c312121f1af63780162f593d9363b98`. It measures versioned manual and graph planning,
+in-process and detached rendering, production cold/warm/forced-full behavior, fragment cache
+sweeps, add/change/delete/reorder equivalence, portable publication, receipt-only status, traced
+memory, detached worker peak memory, and response size. Its interpretation is in
+[`docs/MILESTONE_3_BASELINE.md`](../docs/MILESTONE_3_BASELINE.md).
+
+The generic fixtures expose whole-source and projection scaling. They do not replace incremental
+adapter equivalence tests. Milestone 3's portable fixture contains 1,000 Nodes/Flow/Web nodes and
+999 edges; portable version 1 deliberately excludes Logic.
The Milestone 1 harness treats wall time and structured work counters as separate gates. Warm
operations fail if they load a complete project, parse source files, reconstruct an adapter
@@ -73,4 +88,5 @@ status. The 1,000-node run records bounded semantic summaries for exact errors,
backlinks, both traversal directions, paged context, render receipt states, and visualization
freshness. The reported p95 uses the nearest-rank method; with ten samples it is the maximum.
`process_peak_rss_kib` is the cumulative main-process `RUSAGE_SELF` high-water mark and excludes the
-detached viewer worker.
+detached viewer worker. It is diagnostic and not operation-local. Milestone 3 memory gates use
+per-operation `tracemalloc` peaks and detached worker receipt peaks instead.
diff --git a/benchmarks/milestone3-2026-07-29.json b/benchmarks/milestone3-2026-07-29.json
new file mode 100644
index 0000000..3386a8d
--- /dev/null
+++ b/benchmarks/milestone3-2026-07-29.json
@@ -0,0 +1,442 @@
+{
+ "benchmark": "docforge2_milestone3",
+ "environment": {
+ "implementation": "CPython",
+ "machine": "x86_64",
+ "platform": "Linux-7.1.3-200.nobara.fc44.x86_64-x86_64-with-glibc2.43",
+ "python": "3.14.6"
+ },
+ "equivalence": {
+ "manual_full_vs_fragment_assisted": true,
+ "manual_in_process_vs_detached": true,
+ "manual_production_cold_warm_full": true,
+ "manual_production_variants": {
+ "add": true,
+ "change": true,
+ "delete": true,
+ "reorder": true
+ },
+ "portable_graph_in_process_vs_detached": true
+ },
+ "fixture": {
+ "edge_count": 999,
+ "full_coverage": true,
+ "kind": "synthetic_generic_projection",
+ "manual_page_count": 1000,
+ "node_count": 1000,
+ "portable_graph_edge_count": 999,
+ "portable_graph_node_count": 1000
+ },
+ "memory": {
+ "manual_production_worker_peak_bytes": 104771584,
+ "manual_worker_peak_bytes": 88580096,
+ "portable_graph_worker_peak_bytes": 89583616,
+ "process_peak_rss_kib": 102692
+ },
+ "method": {
+ "clock": "time.perf_counter_ns",
+ "detached_peak_memory": "worker receipt resource peak RSS",
+ "determinism": "stable semantic summaries must match across samples; report JSON uses sorted keys",
+ "full_mode_node_requirement": 1000,
+ "in_process_peak_memory": "tracemalloc per measured invocation",
+ "maximum_worker_artifact_bytes": 20000000,
+ "process_peak_memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
+ "response_size": "UTF-8 bytes of canonical compact sorted JSON",
+ "samples": 10
+ },
+ "mode": "full",
+ "operations": {
+ "fragment_assisted_equivalence": {
+ "max_ms": 666.119,
+ "maximum_response_bytes": 664,
+ "maximum_traced_peak_bytes": 5332792,
+ "median_ms": 638.999,
+ "min_ms": 633.157,
+ "p95_limit_ms": 15000,
+ "p95_ms": 666.119,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "artifact_bytes": 583149,
+ "artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "package_bytes": 2364302,
+ "package_id": "ecc21e58106c420a9f78ffbba997778ca98b61586ecfdb23a15b08f431a52c42",
+ "plan_bytes": 1006393,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "fragment_cache_hit_sweep": {
+ "max_ms": 514.324,
+ "maximum_response_bytes": 145,
+ "maximum_traced_peak_bytes": 6941174,
+ "median_ms": 498.911,
+ "min_ms": 495.447,
+ "p95_limit_ms": 5000,
+ "p95_ms": 514.324,
+ "response_limit_bytes": 32768,
+ "samples": 10,
+ "stable_result": {
+ "aggregate_content_bytes": 516921,
+ "fragment_count": 1000,
+ "ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "fragment_cache_miss_sweep": {
+ "max_ms": 106.888,
+ "maximum_response_bytes": 145,
+ "maximum_traced_peak_bytes": 146245,
+ "median_ms": 106.203,
+ "min_ms": 105.383,
+ "p95_limit_ms": 5000,
+ "p95_ms": 106.888,
+ "response_limit_bytes": 32768,
+ "samples": 10,
+ "stable_result": {
+ "aggregate_content_bytes": 516921,
+ "fragment_count": 1000,
+ "ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "fragment_cache_put_sweep": {
+ "max_ms": 539.212,
+ "maximum_response_bytes": 145,
+ "maximum_traced_peak_bytes": 509548,
+ "median_ms": 539.212,
+ "min_ms": 539.212,
+ "p95_limit_ms": 10000,
+ "p95_ms": 539.212,
+ "response_limit_bytes": 32768,
+ "samples": 1,
+ "stable_result": {
+ "aggregate_content_bytes": 516921,
+ "fragment_count": 1000,
+ "ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_detached_worker": {
+ "child_peak_limit_bytes": 268435456,
+ "max_ms": 599.394,
+ "maximum_child_peak_bytes": 88580096,
+ "maximum_response_bytes": 667,
+ "maximum_traced_peak_bytes": 28203708,
+ "median_ms": 591.317,
+ "min_ms": 585.123,
+ "p95_limit_ms": 20000,
+ "p95_ms": 599.394,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "artifact_bytes": 583149,
+ "artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "package_bytes": 1007297,
+ "package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
+ "plan_bytes": 1006393,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_forced_full": {
+ "child_peak_limit_bytes": 268435456,
+ "max_ms": 978.87,
+ "maximum_child_peak_bytes": 104771584,
+ "maximum_response_bytes": 668,
+ "maximum_traced_peak_bytes": 30425714,
+ "median_ms": 944.135,
+ "min_ms": 934.773,
+ "p95_limit_ms": 20000,
+ "p95_ms": 978.87,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "output_bytes": 583149,
+ "output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_full_render": {
+ "max_ms": 810.49,
+ "maximum_response_bytes": 664,
+ "maximum_traced_peak_bytes": 4412754,
+ "median_ms": 801.948,
+ "min_ms": 773.7,
+ "p95_limit_ms": 15000,
+ "p95_ms": 810.49,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "artifact_bytes": 583149,
+ "artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "package_bytes": 1007297,
+ "package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
+ "plan_bytes": 1006393,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_incremental_cold": {
+ "child_peak_limit_bytes": 268435456,
+ "max_ms": 2827.152,
+ "maximum_child_peak_bytes": 99454976,
+ "maximum_response_bytes": 668,
+ "maximum_traced_peak_bytes": 35160716,
+ "median_ms": 2827.152,
+ "min_ms": 2827.152,
+ "p95_limit_ms": 20000,
+ "p95_ms": 2827.152,
+ "response_limit_bytes": 128000,
+ "samples": 1,
+ "stable_result": {
+ "output_bytes": 583149,
+ "output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_incremental_warm": {
+ "child_peak_limit_bytes": 268435456,
+ "max_ms": 2206.54,
+ "maximum_child_peak_bytes": 104767488,
+ "maximum_response_bytes": 669,
+ "maximum_traced_peak_bytes": 34676043,
+ "median_ms": 2143.388,
+ "min_ms": 2125.466,
+ "p95_limit_ms": 20000,
+ "p95_ms": 2206.54,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "output_bytes": 583149,
+ "output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "manual_status_no_work": {
+ "max_ms": 111.381,
+ "maximum_response_bytes": 1542,
+ "maximum_traced_peak_bytes": 1792025,
+ "median_ms": 62.881,
+ "min_ms": 60.111,
+ "p95_limit_ms": 500,
+ "p95_ms": 111.381,
+ "response_limit_bytes": 256000,
+ "samples": 10,
+ "stable_result": {
+ "counters": {
+ "adapter_projection_loads": 0,
+ "adapter_source_extractions": 0,
+ "index_builds": 0,
+ "index_checks": 0,
+ "index_synchronizations": 0,
+ "project_loads": 0,
+ "render_output_bytes_built": 0,
+ "render_output_bytes_hashed": 0,
+ "render_prepare_calls": 0,
+ "source_bytes_parsed": 0,
+ "source_files_parsed": 0,
+ "source_generation_checks": 2,
+ "viewer_manager_requests": 0
+ },
+ "response": {
+ "adapter": "generic",
+ "configured": true,
+ "outputs": [
+ {
+ "actual_output_hash": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "expected_output_hash": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "path": ".docforge/rendered/manual.html",
+ "projection_receipt": {
+ "artifacts": [
+ {
+ "artifact_id": "manual.html",
+ "bytes": 583149,
+ "media_type": "text/html; charset=utf-8",
+ "sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c"
+ }
+ ],
+ "contract": "docforge.projection-receipt",
+ "diagnostics": {
+ "warnings": []
+ },
+ "kind": "manual",
+ "package_id": "b30c65390570537a16ce03afe6a992599c6ca0c4ef5ea2f06d14b33e248bc9aa",
+ "peak_memory_bytes": 104771584,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
+ "receipt_id": "ead4c25b34ada8df674903a592e25dd6bbf9817df2b04e5ebc66cde9e41c1ed1",
+ "renderer": {
+ "renderer_id": "generic_html",
+ "renderer_version": "1+markdown-it-py-4.2.0"
+ },
+ "schema_version": 1,
+ "timing": {
+ "elapsed_ns": 114544865
+ }
+ },
+ "reason": null,
+ "receipt_schema_version": 1,
+ "render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0",
+ "renderer": "generic_html",
+ "renderer_version": "1+markdown-it-py-4.2.0",
+ "state": "current",
+ "template_hash": "ae0ebb3eadeb530e9d033c8fbd21321d406e29d04c0ae7d07a9a0f929f0ffa9f",
+ "verification": "receipt",
+ "view_id": "manual"
+ }
+ ],
+ "project_id": "synthetic-1000",
+ "project_root_fingerprint": "0747acfd975703c0",
+ "revision": "unversioned",
+ "source_hash": "a314da4ffa8fcf291ef7a7b0fc87737caeaa89fa3c558ea442afeb0e5ae49c2d",
+ "state": "current",
+ "status": "ok",
+ "verification": "receipt"
+ }
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "portable_graph_detached_worker": {
+ "child_peak_limit_bytes": 268435456,
+ "max_ms": 268.428,
+ "maximum_child_peak_bytes": 89583616,
+ "maximum_response_bytes": 660,
+ "maximum_traced_peak_bytes": 27587227,
+ "median_ms": 265.448,
+ "min_ms": 263.761,
+ "p95_limit_ms": 20000,
+ "p95_ms": 268.428,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "artifact_bytes": 718383,
+ "artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
+ "package_bytes": 398715,
+ "package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
+ "plan_bytes": 398158,
+ "plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "portable_graph_full_render": {
+ "max_ms": 323.69,
+ "maximum_response_bytes": 657,
+ "maximum_traced_peak_bytes": 3490976,
+ "median_ms": 303.736,
+ "min_ms": 301.215,
+ "p95_limit_ms": 10000,
+ "p95_ms": 323.69,
+ "response_limit_bytes": 128000,
+ "samples": 10,
+ "stable_result": {
+ "artifact_bytes": 718383,
+ "artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
+ "package_bytes": 398715,
+ "package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
+ "plan_bytes": 398158,
+ "plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b"
+ },
+ "traced_peak_limit_bytes": 268435456
+ },
+ "portable_graph_status_no_work": {
+ "max_ms": 59.331,
+ "maximum_response_bytes": 879,
+ "maximum_traced_peak_bytes": 685012,
+ "median_ms": 58.467,
+ "min_ms": 58.052,
+ "p95_limit_ms": 500,
+ "p95_ms": 59.331,
+ "response_limit_bytes": 256000,
+ "samples": 10,
+ "stable_result": {
+ "counters": {
+ "adapter_projection_loads": 0,
+ "adapter_source_extractions": 0,
+ "index_builds": 0,
+ "index_checks": 0,
+ "index_synchronizations": 0,
+ "project_loads": 0,
+ "render_output_bytes_built": 0,
+ "render_output_bytes_hashed": 0,
+ "render_prepare_calls": 0,
+ "source_bytes_parsed": 0,
+ "source_files_parsed": 0,
+ "source_generation_checks": 2,
+ "viewer_manager_requests": 0
+ },
+ "response": {
+ "adapter": "generic",
+ "configured": true,
+ "outputs": [
+ {
+ "artifact": {
+ "artifact_id": "portable-graph.html",
+ "bytes": 718383,
+ "media_type": "text/html; charset=utf-8",
+ "sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30"
+ },
+ "package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
+ "path": ".docforge/portable-graph/architecture.html",
+ "plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b",
+ "publication_id": "2620ce106593d5499931b145a42fc1eccd31f5e356b19bf1d76687f8d45bcad2",
+ "reason": null,
+ "renderer": "portable_graph_html",
+ "renderer_version": "1",
+ "state": "current",
+ "verification": "manifest",
+ "view_id": "architecture"
+ }
+ ],
+ "project_id": "synthetic-1000",
+ "project_root_fingerprint": "0747acfd975703c0",
+ "revision": "unversioned",
+ "source_hash": "a314da4ffa8fcf291ef7a7b0fc87737caeaa89fa3c558ea442afeb0e5ae49c2d",
+ "state": "current",
+ "status": "ok"
+ }
+ },
+ "traced_peak_limit_bytes": 268435456
+ }
+ },
+ "schema_version": 1,
+ "sizes": {
+ "fragment_assisted_manual": {
+ "aggregate_fragment_content_bytes": 516921,
+ "artifact_bytes": 583149,
+ "artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "fragment_count": 1000,
+ "package_bytes": 2364302,
+ "package_id": "ecc21e58106c420a9f78ffbba997778ca98b61586ecfdb23a15b08f431a52c42",
+ "plan_bytes": 1006393,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
+ "receipt_bytes": 664
+ },
+ "manual": {
+ "artifact_bytes": 583149,
+ "artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
+ "package_bytes": 1007297,
+ "package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
+ "plan_bytes": 1006393,
+ "plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
+ "receipt_bytes": 664
+ },
+ "manual_status_response_bytes": 1542,
+ "portable_graph": {
+ "artifact_bytes": 718383,
+ "artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
+ "package_bytes": 398715,
+ "package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
+ "plan_bytes": 398158,
+ "plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b",
+ "receipt_bytes": 657
+ },
+ "portable_graph_status_response_bytes": 879
+ },
+ "source": {
+ "dirty": false,
+ "revision": "f5dccb5e1c312121f1af63780162f593d9363b98"
+ }
+}
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 12c6247..3925f59 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -71,6 +71,19 @@ Existing hand-written client configurations remain valid and are never rewritten
Doctor is inspection-only and does not become a hidden bootstrap, synchronization, or migration
path.
+The following Milestone 3 CLI additions are also additive:
+
+- `docforge graph-plan VIEW_ID`
+- `docforge graph-render VIEW_ID`
+- `docforge graph-render-status [VIEW_ID]`
+- `--manual-render-policy auto|explicit|disabled`
+- `--portable-graph-policy explicit|disabled`
+- `--live-viewer-policy on-demand|disabled`
+
+MCP adds the read-only `docforge_graph_plan` and `docforge_graph_render_status` tools. Portable
+graph publication remains an explicit local CLI integration action. Existing manual render,
+preview, visualization, and status names remain supported.
+
## Versioned data contracts
Milestone 0 preserves:
@@ -96,6 +109,11 @@ Milestone 0 preserves:
- Latest-generation-diff page schema version 1. Pages use one top-level pagination object and a
nested `receipt_header`. `stored_receipt_hash` names the complete stored receipt. Opaque cursors
may be restarted after a server or receipt change and are not durable public identifiers.
+- Manual render-plan schema version 1.
+- Graph view-plan schema version 1.
+- Projection-package schema version 1.
+- Projection-receipt schema version 1.
+- Independent projection-policy schema version 2. Effective policy version 1 remains frozen.
Indexes, attestations, extraction caches, previews, and rendered artifacts are disposable. A schema
change may rebuild them. Canonical project content and stored proposals may not be silently
@@ -161,12 +179,24 @@ The following guarantees remain stable:
## Rendering and visualization
-The `generic_html` renderer remains the supported version-1 manual projection. It retains confined
-paths, raw-HTML suppression, fixed template tokens, deterministic identities, atomic replacement,
-and side-effect-free status.
+The `generic_html` renderer remains the supported version-1 manual projection. Its public
+`GenericHtmlRenderer.prepare()` signature, renderer identity, frozen alpha bytes, confined paths,
+raw-HTML suppression, fixed template tokens, deterministic identities, atomic replacement, and
+side-effect-free status remain compatible. It now delegates through a versioned manual plan,
+immutable package, and independent renderer.
+
+The `portable_graph_html` renderer and `graph_render` descriptor table are additive. Manual and
+portable graph declarations, plans, policies, publication receipts, and status remain separate.
+The portable renderer does not replace the existing live viewer or `docforge_visualize`.
+
+Existing project descriptors may retain any positive `max_render_bytes` accepted by schema version
+1. A value above 20,000,000 bytes does not make the descriptor invalid, and a smaller actual
+artifact still renders. Actual detached worker transfer is a separate fixed 20,000,000-byte
+runtime boundary.
The live graph viewer remains a read-only consumer of a generation-pinned validated index. It does
-not become project authority or MCP retrieval authority.
+not become project authority or MCP retrieval authority. Source reads use the pinned index
+generation instead of reopening mutable canonical files behind that generation.
## Task-context compatibility
@@ -199,8 +229,9 @@ The exact version-1 relation aliases are frozen by the MCP contract and reposito
Changing an alias category requires a new planner version; it is not a silent implementation
detail.
-`ManualRenderPlan`, `GraphViewPlan`, a portable graph renderer, and independently packaged
-renderers are later-milestone direction. Milestone 0 does not claim that those contracts exist.
+Milestone 3 adds `ManualRenderPlanV1`, `GraphViewPlanV1`, projection package and receipt version 1,
+and projection policy version 2. These are additive submodule and schema contracts. They do not
+change the legacy task-context, adapter, changeset, or effective-policy contracts described above.
## Safety boundary
@@ -221,6 +252,8 @@ Milestone 0 records rather than redesigns these areas:
callers use targeted retrieval for that node.
- One individually oversized changeset diff is transported as reconstructable canonical-JSON
chunks. Cursors are corruption-detecting read tokens, not authenticated authorization tokens.
-- Manual planning is not separated from rendering.
-- There is no portable graph-planning or graph-rendering contract.
+- Production fragment validation is currently slower than forced-full rendering at the maintained
+ 1,000-page fixture. Full rendering remains the equivalence and recovery oracle.
+- Remote render services, render farms, third-party renderer ecosystems, and a separate render MCP
+ remain deferred.
- DocForge2 does not self-host its bootstrap documentation.
diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md
index ec2b062..cfbaf30 100644
--- a/docs/CONTRACT.md
+++ b/docs/CONTRACT.md
@@ -24,6 +24,11 @@ commit when Git is available; it cannot change repository state.
- Latest generation-diff page: `schemas/generation-diff-page.schema.json`, version 1.
- Generated client configuration: `schemas/client-configuration.schema.json`, version 1.
- Client doctor result: `schemas/doctor-result.schema.json`, version 1.
+- Manual render plan: `schemas/manual-render-plan.schema.json`, version 1.
+- Graph view plan: `schemas/graph-view-plan.schema.json`, version 1.
+- Projection package: `schemas/projection-package.schema.json`, version 1.
+- Projection receipt: `schemas/projection-receipt.schema.json`, version 1.
+- Independent projection policy: `schemas/projection-policy.schema.json`, version 2.
- Index schema: version 3, disposable and reproducible.
- Index attestation: schema version 1, disposable and reproducible.
- Core, CLI, and MCP server: version 1.3.0.dev0.
@@ -82,7 +87,9 @@ the header alone. One top-level pagination object carries the only continuation
Generated Codex, Claude, and OpenClaw fragments are machine-local projections. They are not
canonical project content. Version 1 binds the selected project, exact isolated Python
interpreter, canonical argument layout, effective policy, no-AST projection, render policy,
-timeouts, artifact bytes, and configuration hash.
+timeouts, artifact bytes, and configuration hash. Milestone 3 adds the version-2 projection policy,
+its hash, projection availability, and the exact descriptor hash to that attested configuration
+evidence. Omitted default selectors are recomposed against the bound descriptor.
Preview is side-effect free. Explicit publication creates only one new private standalone
fragment in an existing real directory. It never merges or replaces different content. Descriptor,
@@ -135,10 +142,12 @@ preview root, and one or more stable view IDs. Each view names a built-in render
derived output file, title, and optional family filter. Paths are resolved under the project root
and may not overlap canonical content, authority files, changesets, templates, or previews.
-The initial `generic_html` renderer uses pinned CommonMark parsing with raw HTML disabled. Templates
-are UTF-8 files with a fixed token vocabulary; they cannot name commands, modules, or executable
+The `generic_html` renderer uses pinned CommonMark parsing with raw HTML disabled. Templates are
+UTF-8 files with a fixed token vocabulary; they cannot name commands, modules, or executable
renderers. Render identity covers the canonical source hash, optional changeset hash, selected node
-and edge identities, view configuration, template hash, renderer contract, and exact parser version.
+and edge identities, view configuration, template hash, renderer contract, and exact parser
+version. The frozen version-1 API and alpha bytes are preserved by a compatibility wrapper over the
+manual plan/package/renderer path.
An explicit CLI render atomically replaces one declared derived output. MCP can render a validated
changeset only to its isolated preview path. Normal status verifies bounded source, configuration,
@@ -146,6 +155,56 @@ template, output, renderer, and publication-receipt identities without reconstru
Explicit deep status remains the side-effect-free full-render oracle. Input changes detected before
atomic replacement fail without publishing a current receipt for stale output.
+## Independent projection boundary
+
+Manual and portable graph plans are separate version-1 contracts over one immutable validated
+generation. They use canonical JSON, deterministic ordering, fixed structural and serialized-size
+bounds, and content-derived identities. Plans contain selected graph facts and bounded content.
+They contain no live project object, database handle, absolute project or index path, arbitrary
+query, command, executable path, or caller-selected module.
+
+Projection packages bind one plan to inert assets, a closed built-in renderer identity, declared
+component versions, and an artifact inventory with a byte allowance. Receipts bind the exact
+package, plan, renderer, artifact hashes and sizes, diagnostics, timing, and detached peak memory.
+Manual and graph renderer modules accept only validated packages. They cannot select nodes, invent
+relationships, read project state, choose publication paths, or write canonical files.
+
+Detached execution uses one fixed private Python module, isolated mode, a trusted working
+directory, a sanitized environment, exactly one canonical newline-terminated JSON request and
+response, a closed renderer allowlist, a 30-second timeout, disk-spooled stdout, and bounded reads. The package
+contract is capped at 24,000,000 bytes and actual detached artifact transfer at 20,000,000 bytes.
+Project descriptors may retain a larger `max_render_bytes` compatibility allowance, but an actual
+detached transfer above the fixed worker boundary fails closed.
+
+Portable graph configuration is independent of manual render configuration. One view selects
+either an exact root or a bounded metadata-only lexical query plus closed filters and node, edge,
+depth, and work limits. Logic is excluded. The renderer emits a complete static Nodes, Flow, or Web
+artifact and uses JavaScript only as progressive enhancement.
+
+Portable publication commits a content-addressed artifact, renderer receipt, and one bounded
+generation/view manifest in that order. The manifest is the publication commit. Status reads only
+bounded manifest and receipt evidence and never plans or renders. Repair restores declared output
+only from validated content-addressed evidence. A failure after a replacement that cannot be
+proven rolled back returns explicit degraded committed evidence.
+
+Manual fragment records are disposable semantic cache entries. Their keys bind the renderer,
+component version, and complete page semantics. The detached renderer recomputes the expected page
+fragment before accepting cached bytes. Cold creation is compared with a full detached render
+before cache publication. Invalid, corrupt, forged, stale, individually oversized, or
+aggregate-oversized records fall back to the full oracle. The dedicated cache retains only current
+keys and is capped at 10,000 entries and 64,000,000 bytes.
+
+Projection policy version 2 composes manual `auto|explicit|disabled`, portable graph
+`explicit|disabled`, and live viewer `on-demand|disabled` independently. Active plan, render,
+application, onboarding, and viewer-start operations enforce the relevant policy before hidden
+work. Receipt-only status and explicit viewer stop remain available. Effective policy version 1
+and its legacy projections remain unchanged.
+
+The live viewer remains separate from portable graph publication. It consumes one
+generation-pinned validated index through the viewer manager. Source reads come from that pinned
+generation and do not reopen mutable canonical files behind an older snapshot. Neither live nor
+portable visualization is retrieval or canonical authority.
+
Normal MCP access does not expose canonical application. An explicitly configured canonical
applier registers one hash-bound application tool. No MCP mode exposes arbitrary renderer
execution, arbitrary file writes, shell commands, Git mutation, build commands, deployment, or
diff --git a/docs/MILESTONE_3_BASELINE.md b/docs/MILESTONE_3_BASELINE.md
new file mode 100644
index 0000000..6c95702
--- /dev/null
+++ b/docs/MILESTONE_3_BASELINE.md
@@ -0,0 +1,90 @@
+# Milestone 3 baseline
+
+## Scope and method
+
+This baseline records the independent-projection behavior completed in Milestone 3. It was
+captured on 2026-07-29 from clean candidate commit
+`f5dccb5e1c312121f1af63780162f593d9363b98`.
+
+The maintained command was:
+
+```bash
+.venv/bin/python tools/milestone3_benchmark.py \
+ --mode full \
+ --nodes 1000 \
+ --samples 10 \
+ --output benchmarks/milestone3-2026-07-29.json
+```
+
+The synthetic generic fixture contains 1,000 manual pages, 1,000 portable-graph nodes, and 999
+edges. Durations use `time.perf_counter_ns()` and nearest-rank p95. In-process peak memory uses
+`tracemalloc`; detached worker peak memory comes from the worker receipt and `RUSAGE_SELF`.
+Every measured result is checked for deterministic semantic identity and bounded response size.
+
+Environment:
+
+- Linux 7.1.3-200.nobara.fc44.x86_64.
+- CPython 3.14.6.
+- x86_64.
+- Ten samples except the one-time production cold render and fragment-cache population.
+- In-process and detached-worker memory ceiling: 268,435,456 bytes.
+- Detached artifact-transfer ceiling: 20,000,000 bytes.
+
+The complete machine-readable result is
+[`benchmarks/milestone3-2026-07-29.json`](../benchmarks/milestone3-2026-07-29.json).
+
+## Results
+
+| Operation | Median | p95 | Limit | Maximum response |
+|---|---:|---:|---:|---:|
+| Manual full plan/package/render | 801.948 ms | 810.490 ms | 15,000 ms | 664 B |
+| Manual detached worker | 591.317 ms | 599.394 ms | 20,000 ms | 667 B |
+| Fragment-assisted equivalence | 638.999 ms | 666.119 ms | 15,000 ms | 664 B |
+| Production incremental cold | 2,827.152 ms | 2,827.152 ms | 20,000 ms | 668 B |
+| Production incremental warm | 2,143.388 ms | 2,206.540 ms | 20,000 ms | 669 B |
+| Production forced full | 944.135 ms | 978.870 ms | 20,000 ms | 668 B |
+| Fragment cache miss sweep | 106.203 ms | 106.888 ms | 5,000 ms | 145 B |
+| Fragment cache hit sweep | 498.911 ms | 514.324 ms | 5,000 ms | 145 B |
+| Portable graph full plan/package/render | 303.736 ms | 323.690 ms | 10,000 ms | 657 B |
+| Portable graph detached worker | 265.448 ms | 268.428 ms | 20,000 ms | 660 B |
+| Manual receipt-only status | 62.881 ms | 111.381 ms | 500 ms | 1,542 B |
+| Portable graph receipt-only status | 58.467 ms | 59.331 ms | 500 ms | 879 B |
+
+The largest traced in-process peak was 35,160,716 bytes. The direct manual worker track peaked at
+88,580,096 bytes and the portable graph worker at 89,583,616 bytes. The production manual paths,
+including cold, warm, forced-full, and mutation variants, peaked at 104,771,584 bytes. Every child
+peak was validated from its projection receipt against the 268,435,456-byte gate.
+
+The manual artifact was 583,149 bytes. The portable graph artifact was 718,383 bytes. The manual
+plan was 1,006,393 bytes and its ordinary package was 1,007,297 bytes. The graph plan was 398,158
+bytes and its package was 398,715 bytes.
+
+## Equivalence and no-work gates
+
+The benchmark proved exact output equivalence for:
+
+- Manual in-process and detached rendering.
+- Manual full and fragment-assisted rendering.
+- Production cold, warm, and forced-full rendering.
+- Production add, change, delete, and reorder variants.
+- Portable graph in-process and detached rendering.
+
+Manual and portable-graph status each performed zero project loads, source parses, adapter
+projection loads, adapter extraction, index checks, synchronization, index builds, render
+preparation, output construction, output hashing, and viewer-manager requests. Each status path
+performed only two cheap source-generation checks and verified committed receipt or manifest
+evidence.
+
+## Measured limits and future notes
+
+- Fragment reuse is a correctness, isolation, and recovery boundary in this milestone. At 1,000
+ pages, production warm fragment validation is slower than the forced-full path. Later
+ optimization must start from this measurement and preserve byte equivalence.
+- Full rendering remains the oracle and recovery path. Invalid, corrupt, oversized, stale, or
+ mismatched fragment records fall back without changing canonical facts.
+- The benchmark main-process `ru_maxrss` value was 102,692 KiB. It is cumulative across all
+ main-process operations and is recorded only as diagnostic context. Detached child peaks are
+ measured separately. Per-operation traced peaks and every detached receipt peak own the memory
+ gates.
+- The results do not justify a storage rewrite, render farm, remote renderer, or separate render
+ MCP.
diff --git a/docs/MILESTONE_3_CLOSEOUT.md b/docs/MILESTONE_3_CLOSEOUT.md
new file mode 100644
index 0000000..c46c193
--- /dev/null
+++ b/docs/MILESTONE_3_CLOSEOUT.md
@@ -0,0 +1,89 @@
+# Milestone 3 closeout
+
+## Outcome
+
+Milestone 3 is complete. Manual compilation, portable graph rendering, and the live viewer are
+separate generation-pinned consumers of the validated graph. They cannot become canonical or
+retrieval authority.
+
+Implemented contracts:
+
+- Version-1 `ManualRenderPlan`, `GraphViewPlan`, projection package, and projection receipt.
+- Strict canonical JSON identities and packaged Draft 2020-12 schemas.
+- Independent manual and portable-graph renderer import boundaries.
+- One isolated, fixed, one-request detached worker protocol with bounded request, response,
+ artifact, timeout, environment, and renderer inventory.
+- Content-addressed portable graph artifacts, renderer receipts, generation/view manifests,
+ receipt-only status, repair, and degraded committed-publication evidence.
+- Disposable semantic fragment records with bounded cache inventory, corruption recovery, and
+ full-render equivalence.
+- Version-2 independent projection policy while preserving version-1 effective-policy behavior.
+- Generation-pinned live source reads and a separate read-only viewer-manager lifecycle.
+- Automated axe-tag and keyboard gates for the manual, portable graph, and live viewer.
+- Repository-native contract, smoke, scale, response-size, memory, and equivalence gates.
+
+## Candidate evidence
+
+The frozen implementation candidate is
+`f5dccb5e1c312121f1af63780162f593d9363b98`.
+
+The complete repository gate passed:
+
+- Ruff formatting and lint.
+- HTML, rendered-manual HTML, portable-graph HTML, CSS, and JavaScript checks.
+- Pyright with zero diagnostics.
+- Warning-strict compilation and tests.
+- 281 tests and 272 subtests.
+- Three Playwright and axe accessibility flows. The alpha manual is checked with WCAG 2.0/2.1
+ A/AA axe tags; portable and live graph flows add WCAG 2.2 A/AA tags and keyboard interaction.
+- Lock and npm dependency-tree checks.
+- Wheel and source-distribution builds.
+- Milestone 0, 1, 2, and 3 smoke benchmarks.
+
+The maintained projection contract subset passed 142 tests and 236 subtests. A 10,000-node deep
+chain and one 10,000-node strongly connected component prove that manual cycle planning has no
+recursion-depth failure.
+
+An isolated wheel installation passed CLI and MCP startup, a real detached manual render, and the
+closed malformed-worker-request contract. Six Milestone 3 commits and the complete candidate tree
+passed Gitleaks 8.30.1 with no findings.
+
+Three independent adversarial review tracks covered manual isolation and fragment integrity,
+portable publication and policy binding, and worker/accessibility/benchmark gates. Reproduced
+project import, hostile environment, unbounded stdout, fragment forgery, cache growth, aggregate
+overflow, coordinated policy drift, render-limit compatibility, deep-graph, and module-startup
+defects were fixed and regression-tested before closeout.
+
+The clean ten-sample 1,000-node benchmark passed every threshold. Exact measurements, equivalence
+results, memory peaks, and response sizes are recorded in
+[`MILESTONE_3_BASELINE.md`](MILESTONE_3_BASELINE.md) and
+[`benchmarks/milestone3-2026-07-29.json`](../benchmarks/milestone3-2026-07-29.json).
+
+## Preserved boundaries
+
+- The `docforge` distribution, package, CLI, MCP executable, and existing tool names remain.
+- The frozen alpha manual remains exactly 2,043 bytes with its legacy output hash and render
+ identity.
+- Legacy one-method `load_projection()` adapters remain supported.
+- Effective policy version 1, no-AST behavior, and existing client bindings remain compatible.
+- Project descriptor schema version 1 and SQLite index schema version 3 remain unchanged.
+- Configured `max_render_bytes` values above the detached transfer ceiling still load; a small
+ actual artifact renders normally. Actual detached transfer remains capped at 20,000,000 bytes.
+- No storage replacement or self-hosting dependency was introduced.
+- WorldForge and ScrapeStation were not touched.
+- No production MCP integration was repointed.
+- The legacy Forgejo repository and `legacy` remote were not changed.
+- No tag, release, release announcement, or visibility change was created.
+
+## Known follow-up work
+
+Milestone 4 remains directional and is not active. Its adapter SDK and product-documentation work
+must not silently absorb these separate future ideas:
+
+- Optimize production fragment reuse only from measured profiles while preserving the forced-full
+ oracle.
+- Add authenticated cursors only if a stronger threat model requires them.
+- Verify Claude's native timeout representation.
+- Add versioned adapter-owned launcher metadata before generating custom-adapter configurations.
+- Keep remote render services, shared render farms, third-party renderers, storage replacement,
+ and self-hosting deferred until their own evidence justifies them.
diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md
index a92ed1b..640f9ae 100644
--- a/docs/USER_MANUAL.md
+++ b/docs/USER_MANUAL.md
@@ -27,6 +27,10 @@ incremental methods while retaining the full loader as a fallback.
- Opt-in incremental adapter extraction with reverse-dependency invalidation.
- Lazy function-scoped logic projections that do not densify the primary graph.
- Declared HTML render views. Arbitrary templates, render commands, and output paths are rejected.
+- Separate versioned manual and portable graph plans, immutable packages, detached built-in
+ renderers, and validated receipts.
+- Content-addressed portable Nodes/Flow/Web artifacts with receipt-only status and repair.
+- Independent manual, portable-graph, and live-viewer policy.
- A loopback-only graph browser with Nodes, semantic Flow, and convergence Web views,
relationship keys, source inspection, branch-aware node hiding, panel resizing, zooming, and
managed idle shutdown.
@@ -47,7 +51,11 @@ Canonical files own facts:
canonical Markdown/TOML or adapter sources
↓ validate
disposable SQLite graph
- ↓ query / visualize / compile context
+ ├── query / compile context
+ ├── ManualRenderPlanV1 → detached manual renderer → declared manual
+ ├── GraphViewPlanV1 → detached graph renderer → portable Nodes/Flow/Web artifact
+ └── pinned index → managed live Nodes/Flow/Web/Logic viewer
+ ↓
people and agents
↓ propose
isolated changeset + preview
@@ -77,8 +85,8 @@ source format.
Clone and verify DocForge:
```bash
-git clone forgejo@repo.andraxion.net:administrator/DocForge.git /absolute/path/DocForge
-cd /absolute/path/DocForge
+git clone forgejo@repo.andraxion.net:administrator/DocForge2.git /absolute/path/DocForge2
+cd /absolute/path/DocForge2
uv sync --group dev
npm ci
@@ -159,6 +167,27 @@ output = "Docs/Rendered/Manual.html"
title = "My Project Manual"
families = ["architecture", "system", "operations", "roadmap"]
+[graph_render]
+output_root = ".docforge/portable-graph"
+
+[[graph_render.views]]
+id = "architecture"
+renderer = "portable_graph_html"
+output = "architecture.html"
+title = "Architecture"
+root = "architecture.overview"
+initial_mode = "web"
+depth = 3
+max_nodes = 250
+max_edges = 1000
+max_work = 100000
+families = ["architecture", "system"]
+relations = ["depends_on", "owns", "calls", "reads", "writes", "tested_by", "relates_to"]
+authorities = []
+statuses = ["current", "active", "verified"]
+tags = []
+include_logic = false
+
[graph]
allowed_relations = ["depends_on", "owns", "calls", "reads", "writes", "tested_by", "relates_to"]
@@ -492,13 +521,25 @@ builds or repairs the index.
```text
render-status [VIEW_ID] [--deep]
render VIEW_ID
+graph-plan VIEW_ID
+graph-render VIEW_ID
+graph-render-status [VIEW_ID]
preview CHANGESET_ID VIEW_ID
apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID
```
+`graph-plan` validates and returns one declared `GraphViewPlanV1` without publishing. A portable
+view must select exactly one stable `root` or metadata-only lexical `query`. It may use only Nodes,
+Flow, or Web as `initial_mode`; portable version 1 excludes function-scoped Logic.
+
+`graph-render` explicitly publishes the declared static artifact, content-addressed renderer
+evidence, and generation/view manifest. `graph-render-status` verifies only bounded committed
+evidence and never plans or renders. Portable publication is a local CLI action.
+
The CLI apply command supports the generic adapter. It verifies that the configured writer owns the
changeset, applies the exact reviewed hash, rebuilds the index, checks it, and regenerates every
-declared render. It does not commit or push the result.
+declared manual render when manual policy is `auto`. It does not publish portable graphs, commit,
+or push the result.
### Viewer commands
@@ -549,6 +590,20 @@ declares the named writer, and application requires the same writer/applier iden
`--no-ast` to preserve the no-AST binding. Generic CLI generation refuses project-owned adapters
because it cannot safely reconstruct their composition.
+Select projection behavior independently:
+
+```bash
+docforge configure codex \
+ --project /absolute/path/MyProject \
+ --manual-render-policy explicit \
+ --portable-graph-policy disabled \
+ --live-viewer-policy on-demand
+```
+
+The generated version-1 configuration result carries an additive version-2 `projection_policy`,
+its hash, projection availability, and the exact descriptor hash. Omitted default selectors are
+validated against that descriptor rather than trusted as self-reported output.
+
Inspect one configured client binding:
```bash
@@ -569,6 +624,54 @@ starts MCP, executes the configured command, synchronizes, builds, renders, star
writes configuration. Claude timeout representation and client filtering that cannot be proved
locally remain explicit warnings.
+## Independent projection behavior
+
+Manual and portable graph renderers consume immutable, path-free packages. A package binds one
+generation-pinned plan, inert assets, fixed component versions, a built-in renderer identity, and
+an exact artifact inventory. The detached child cannot select nodes, open the project or index,
+choose a publication path, execute project code, or mutate canonical facts.
+
+Child startup is fixed to isolated Python, a private module entrypoint, a trusted working
+directory, and a sanitized environment. One request and response use canonical newline-terminated
+JSON. The request, response, receipt, execution time, and disk-spooled stdout are bounded. Actual
+artifact transfer is capped at 20,000,000 bytes even when the descriptor retains a larger
+`max_render_bytes` compatibility value.
+
+Manual fragment records are disposable semantic cache entries. On a cold miss, DocForge performs a
+trusted full detached render, extracts candidate page fragments, and compares fragment-assisted
+output byte-for-byte before publishing records. On a warm hit, the worker recomputes each expected
+page fragment before accepting cached bytes. Corrupt, forged, stale, individually oversized, or
+aggregate-oversized records fall back to the full oracle. Fragment reuse is currently a correctness
+and recovery boundary, not a promised speedup.
+
+Projection policy version 2 is:
+
+```text
+manual: auto | explicit | disabled
+portable_graph: explicit | disabled
+live_viewer: on-demand | disabled
+```
+
+For ordinary CLI commands, place the corresponding global flag before the subcommand:
+
+```bash
+docforge --project-root "$PROJECT" --manual-render-policy disabled render manual
+docforge --project-root "$PROJECT" --portable-graph-policy disabled graph-plan architecture
+docforge --project-root "$PROJECT" --live-viewer-policy disabled visualize
+```
+
+An active operation blocked by policy returns `projection_policy_forbids_operation` before hidden
+work. Manual and portable receipt-only status remain available. Viewer status and explicit stop
+remain available when viewer start is disabled.
+
+A non-disabled projection also requires its declared configuration or runtime. Manual `explicit`
+requires manual render configuration. Manual `auto` additionally requires canonical application in
+the current operation or server capability. Portable graph `explicit` requires portable graph
+render configuration, and live viewer `on-demand` requires its runtime. An unavailable selection
+returns `projection_policy_unavailable` before work begins. In particular, ordinary CLI `render`
+operations cannot select manual `auto`; use `explicit`, or let a configured canonical `apply`
+operation own automatic regeneration.
+
## MCP usage
Run one MCP server per project with absolute paths:
@@ -586,7 +689,10 @@ Select the session's declared surface explicitly when useful:
```bash
docforge-mcp \
--project-root /absolute/path/MyProject \
- --capability-mode read
+ --capability-mode read \
+ --manual-render-policy explicit \
+ --portable-graph-policy explicit \
+ --live-viewer-policy on-demand
```
Supported modes are `read`, `proposal`, `application`, and `operator`. Existing startup defaults
@@ -613,8 +719,9 @@ identity, not a command. The changeset creator, configured writer, and canonical
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.
+and a recommended first operation. The result also carries the independently composed version-2
+`projection_policy` and hash. Workflow guidance does not recommend registration or application
+when those startup capabilities are unavailable.
Example MCP client configuration:
@@ -653,11 +760,16 @@ Example MCP client configuration:
- `docforge_get_task_context`
- `docforge_validate_project`
- `docforge_render_status`
+- `docforge_graph_plan`
+- `docforge_graph_render_status`
- `docforge_visualize`
- `docforge_visualization_status`
- `docforge_stop_visualization`
- `docforge_get_generation_diff`
+MCP graph plan and status are read-only. MCP does not expose portable graph publication; use the
+explicit local `graph-render` CLI command.
+
### Proposal tools
- `docforge_create_changeset`
@@ -976,8 +1088,29 @@ docforge --project-root "$PROJECT" render-status
docforge --project-root "$PROJECT" render VIEW_ID
```
-Successful canonical apply regenerates all declared views automatically. A manual canonical edit
-requires reindexing and rendering.
+Successful canonical apply regenerates declared manual views only when manual policy is `auto`.
+A manual canonical edit requires reindexing and explicit rendering. Portable graph publication
+always remains a separate explicit CLI action.
+
+Portable graph publication has separate status and policy:
+
+```bash
+docforge --project-root "$PROJECT" graph-render-status
+docforge --project-root "$PROJECT" graph-render architecture
+```
+
+### `projection_policy_forbids_operation`
+
+The process was deliberately started with the relevant manual, portable-graph, or live-viewer
+operation disabled. Restart with an allowed selector after confirming that the integration should
+receive that capability. Status and explicit stop operations remain available as described above.
+
+### `projection_policy_unavailable`
+
+The selected non-disabled projection has no matching project configuration or runtime. Add the
+declared manual or portable graph render configuration, or make the live viewer runtime available,
+before selecting that mode. Manual `auto` also requires an operation or MCP server with canonical
+application enabled. Use manual `explicit` for a standalone CLI render.
### Descriptor changed after startup
@@ -992,8 +1125,11 @@ Run the complete release gate from the DocForge repository:
make gate
```
-Use `make benchmark` for the historical Milestone 0 baseline and `make benchmark-m1` for the
-counter-gated 1,000-node warm-operation benchmark.
+Use `make benchmark` for the historical Milestone 0 baseline, `make benchmark-m1` for the
+counter-gated warm-operation benchmark, `make benchmark-m2` for agent workflow gates, and
+`make benchmark-m3-full` for the ten-sample 1,000-node projection, worker, fragment, status,
+equivalence, response-size, and memory gates. `make accessibility` runs the generated manual,
+portable graph, and live viewer axe and keyboard flows.
Project-specific vocabulary, extraction rules, and serialization belong in the project adapter.
Generic core behavior must remain deterministic, project-bound, and recoverable.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0de1999..5fc5cd0 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -21,4 +21,26 @@ export default [
"prefer-const": "error",
},
},
+ {
+ files: ["playwright.accessibility.config.mjs", "tests/accessibility.spec.mjs"],
+ ...js.configs.recommended,
+ languageOptions: {
+ ecmaVersion: 2024,
+ sourceType: "module",
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ linterOptions: {
+ reportUnusedDisableDirectives: "error",
+ },
+ rules: {
+ ...js.configs.recommended.rules,
+ eqeqeq: "error",
+ "no-implicit-coercion": "error",
+ "no-var": "error",
+ "prefer-const": "error",
+ },
+ },
];
diff --git a/package-lock.json b/package-lock.json
index d61baea..62e1957 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,9 @@
"name": "docforge-web-quality",
"version": "0.0.0",
"devDependencies": {
+ "@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
+ "@playwright/test": "1.62.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"html-validate": "11.5.6",
@@ -18,6 +20,19 @@
"stylelint-csstree-validator": "4.0.0"
}
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
+ "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.12.1"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -515,6 +530,22 @@
"node": ">= 8"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
+ "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -635,6 +666,16 @@
"node": ">=8"
}
},
+ "node_modules/axe-core": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
+ "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1943,6 +1984,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
diff --git a/package.json b/package.json
index edad8d0..2e654a8 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,14 @@
"private": true,
"packageManager": "npm@10.9.7",
"scripts": {
- "lint:web": "uv run python tools/check_web_assets.py"
+ "install:accessibility-browser": "playwright install chromium",
+ "lint:web": "uv run python tools/check_web_assets.py && eslint --max-warnings=0 playwright.accessibility.config.mjs tests/accessibility.spec.mjs",
+ "test:accessibility": "npm run install:accessibility-browser && playwright test --config=playwright.accessibility.config.mjs"
},
"devDependencies": {
+ "@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
+ "@playwright/test": "1.62.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"html-validate": "11.5.6",
diff --git a/playwright.accessibility.config.mjs b/playwright.accessibility.config.mjs
new file mode 100644
index 0000000..7a7c340
--- /dev/null
+++ b/playwright.accessibility.config.mjs
@@ -0,0 +1,24 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests",
+ testMatch: "accessibility.spec.mjs",
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ reporter: "line",
+ outputDir: "/tmp/docforge-playwright-accessibility",
+ timeout: 30_000,
+ expect: {
+ timeout: 5_000,
+ },
+ use: {
+ browserName: "chromium",
+ bypassCSP: true,
+ headless: true,
+ viewport: {
+ width: 1440,
+ height: 1000,
+ },
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
index 20c1ec8..6024c3e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,10 @@ docforge-mcp = "docforge.mcp_server:main"
docforge-viewer-manager = "docforge.viewer_manager:main"
[tool.hatch.build.targets.wheel]
-packages = ["src/docforge"]
+packages = ["src/docforge", "src/docforge_renderers"]
+
+[tool.hatch.build.targets.wheel.force-include]
+schemas = "docforge/schemas"
[tool.ruff]
line-length = 100
diff --git a/schemas/client-configuration.schema.json b/schemas/client-configuration.schema.json
index b4cf208..1cf7c6a 100644
--- a/schemas/client-configuration.schema.json
+++ b/schemas/client-configuration.schema.json
@@ -127,6 +127,17 @@
},
"additionalProperties": false
},
+ "projection_policy": {
+ "type": "object",
+ "required": ["schema_version", "manual", "portable_graph", "live_viewer"],
+ "properties": {
+ "schema_version": { "const": 2 },
+ "manual": { "enum": ["auto", "explicit", "disabled"] },
+ "portable_graph": { "enum": ["explicit", "disabled"] },
+ "live_viewer": { "enum": ["on-demand", "disabled"] }
+ },
+ "additionalProperties": false
+ },
"diagnostics": {
"type": "object",
"required": [
@@ -211,6 +222,9 @@
"project",
"binding",
"effective_policy",
+ "projection_policy",
+ "projection_policy_hash",
+ "projection_availability",
"artifact",
"configuration_hash",
"warnings"
@@ -231,7 +245,8 @@
"project_id",
"project_root",
"project_root_fingerprint",
- "adapter"
+ "adapter",
+ "descriptor_hash"
],
"properties": {
"project_id": { "type": "string", "minLength": 1 },
@@ -240,7 +255,8 @@
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
- "adapter": { "type": "string", "minLength": 1 }
+ "adapter": { "type": "string", "minLength": 1 },
+ "descriptor_hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
@@ -317,6 +333,24 @@
"additionalProperties": false
},
"effective_policy": { "$ref": "#/$defs/effective_policy" },
+ "projection_policy": { "$ref": "#/$defs/projection_policy" },
+ "projection_policy_hash": { "$ref": "#/$defs/sha256" },
+ "projection_availability": {
+ "type": "object",
+ "required": [
+ "manual_configured",
+ "portable_graph_configured",
+ "application_enabled",
+ "live_viewer_available"
+ ],
+ "properties": {
+ "manual_configured": { "type": "boolean" },
+ "portable_graph_configured": { "type": "boolean" },
+ "application_enabled": { "type": "boolean" },
+ "live_viewer_available": { "const": true }
+ },
+ "additionalProperties": false
+ },
"artifact": {
"type": "object",
"required": [
diff --git a/schemas/graph-view-plan.schema.json b/schemas/graph-view-plan.schema.json
new file mode 100644
index 0000000..248ab07
--- /dev/null
+++ b/schemas/graph-view-plan.schema.json
@@ -0,0 +1,321 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docforge.local/schema/graph-view-plan-v1.json",
+ "title": "DocForge immutable portable graph view plan",
+ "$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator. The version-1 nested graph vocabulary remains planner-owned.",
+ "$defs": {
+ "sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "project": {
+ "type": "object",
+ "required": [
+ "project_id",
+ "project_root_fingerprint",
+ "adapter",
+ "revision",
+ "source_hash"
+ ],
+ "properties": {
+ "project_id": { "type": "string", "minLength": 1 },
+ "project_root_fingerprint": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{16}$"
+ },
+ "adapter": { "type": "string", "minLength": 1 },
+ "revision": { "type": "string", "minLength": 1 },
+ "source_hash": { "$ref": "#/$defs/sha256" }
+ },
+ "additionalProperties": false
+ },
+ "filter": {
+ "type": "array",
+ "maxItems": 64,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "uniqueItems": true
+ },
+ "edge": {
+ "type": "object",
+ "required": ["source_id", "relation", "target_id"],
+ "properties": {
+ "source_id": { "type": "string", "minLength": 1 },
+ "relation": { "type": "string", "minLength": 1 },
+ "target_id": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "node": {
+ "type": "object",
+ "required": [
+ "node_id",
+ "title",
+ "family",
+ "authority",
+ "status",
+ "tags",
+ "summary",
+ "content_hash"
+ ],
+ "properties": {
+ "node_id": { "type": "string", "minLength": 1 },
+ "title": { "type": "string", "minLength": 1 },
+ "family": { "type": "string", "minLength": 1 },
+ "authority": { "type": "string", "minLength": 1 },
+ "status": { "type": "string", "minLength": 1 },
+ "tags": { "$ref": "#/$defs/filter" },
+ "summary": { "type": "string" },
+ "content_hash": { "$ref": "#/$defs/sha256" }
+ },
+ "additionalProperties": false
+ },
+ "omission": {
+ "oneOf": [
+ {
+ "type": "object",
+ "required": ["code", "subject", "limit", "minimum_omitted"],
+ "properties": {
+ "code": { "const": "node_result_limit" },
+ "subject": { "const": "nodes" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 1000 },
+ "minimum_omitted": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "required": ["code", "subject", "limit", "minimum_omitted"],
+ "properties": {
+ "code": { "const": "edge_result_limit" },
+ "subject": { "const": "edges" },
+ "limit": { "type": "integer", "minimum": 0, "maximum": 4000 },
+ "minimum_omitted": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "required": [
+ "code",
+ "subject",
+ "limit",
+ "examined",
+ "minimum_omitted"
+ ],
+ "properties": {
+ "code": { "const": "work_limit" },
+ "subject": { "const": "selection" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 1000000 },
+ "examined": { "type": "integer", "minimum": 0 },
+ "minimum_omitted": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "required": ["code", "subject", "minimum_omitted"],
+ "properties": {
+ "code": { "const": "logic_forbidden" },
+ "subject": { "const": "logic" },
+ "minimum_omitted": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "bounds",
+ "policy",
+ "graph",
+ "omissions",
+ "diagnostics"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.graph-view-plan" },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "project": { "$ref": "#/$defs/project" },
+ "view": {
+ "type": "object",
+ "required": [
+ "view_id",
+ "title",
+ "initial_mode",
+ "scope",
+ "filters",
+ "detail_fields"
+ ],
+ "properties": {
+ "view_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "initial_mode": { "enum": ["nodes", "flow", "web", "logic"] },
+ "scope": {
+ "oneOf": [
+ {
+ "type": "object",
+ "required": ["kind", "root_node_id", "depth"],
+ "properties": {
+ "kind": { "const": "exact_root" },
+ "root_node_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "depth": { "type": "integer", "minimum": 1, "maximum": 32 }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "required": ["kind", "query"],
+ "properties": {
+ "kind": { "const": "lexical" },
+ "query": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 10000
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
+ },
+ "filters": {
+ "type": "object",
+ "required": [
+ "families",
+ "relations",
+ "authorities",
+ "statuses",
+ "tags"
+ ],
+ "properties": {
+ "families": { "$ref": "#/$defs/filter" },
+ "relations": { "$ref": "#/$defs/filter" },
+ "authorities": { "$ref": "#/$defs/filter" },
+ "statuses": { "$ref": "#/$defs/filter" },
+ "tags": { "$ref": "#/$defs/filter" }
+ },
+ "additionalProperties": false
+ },
+ "detail_fields": {
+ "const": [
+ "node_id",
+ "title",
+ "family",
+ "authority",
+ "status",
+ "tags",
+ "summary",
+ "content_hash"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "bounds": {
+ "type": "object",
+ "required": ["depth", "max_nodes", "max_edges", "max_work"],
+ "properties": {
+ "depth": { "type": "integer", "minimum": 1, "maximum": 32 },
+ "max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 },
+ "max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
+ "max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 }
+ },
+ "additionalProperties": false
+ },
+ "policy": {
+ "type": "object",
+ "required": [
+ "visibility",
+ "source_paths",
+ "source_bodies",
+ "database_queries",
+ "executable_content",
+ "logic",
+ "logic_requested"
+ ],
+ "properties": {
+ "visibility": { "const": "selected_graph_only" },
+ "source_paths": { "const": "excluded" },
+ "source_bodies": { "const": "excluded" },
+ "database_queries": { "const": "forbidden" },
+ "executable_content": { "const": "forbidden" },
+ "logic": { "enum": ["allowed", "forbidden"] },
+ "logic_requested": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "graph": {
+ "type": "object",
+ "required": ["root_node_id", "nodes", "edges", "logic_projections"],
+ "properties": {
+ "root_node_id": {
+ "type": ["string", "null"],
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "nodes": {
+ "type": "array",
+ "maxItems": 1000,
+ "items": { "$ref": "#/$defs/node" }
+ },
+ "edges": {
+ "type": "array",
+ "maxItems": 4000,
+ "items": { "$ref": "#/$defs/edge" }
+ },
+ "logic_projections": {
+ "type": "array",
+ "maxItems": 0
+ }
+ },
+ "additionalProperties": false
+ },
+ "omissions": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "$ref": "#/$defs/omission" }
+ },
+ "diagnostics": {
+ "type": "object",
+ "required": [
+ "selection",
+ "returned_nodes",
+ "returned_edges",
+ "examined_work_units",
+ "truncated",
+ "ordering"
+ ],
+ "properties": {
+ "selection": { "enum": ["exact_root", "lexical"] },
+ "returned_nodes": { "type": "integer", "minimum": 0, "maximum": 1000 },
+ "returned_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
+ "examined_work_units": { "type": "integer", "minimum": 0 },
+ "truncated": { "type": "boolean" },
+ "ordering": { "const": "node_id;source_id,relation,target_id" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/manual-render-plan.schema.json b/schemas/manual-render-plan.schema.json
new file mode 100644
index 0000000..307bce3
--- /dev/null
+++ b/schemas/manual-render-plan.schema.json
@@ -0,0 +1,214 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docforge.local/schema/manual-render-plan-v1.json",
+ "title": "DocForge immutable manual render plan",
+ "$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator.",
+ "$defs": {
+ "sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "project": {
+ "type": "object",
+ "required": [
+ "project_id",
+ "project_root_fingerprint",
+ "adapter",
+ "revision",
+ "source_hash"
+ ],
+ "properties": {
+ "project_id": { "type": "string", "minLength": 1 },
+ "project_root_fingerprint": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{16}$"
+ },
+ "adapter": { "type": "string", "minLength": 1 },
+ "revision": { "type": "string", "minLength": 1 },
+ "source_hash": { "$ref": "#/$defs/sha256" }
+ },
+ "additionalProperties": false
+ },
+ "edge": {
+ "type": "object",
+ "required": ["source_id", "relation", "target_id"],
+ "properties": {
+ "source_id": { "type": "string", "minLength": 1 },
+ "relation": { "type": "string", "minLength": 1 },
+ "target_id": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "page": {
+ "type": "object",
+ "required": [
+ "node_id",
+ "title",
+ "family",
+ "authority",
+ "status",
+ "tags",
+ "summary",
+ "content",
+ "content_hash",
+ "components",
+ "breadcrumbs",
+ "cross_references",
+ "backlinks"
+ ],
+ "properties": {
+ "node_id": { "type": "string", "minLength": 1 },
+ "title": { "type": "string", "minLength": 1 },
+ "family": { "type": "string", "minLength": 1 },
+ "authority": { "type": "string", "minLength": 1 },
+ "status": { "type": "string", "minLength": 1 },
+ "tags": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "summary": { "type": "string" },
+ "content": { "type": "string" },
+ "content_hash": { "$ref": "#/$defs/sha256" },
+ "components": {
+ "type": "array",
+ "maxItems": 32,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "breadcrumbs": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "cross_references": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "$ref": "#/$defs/edge" }
+ },
+ "backlinks": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "$ref": "#/$defs/edge" }
+ }
+ },
+ "additionalProperties": false
+ },
+ "navigation_item": {
+ "type": "object",
+ "required": ["node_id", "title"],
+ "properties": {
+ "node_id": { "type": "string", "minLength": 1 },
+ "title": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "search_document": {
+ "type": "object",
+ "required": [
+ "node_id",
+ "title",
+ "summary",
+ "family",
+ "status",
+ "tags"
+ ],
+ "properties": {
+ "node_id": { "type": "string", "minLength": 1 },
+ "title": { "type": "string", "minLength": 1 },
+ "summary": { "type": "string" },
+ "family": { "type": "string", "minLength": 1 },
+ "status": { "type": "string", "minLength": 1 },
+ "tags": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "changeset_hash",
+ "pages",
+ "navigation",
+ "search_documents",
+ "diagnostics"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.manual-render-plan" },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "project": { "$ref": "#/$defs/project" },
+ "view": {
+ "type": "object",
+ "required": ["view_id", "title", "families", "renderer"],
+ "properties": {
+ "view_id": { "type": "string", "minLength": 1 },
+ "title": { "type": "string", "minLength": 1 },
+ "families": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "renderer": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "changeset_hash": {
+ "oneOf": [
+ { "$ref": "#/$defs/sha256" },
+ { "type": "null" }
+ ]
+ },
+ "pages": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "$ref": "#/$defs/page" }
+ },
+ "navigation": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "$ref": "#/$defs/navigation_item" }
+ },
+ "search_documents": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "$ref": "#/$defs/search_document" }
+ },
+ "diagnostics": {
+ "type": "object",
+ "required": ["orphans", "cycles"],
+ "properties": {
+ "orphans": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "cycles": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 10000,
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/project.schema.json b/schemas/project.schema.json
index 4d7f046..7d9a64f 100644
--- a/schemas/project.schema.json
+++ b/schemas/project.schema.json
@@ -74,6 +74,82 @@
},
"additionalProperties": false
},
+ "graph_render": {
+ "type": "object",
+ "required": ["output_root", "views"],
+ "properties": {
+ "output_root": { "$ref": "#/$defs/relativePath" },
+ "views": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["id", "renderer", "output", "title"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,127}$" },
+ "renderer": { "const": "portable_graph_html" },
+ "output": {
+ "allOf": [
+ { "$ref": "#/$defs/relativePath" },
+ { "pattern": "\\.html$" }
+ ]
+ },
+ "title": { "type": "string", "minLength": 1, "maxLength": 1024 },
+ "root": { "type": "string", "minLength": 1, "maxLength": 1024 },
+ "query": { "type": "string", "minLength": 1, "maxLength": 10000 },
+ "initial_mode": { "enum": ["nodes", "flow", "web"] },
+ "depth": { "type": "integer", "minimum": 1, "maximum": 32 },
+ "max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 },
+ "max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
+ "max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 },
+ "families": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
+ },
+ "relations": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
+ },
+ "authorities": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
+ },
+ "statuses": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
+ },
+ "tags": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
+ },
+ "include_logic": { "const": false }
+ },
+ "oneOf": [
+ {
+ "required": ["root"],
+ "not": { "required": ["query"] }
+ },
+ {
+ "required": ["query"],
+ "not": { "required": ["root"] }
+ }
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+ },
"graph": {
"type": "object",
"required": ["allowed_relations"],
diff --git a/schemas/projection-package.schema.json b/schemas/projection-package.schema.json
new file mode 100644
index 0000000..0d0f74b
--- /dev/null
+++ b/schemas/projection-package.schema.json
@@ -0,0 +1,190 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docforge.local/schema/projection-package-v1.json",
+ "title": "DocForge immutable renderer projection package",
+ "$comment": "package_id and embedded plan identity equality are verified by the runtime contract validator.",
+ "$defs": {
+ "sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "manual_plan": {
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "changeset_hash",
+ "pages",
+ "navigation",
+ "search_documents",
+ "diagnostics"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.manual-render-plan" },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "project": { "type": "object" },
+ "view": { "type": "object" },
+ "changeset_hash": {
+ "oneOf": [
+ { "$ref": "#/$defs/sha256" },
+ { "type": "null" }
+ ]
+ },
+ "pages": { "type": "array", "maxItems": 10000 },
+ "navigation": { "type": "array", "maxItems": 10000 },
+ "search_documents": { "type": "array", "maxItems": 10000 },
+ "diagnostics": { "type": "object" }
+ },
+ "additionalProperties": false
+ },
+ "graph_plan": {
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "bounds",
+ "policy",
+ "graph",
+ "omissions",
+ "diagnostics"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.graph-view-plan" },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "project": { "type": "object" },
+ "view": { "type": "object" },
+ "bounds": { "type": "object" },
+ "policy": { "type": "object" },
+ "graph": { "type": "object" },
+ "omissions": { "type": "array", "maxItems": 10000 },
+ "diagnostics": { "type": "object" }
+ },
+ "additionalProperties": false
+ },
+ "renderer": {
+ "type": "object",
+ "required": ["renderer_id", "renderer_version"],
+ "properties": {
+ "renderer_id": { "type": "string", "minLength": 1 },
+ "renderer_version": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "component": {
+ "type": "object",
+ "required": ["component_id"],
+ "properties": {
+ "component_id": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "asset": {
+ "type": "object",
+ "required": ["asset_id", "media_type", "sha256", "text"],
+ "properties": {
+ "asset_id": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[^/]+$"
+ },
+ "media_type": { "type": "string", "minLength": 1 },
+ "sha256": { "$ref": "#/$defs/sha256" },
+ "text": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "package_id",
+ "kind",
+ "plan_id",
+ "plan",
+ "renderer",
+ "components",
+ "assets",
+ "output_policy"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.projection-package" },
+ "package_id": { "$ref": "#/$defs/sha256" },
+ "kind": { "enum": ["manual", "graph"] },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "plan": {
+ "oneOf": [
+ { "$ref": "#/$defs/manual_plan" },
+ { "$ref": "#/$defs/graph_plan" }
+ ]
+ },
+ "renderer": { "$ref": "#/$defs/renderer" },
+ "components": {
+ "type": "array",
+ "maxItems": 32,
+ "items": { "$ref": "#/$defs/component" },
+ "uniqueItems": true
+ },
+ "assets": {
+ "type": "array",
+ "maxItems": 32,
+ "items": { "$ref": "#/$defs/asset" }
+ },
+ "output_policy": {
+ "type": "object",
+ "required": ["artifact_ids", "max_total_bytes"],
+ "properties": {
+ "artifact_ids": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[^/]+$"
+ },
+ "uniqueItems": true
+ },
+ "max_total_bytes": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": { "kind": { "const": "manual" } },
+ "required": ["kind"]
+ },
+ "then": {
+ "properties": {
+ "plan": { "$ref": "#/$defs/manual_plan" }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": { "kind": { "const": "graph" } },
+ "required": ["kind"]
+ },
+ "then": {
+ "properties": {
+ "plan": { "$ref": "#/$defs/graph_plan" }
+ }
+ }
+ }
+ ],
+ "additionalProperties": false
+}
diff --git a/schemas/projection-policy.schema.json b/schemas/projection-policy.schema.json
new file mode 100644
index 0000000..9186721
--- /dev/null
+++ b/schemas/projection-policy.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docforge.local/schema/projection-policy-v2.json",
+ "title": "DocForge independent projection policy",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "manual",
+ "portable_graph",
+ "live_viewer"
+ ],
+ "properties": {
+ "schema_version": { "const": 2 },
+ "manual": { "enum": ["auto", "explicit", "disabled"] },
+ "portable_graph": { "enum": ["explicit", "disabled"] },
+ "live_viewer": { "enum": ["on-demand", "disabled"] }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/projection-receipt.schema.json b/schemas/projection-receipt.schema.json
new file mode 100644
index 0000000..7e5d7d5
--- /dev/null
+++ b/schemas/projection-receipt.schema.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docforge.local/schema/projection-receipt-v1.json",
+ "title": "DocForge projection renderer receipt",
+ "$comment": "receipt_id is the SHA-256 of canonical JSON without receipt_id and is verified by the runtime contract validator.",
+ "$defs": {
+ "sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "renderer": {
+ "type": "object",
+ "required": ["renderer_id", "renderer_version"],
+ "properties": {
+ "renderer_id": { "type": "string", "minLength": 1 },
+ "renderer_version": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "artifact": {
+ "type": "object",
+ "required": ["artifact_id", "media_type", "sha256", "bytes"],
+ "properties": {
+ "artifact_id": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[^/]+$"
+ },
+ "media_type": { "type": "string", "minLength": 1 },
+ "sha256": { "$ref": "#/$defs/sha256" },
+ "bytes": { "type": "integer", "minimum": 0 }
+ },
+ "additionalProperties": false
+ }
+ },
+ "type": "object",
+ "required": [
+ "schema_version",
+ "contract",
+ "receipt_id",
+ "kind",
+ "package_id",
+ "plan_id",
+ "renderer",
+ "artifacts",
+ "diagnostics",
+ "timing",
+ "peak_memory_bytes"
+ ],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "contract": { "const": "docforge.projection-receipt" },
+ "receipt_id": { "$ref": "#/$defs/sha256" },
+ "kind": { "enum": ["manual", "graph"] },
+ "package_id": { "$ref": "#/$defs/sha256" },
+ "plan_id": { "$ref": "#/$defs/sha256" },
+ "renderer": { "$ref": "#/$defs/renderer" },
+ "artifacts": {
+ "type": "array",
+ "maxItems": 32,
+ "items": { "$ref": "#/$defs/artifact" }
+ },
+ "diagnostics": {
+ "type": "object",
+ "required": ["warnings"],
+ "properties": {
+ "warnings": {
+ "type": "array",
+ "maxItems": 10000,
+ "items": { "type": "string" }
+ }
+ },
+ "additionalProperties": false
+ },
+ "timing": {
+ "type": "object",
+ "required": ["elapsed_ns"],
+ "properties": {
+ "elapsed_ns": { "type": "integer", "minimum": 0 }
+ },
+ "additionalProperties": false
+ },
+ "peak_memory_bytes": {
+ "type": ["integer", "null"],
+ "minimum": 0
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/result.schema.json b/schemas/result.schema.json
index aac219b..31cc087 100644
--- a/schemas/result.schema.json
+++ b/schemas/result.schema.json
@@ -20,6 +20,7 @@
"test",
"benchmark.m1",
"benchmark.m2",
+ "benchmark.m3",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
@@ -37,6 +38,8 @@
"mcp.generation_diff",
"mcp.validate_project",
"mcp.render_status",
+ "mcp.graph_plan",
+ "mcp.graph_render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
@@ -58,6 +61,9 @@
"cli.impact",
"cli.context",
"cli.generation-diff",
+ "cli.graph-plan",
+ "cli.graph-render",
+ "cli.graph-render-status",
"cli.configure",
"cli.doctor",
"cli.render",
diff --git a/src/docforge/_fs_safety.py b/src/docforge/_fs_safety.py
index e2c8c94..6795a77 100644
--- a/src/docforge/_fs_safety.py
+++ b/src/docforge/_fs_safety.py
@@ -3,7 +3,10 @@
from __future__ import annotations
import os
+import secrets
import stat
+from collections.abc import Callable
+from contextlib import suppress
from pathlib import Path
from .errors import DocForgeError
@@ -69,3 +72,177 @@ def require_bound_directory(path: Path, directory_fd: int) -> None:
"path_escape",
"Derived cache root disappeared during publication",
) from error
+
+
+def open_confined_directory(root: Path, path: Path, *, create: bool) -> int:
+ """Open a descendant directory through stable no-follow directory descriptors."""
+
+ try:
+ unsafe = (
+ root.is_symlink()
+ or root.resolve(strict=True) != root
+ or not path.is_relative_to(root)
+ or path == root
+ )
+ except OSError as error:
+ raise DocForgeError("path_escape", "Project root cannot be resolved safely") from error
+ if unsafe:
+ raise DocForgeError("path_escape", "Derived output directory is not confined")
+ relative = path.relative_to(root)
+ try:
+ descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ except OSError as error:
+ raise DocForgeError("path_escape", "Project root cannot be opened safely") from error
+ try:
+ for part in relative.parts:
+ if part in {"", ".", ".."}:
+ raise DocForgeError("path_escape", "Derived output directory is not confined")
+ if create:
+ try:
+ os.mkdir(part, mode=0o700, dir_fd=descriptor)
+ except FileExistsError:
+ pass
+ except OSError as error:
+ raise DocForgeError(
+ "publication_failure",
+ "Derived output directory could not be created",
+ ) from error
+ try:
+ next_descriptor = os.open(
+ part,
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
+ dir_fd=descriptor,
+ )
+ except OSError as error:
+ raise DocForgeError(
+ "path_escape",
+ "Derived output directory is missing or unsafe",
+ ) from error
+ os.close(descriptor)
+ descriptor = next_descriptor
+ require_bound_directory(path, descriptor)
+ return descriptor
+ except Exception:
+ os.close(descriptor)
+ raise
+
+
+def safe_file_identity_at(
+ directory: Path,
+ directory_fd: int,
+ name: str,
+) -> dict[str, object] | None:
+ """Return one no-follow regular-file identity relative to a bound directory."""
+
+ del directory
+ if not name or "/" in name or name in {".", ".."}:
+ raise DocForgeError("path_escape", "Derived artifact name is unsafe")
+ try:
+ current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
+ except FileNotFoundError:
+ return None
+ except OSError as error:
+ raise DocForgeError("path_escape", "Derived artifact cannot be inspected") from error
+ if not stat.S_ISREG(current.st_mode):
+ raise DocForgeError("path_escape", "Derived artifact is not a safe regular file")
+ return {
+ "path": name,
+ "device": current.st_dev,
+ "inode": current.st_ino,
+ "mode": current.st_mode,
+ "size": current.st_size,
+ "mtime_ns": current.st_mtime_ns,
+ "ctime_ns": current.st_ctime_ns,
+ }
+
+
+def read_bounded_file_at(
+ directory_fd: int,
+ name: str,
+ maximum_bytes: int,
+) -> bytes | None:
+ """Read one regular file through a bound directory without following links."""
+
+ try:
+ descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
+ except FileNotFoundError:
+ return None
+ except OSError as error:
+ raise DocForgeError("path_escape", "Derived artifact cannot be opened safely") from error
+ with os.fdopen(descriptor, "rb") as handle:
+ current = os.fstat(handle.fileno())
+ if not stat.S_ISREG(current.st_mode) or current.st_size > maximum_bytes:
+ raise DocForgeError("invalid_projection", "Derived artifact is invalid or oversized")
+ content = handle.read(maximum_bytes + 1)
+ if len(content) > maximum_bytes:
+ raise DocForgeError("invalid_projection", "Derived artifact is oversized")
+ return content
+
+
+def atomic_replace_bytes_at(
+ path: Path,
+ directory_fd: int,
+ name: str,
+ content: bytes,
+ *,
+ verify: Callable[[], None],
+) -> dict[str, object]:
+ """Durably replace one file inside an already bound directory."""
+
+ if not name or "/" in name or name in {".", ".."}:
+ raise DocForgeError("path_escape", "Derived artifact name is unsafe")
+ existing = safe_file_identity_at(path, directory_fd, name)
+ del existing
+ temporary = f".docforge-projection-{secrets.token_hex(12)}"
+ descriptor: int | None = None
+ committed = False
+ try:
+ descriptor = os.open(
+ temporary,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
+ 0o600,
+ dir_fd=directory_fd,
+ )
+ with os.fdopen(descriptor, "wb") as handle:
+ descriptor = None
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ verify()
+ require_bound_directory(path, directory_fd)
+ os.replace(
+ temporary,
+ name,
+ src_dir_fd=directory_fd,
+ dst_dir_fd=directory_fd,
+ )
+ committed = True
+ os.fsync(directory_fd)
+ identity = safe_file_identity_at(path, directory_fd, name)
+ if identity is None:
+ raise DocForgeError(
+ "publication_failure",
+ "Derived artifact disappeared after publication",
+ mutation_committed=True,
+ )
+ return identity
+ except DocForgeError as error:
+ if committed:
+ raise DocForgeError(
+ "publication_failure",
+ "Derived artifact was replaced but final publication verification failed",
+ mutation_committed=True,
+ cause=error.code,
+ ) from error
+ raise
+ except OSError as error:
+ raise DocForgeError(
+ "publication_failure",
+ "Derived artifact publication failed",
+ mutation_committed=committed,
+ ) from error
+ finally:
+ if descriptor is not None:
+ os.close(descriptor)
+ with suppress(OSError):
+ os.unlink(temporary, dir_fd=directory_fd)
diff --git a/src/docforge/_projection_worker_main.py b/src/docforge/_projection_worker_main.py
new file mode 100644
index 0000000..801c5c3
--- /dev/null
+++ b/src/docforge/_projection_worker_main.py
@@ -0,0 +1,8 @@
+"""Private module entry point for the detached projection worker."""
+
+from __future__ import annotations
+
+from .projection_worker import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/docforge/application.py b/src/docforge/application.py
index bb52c0a..3efb2b1 100644
--- a/src/docforge/application.py
+++ b/src/docforge/application.py
@@ -15,6 +15,7 @@ from .changesets import ChangesetStore
from .errors import DocForgeError
from .index import ProjectIndex
from .models import Node, ProjectService, ProjectSnapshot
+from .projection_policy import ManualProjectionMode, validate_manual_projection_mode
from .rendering import RenderService
@@ -353,13 +354,19 @@ class CanonicalApplicationService:
applier_id: str | None,
applier: CanonicalApplier | None,
index: ProjectIndex | None = None,
+ manual_policy: ManualProjectionMode = "auto",
) -> None:
self.project = project
self.applier_id = applier_id
self.applier = applier
self.changesets = ChangesetStore(project, applier_id)
self.index = index or ProjectIndex(project)
- self.rendering = RenderService(project, self.changesets)
+ self.manual_policy = validate_manual_projection_mode(manual_policy)
+ self.rendering = RenderService(
+ project,
+ self.changesets,
+ manual_policy=self.manual_policy,
+ )
@property
def enabled(self) -> bool:
@@ -402,7 +409,9 @@ class CanonicalApplicationService:
)
renders: list[dict[str, object]] = []
config = self.project.descriptor.render
- if config is not None:
+ render_action = "not_configured"
+ if config is not None and self.manual_policy == "auto":
+ render_action = "rendered"
for view in config.views:
try:
rendered = self.rendering.render(view.view_id)
@@ -435,6 +444,10 @@ class CanonicalApplicationService:
"error": error.as_dict(),
}
)
+ elif config is not None:
+ render_action = (
+ "skipped_explicit" if self.manual_policy == "explicit" else "skipped_disabled"
+ )
return {
**applied,
"derived_refresh": {
@@ -442,6 +455,10 @@ class CanonicalApplicationService:
"index": index_result,
"check": index_check,
"renders": renders,
+ "render_policy": {
+ "mode": self.manual_policy,
+ "action": render_action,
+ },
"errors": refresh_errors,
},
}
diff --git a/src/docforge/assets/graph.html b/src/docforge/assets/graph.html
index be58d3d..2f3ed8c 100644
--- a/src/docforge/assets/graph.html
+++ b/src/docforge/assets/graph.html
@@ -95,7 +95,7 @@
aria-label="Visible relationship color and symbol key">
+ role="group" aria-label="Interactive node neighborhood">
Search for a node to inspect its neighborhood.
Visualization disconnected
diff --git a/src/docforge/cli.py b/src/docforge/cli.py
index 35b55d2..9e5f507 100644
--- a/src/docforge/cli.py
+++ b/src/docforge/cli.py
@@ -13,9 +13,11 @@ from .client_config import CLIENT_NAMES, generate_client_configuration
from .context import compile_context
from .doctor import run_doctor
from .errors import DocForgeError
+from .graph_rendering import GraphRenderService
from .index import ProjectIndex
from .onboarding import assess_project, scaffold_project
from .project import Project, project_root_fingerprint
+from .projection_policy import compose_projection_policy
from .rendering import RenderService
from .telemetry import request
from .viewer_manager import ViewerManagerClient
@@ -29,6 +31,18 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="Attach bounded request-local stage timings and counters",
)
+ parser.add_argument(
+ "--manual-render-policy",
+ choices=("auto", "explicit", "disabled"),
+ )
+ parser.add_argument(
+ "--portable-graph-policy",
+ choices=("explicit", "disabled"),
+ )
+ parser.add_argument(
+ "--live-viewer-policy",
+ choices=("on-demand", "disabled"),
+ )
commands = parser.add_subparsers(dest="command", required=True)
configure = commands.add_parser("configure")
configure.add_argument("client", choices=CLIENT_NAMES)
@@ -42,6 +56,21 @@ def _parser() -> argparse.ArgumentParser:
configure.add_argument("--proposal-writer")
configure.add_argument("--canonical-applier")
configure.add_argument("--no-ast", action="store_true")
+ configure.add_argument(
+ "--manual-render-policy",
+ choices=("auto", "explicit", "disabled"),
+ default=argparse.SUPPRESS,
+ )
+ configure.add_argument(
+ "--portable-graph-policy",
+ choices=("explicit", "disabled"),
+ default=argparse.SUPPRESS,
+ )
+ configure.add_argument(
+ "--live-viewer-policy",
+ choices=("on-demand", "disabled"),
+ default=argparse.SUPPRESS,
+ )
configure.add_argument("--startup-timeout", type=int, default=30)
configure.add_argument("--tool-timeout", type=int, default=300)
configure.add_argument("--output", type=Path)
@@ -95,6 +124,12 @@ def _parser() -> argparse.ArgumentParser:
render_status = commands.add_parser("render-status")
render_status.add_argument("view_id", nargs="?")
render_status.add_argument("--deep", action="store_true")
+ graph_plan = commands.add_parser("graph-plan")
+ graph_plan.add_argument("view_id")
+ graph_render = commands.add_parser("graph-render")
+ graph_render.add_argument("view_id")
+ graph_render_status = commands.add_parser("graph-render-status")
+ graph_render_status.add_argument("view_id", nargs="?")
preview = commands.add_parser("preview")
preview.add_argument("changeset_id")
preview.add_argument("view_id")
@@ -124,6 +159,9 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
proposal_writer=arguments.proposal_writer,
canonical_applier=arguments.canonical_applier,
no_ast=arguments.no_ast,
+ manual_render_policy=arguments.manual_render_policy,
+ portable_graph_policy=arguments.portable_graph_policy,
+ live_viewer_policy=arguments.live_viewer_policy,
startup_timeout=arguments.startup_timeout,
tool_timeout=arguments.tool_timeout,
output=arguments.output,
@@ -152,12 +190,39 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
content_root=arguments.content_root,
)
project = Project.open(arguments.project_root)
+ projection_policy = compose_projection_policy(
+ manual=arguments.manual_render_policy,
+ portable_graph=arguments.portable_graph_policy,
+ live_viewer=arguments.live_viewer_policy,
+ manual_configured=project.descriptor.render is not None,
+ portable_graph_configured=project.descriptor.graph_render is not None,
+ application_enabled=False,
+ )
build = ProjectIndex(project).build()
- render = RenderService(project).render("manual")
+ render = (
+ {
+ "status": "ok",
+ "state": "skipped",
+ "reason": "projection_policy_disabled",
+ }
+ if projection_policy.manual == "disabled"
+ else RenderService(
+ project,
+ manual_policy=projection_policy.manual,
+ ).render("manual")
+ )
return {**scaffold, "build": build, "render": render}
return assess_project(arguments.project_root, requested_languages=languages)
project = Project.open(arguments.project_root)
index = ProjectIndex(project)
+ projection_policy = compose_projection_policy(
+ manual=arguments.manual_render_policy,
+ portable_graph=arguments.portable_graph_policy,
+ live_viewer=arguments.live_viewer_policy,
+ manual_configured=project.descriptor.render is not None,
+ portable_graph_configured=project.descriptor.graph_render is not None,
+ application_enabled=arguments.command == "apply",
+ )
if arguments.command == "info":
snapshot = project.load()
return {
@@ -250,24 +315,52 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
cursor=arguments.cursor,
)
if arguments.command == "render":
- return RenderService(project).render(arguments.view_id)
+ return RenderService(
+ project,
+ manual_policy=projection_policy.manual,
+ ).render(arguments.view_id)
if arguments.command == "render-status":
- rendering = RenderService(project)
+ rendering = RenderService(
+ project,
+ manual_policy=projection_policy.manual,
+ )
return (
rendering.deep_status(arguments.view_id)
if arguments.deep
else rendering.status(arguments.view_id)
)
+ if arguments.command == "graph-plan":
+ return GraphRenderService(
+ project,
+ portable_graph_policy=projection_policy.portable_graph,
+ ).plan(arguments.view_id)
+ if arguments.command == "graph-render":
+ return GraphRenderService(
+ project,
+ portable_graph_policy=projection_policy.portable_graph,
+ ).render(arguments.view_id)
+ if arguments.command == "graph-render-status":
+ return GraphRenderService(
+ project,
+ portable_graph_policy=projection_policy.portable_graph,
+ ).status(arguments.view_id)
if arguments.command == "preview":
- return RenderService(project).preview(arguments.changeset_id, arguments.view_id)
+ return RenderService(
+ project,
+ manual_policy=projection_policy.manual,
+ ).preview(arguments.changeset_id, arguments.view_id)
if arguments.command == "apply":
return CanonicalApplicationService(
project,
applier_id=arguments.applier,
applier=GenericCanonicalApplier(project),
+ manual_policy=projection_policy.manual,
).apply(arguments.changeset_id, arguments.changeset_hash)
if arguments.command == "visualize":
- visualization = ViewerManagerClient(index).start(
+ visualization = ViewerManagerClient(
+ index,
+ live_viewer_policy=projection_policy.live_viewer,
+ ).start(
node_id=arguments.node,
query=arguments.query,
depth=arguments.depth,
@@ -284,9 +377,15 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
"visualization": visualization,
}
if arguments.command == "visualization-status":
- return ViewerManagerClient(index).status()
+ return ViewerManagerClient(
+ index,
+ live_viewer_policy=projection_policy.live_viewer,
+ ).status()
if arguments.command == "visualization-stop":
- return ViewerManagerClient(index).stop()
+ return ViewerManagerClient(
+ index,
+ live_viewer_policy=projection_policy.live_viewer,
+ ).stop()
raise DocForgeError("invalid_command", "Unknown command")
diff --git a/src/docforge/client_config.py b/src/docforge/client_config.py
index 7b482d5..18c9de6 100644
--- a/src/docforge/client_config.py
+++ b/src/docforge/client_config.py
@@ -18,9 +18,10 @@ from typing import Literal, cast
from .changeset_contract import document_hash
from .errors import DocForgeError
-from .models import ProjectService
+from .models import ProjectDescriptor, ProjectService
from .policy import CapabilityMode, compose_effective_policy
-from .project import project_root_fingerprint, validate_descriptor_binding
+from .project import Project, project_root_fingerprint, validate_descriptor_binding
+from .projection_policy import compose_projection_policy
ClientName = Literal["codex", "claude", "openclaw"]
CLIENT_NAMES: tuple[ClientName, ...] = ("codex", "claude", "openclaw")
@@ -605,11 +606,37 @@ def _atomic_write(
os.close(directory_fd)
-def _validate_configuration_result(result: dict[str, object]) -> None:
+def _validate_configuration_result(
+ result: dict[str, object],
+ *,
+ trusted_descriptor: ProjectDescriptor | None = None,
+) -> None:
artifact = cast(dict[str, object], result["artifact"])
binding = cast(dict[str, object], result["binding"])
policy = cast(dict[str, object], result["effective_policy"])
+ projection_policy = cast(dict[str, object], result["projection_policy"])
+ projection_availability = cast(
+ dict[str, object],
+ result["projection_availability"],
+ )
project = cast(dict[str, object], result["project"])
+ if trusted_descriptor is None:
+ try:
+ bound_descriptor = Project.open(cast(str, project["project_root"])).descriptor
+ except (DocForgeError, KeyError, TypeError) as error:
+ raise AssertionError(
+ "Generated client project binding cannot be independently validated"
+ ) from error
+ else:
+ bound_descriptor = trusted_descriptor
+ if (
+ project["project_id"] != bound_descriptor.project_id
+ or project["project_root"] != str(bound_descriptor.root)
+ or project["project_root_fingerprint"] != project_root_fingerprint(bound_descriptor.root)
+ or project["adapter"] != bound_descriptor.adapter
+ or project["descriptor_hash"] != bound_descriptor.descriptor_hash
+ ):
+ raise AssertionError("Generated client project binding drifted")
content = cast(str, artifact["content"])
if artifact["content_sha256"] != hashlib.sha256(content.encode("utf-8")).hexdigest():
raise AssertionError("Generated client content hash drifted")
@@ -644,6 +671,29 @@ def _validate_configuration_result(result: dict[str, object]) -> None:
if remaining[-1:] != ["--no-ast"] or arguments.count("--no-ast") != 1:
raise AssertionError("Generated no-AST argument layout drifted")
remaining = remaining[:-1]
+ projection_arguments: dict[str, str] = {}
+ authority_arguments: list[str] = []
+ position = 0
+ projection_options = {
+ "--manual-render-policy": "manual",
+ "--portable-graph-policy": "portable_graph",
+ "--live-viewer-policy": "live_viewer",
+ }
+ while position < len(remaining):
+ option = remaining[position]
+ field = projection_options.get(option)
+ if field is None:
+ authority_arguments.append(option)
+ position += 1
+ continue
+ if position + 1 >= len(remaining) or option in projection_arguments:
+ raise AssertionError("Generated projection policy argument layout drifted")
+ value = remaining[position + 1]
+ projection_arguments[option] = value
+ if projection_policy[field] != value:
+ raise AssertionError("Generated projection policy argument drifted")
+ position += 2
+ remaining = authority_arguments
mode = binding["capability_mode"]
if (
(mode == "read" and remaining)
@@ -663,6 +713,34 @@ def _validate_configuration_result(result: dict[str, object]) -> None:
)
):
raise AssertionError("Generated authority argument layout drifted")
+ expected_projection_policy = compose_projection_policy(
+ manual=projection_arguments.get("--manual-render-policy"),
+ portable_graph=projection_arguments.get("--portable-graph-policy"),
+ live_viewer=projection_arguments.get("--live-viewer-policy"),
+ manual_configured=cast(bool, projection_availability["manual_configured"]),
+ portable_graph_configured=cast(
+ bool,
+ projection_availability["portable_graph_configured"],
+ ),
+ application_enabled=cast(
+ bool,
+ projection_availability["application_enabled"],
+ ),
+ live_viewer_available=cast(
+ bool,
+ projection_availability["live_viewer_available"],
+ ),
+ )
+ if (
+ projection_policy != expected_projection_policy.as_dict()
+ or projection_availability["manual_configured"] != (render_policy["manual"] != "disabled")
+ or projection_availability["manual_configured"] != (bound_descriptor.render is not None)
+ or projection_availability["portable_graph_configured"]
+ != (bound_descriptor.graph_render is not None)
+ or projection_availability["application_enabled"] != (mode == "application")
+ or projection_availability["live_viewer_available"] is not True
+ ):
+ raise AssertionError("Generated projection policy drifted from its availability")
composed_policy = compose_effective_policy(
selected_mode=cast(CapabilityMode, mode),
capability_source="explicit",
@@ -698,6 +776,18 @@ def _validate_configuration_result(result: dict[str, object]) -> None:
)
):
raise AssertionError("Generated client policy drifted from its binding")
+ if (
+ result["projection_policy_hash"]
+ != hashlib.sha256(
+ json.dumps(
+ projection_policy,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ ).hexdigest()
+ ):
+ raise AssertionError("Generated projection policy hash drifted")
expected_hash = document_hash(
{
"schema_version": 1,
@@ -706,6 +796,9 @@ def _validate_configuration_result(result: dict[str, object]) -> None:
"project": project,
"binding": binding,
"effective_policy": policy,
+ "projection_policy": projection_policy,
+ "projection_policy_hash": result["projection_policy_hash"],
+ "projection_availability": projection_availability,
"artifact_format": artifact["format"],
"artifact_content_sha256": artifact["content_sha256"],
}
@@ -723,6 +816,9 @@ def generate_client_configuration(
proposal_writer: str | None = None,
canonical_applier: str | None = None,
no_ast: bool = False,
+ manual_render_policy: str | None = None,
+ portable_graph_policy: str | None = None,
+ live_viewer_policy: str | None = None,
startup_timeout: int = 30,
tool_timeout: int = 300,
output: Path | None = None,
@@ -839,9 +935,6 @@ def generate_client_configuration(
arguments.extend(("--proposal-writer", proposal_writer))
if canonical_applier is not None:
arguments.extend(("--canonical-applier", canonical_applier))
- if no_ast:
- arguments.append("--no-ast")
-
policy = compose_effective_policy(
selected_mode=selected_mode,
capability_source="explicit",
@@ -850,6 +943,40 @@ def generate_client_configuration(
render_configured=descriptor.render is not None,
application_enabled=canonical_applier is not None,
)
+ projection_policy = compose_projection_policy(
+ manual=manual_render_policy,
+ portable_graph=portable_graph_policy,
+ live_viewer=live_viewer_policy,
+ manual_configured=descriptor.render is not None,
+ portable_graph_configured=descriptor.graph_render is not None,
+ application_enabled=canonical_applier is not None,
+ )
+ default_projection_policy = compose_projection_policy(
+ manual_configured=descriptor.render is not None,
+ portable_graph_configured=descriptor.graph_render is not None,
+ application_enabled=canonical_applier is not None,
+ )
+ for option, selected, default in (
+ (
+ "--manual-render-policy",
+ projection_policy.manual,
+ default_projection_policy.manual,
+ ),
+ (
+ "--portable-graph-policy",
+ projection_policy.portable_graph,
+ default_projection_policy.portable_graph,
+ ),
+ (
+ "--live-viewer-policy",
+ projection_policy.live_viewer,
+ default_projection_policy.live_viewer,
+ ),
+ ):
+ if selected != default:
+ arguments.extend((option, selected))
+ if no_ast:
+ arguments.append("--no-ast")
artifact_format, content, warning = _artifact(
selected_client,
server_name=selected_name,
@@ -902,6 +1029,7 @@ def generate_client_configuration(
"project_root": str(descriptor.root),
"project_root_fingerprint": fingerprint,
"adapter": descriptor.adapter,
+ "descriptor_hash": descriptor.descriptor_hash,
}
policy_payload = policy.as_dict()
plan_hash = document_hash(
@@ -912,6 +1040,14 @@ def generate_client_configuration(
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
+ "projection_policy": projection_policy.as_dict(),
+ "projection_policy_hash": projection_policy.policy_hash,
+ "projection_availability": {
+ "manual_configured": descriptor.render is not None,
+ "portable_graph_configured": descriptor.graph_render is not None,
+ "application_enabled": canonical_applier is not None,
+ "live_viewer_available": True,
+ },
"artifact_format": artifact_format,
"artifact_content_sha256": artifact["content_sha256"],
}
@@ -926,6 +1062,14 @@ def generate_client_configuration(
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
+ "projection_policy": projection_policy.as_dict(),
+ "projection_policy_hash": projection_policy.policy_hash,
+ "projection_availability": {
+ "manual_configured": descriptor.render is not None,
+ "portable_graph_configured": descriptor.graph_render is not None,
+ "application_enabled": canonical_applier is not None,
+ "live_viewer_available": True,
+ },
"artifact": artifact,
"configuration_hash": plan_hash,
"warnings": [
@@ -933,5 +1077,5 @@ def generate_client_configuration(
*([] if publication_warning is None else [{"code": publication_warning}]),
],
}
- _validate_configuration_result(result)
+ _validate_configuration_result(result, trusted_descriptor=descriptor)
return result
diff --git a/src/docforge/doctor.py b/src/docforge/doctor.py
index e79ed61..89fd345 100644
--- a/src/docforge/doctor.py
+++ b/src/docforge/doctor.py
@@ -20,6 +20,7 @@ from .project import (
project_root_fingerprint,
validate_descriptor_binding,
)
+from .projection_policy import compose_projection_policy
MAX_CLIENT_CONFIG_BYTES = 1_000_000
MAX_CLIENT_SERVERS = 256
@@ -614,6 +615,9 @@ def _parse_binding(arguments: list[str]) -> dict[str, object]:
"--proposal-writer",
"--canonical-applier",
"--capability-mode",
+ "--manual-render-policy",
+ "--portable-graph-policy",
+ "--live-viewer-policy",
}
flag_options = {"--no-ast", "--diagnostics"}
position = 0
@@ -662,6 +666,9 @@ def _parse_binding(arguments: list[str]) -> dict[str, object]:
"capability_mode_implicit": implicit,
"no_ast": "--no-ast" in flags,
"diagnostics": "--diagnostics" in flags,
+ "manual_render_policy": values.get("--manual-render-policy"),
+ "portable_graph_policy": values.get("--portable-graph-policy"),
+ "live_viewer_policy": values.get("--live-viewer-policy"),
}
@@ -749,6 +756,16 @@ def _runtime_policy_check(
canonical_applier is not None and selected_mode in {"application", "operator"}
),
)
+ compose_projection_policy(
+ manual=cast(str | None, binding["manual_render_policy"]),
+ portable_graph=cast(str | None, binding["portable_graph_policy"]),
+ live_viewer=cast(str | None, binding["live_viewer_policy"]),
+ manual_configured=project.descriptor.render is not None,
+ portable_graph_configured=project.descriptor.graph_render is not None,
+ application_enabled=(
+ canonical_applier is not None and selected_mode in {"application", "operator"}
+ ),
+ )
if cast(bool, binding["capability_mode_implicit"]):
return (
"warning",
diff --git a/src/docforge/graph_projection.py b/src/docforge/graph_projection.py
new file mode 100644
index 0000000..d716389
--- /dev/null
+++ b/src/docforge/graph_projection.py
@@ -0,0 +1,525 @@
+"""Pure, bounded portable-graph planning over one immutable graph generation."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Literal, cast
+
+from .errors import DocForgeError
+from .models import Edge, Node, ProjectSnapshot
+from .project import project_root_fingerprint
+from .projection_contract import (
+ MAX_GRAPH_VIEW_DEPTH,
+ MAX_GRAPH_VIEW_EDGES,
+ MAX_GRAPH_VIEW_FILTERS,
+ MAX_GRAPH_VIEW_NODES,
+ MAX_GRAPH_VIEW_QUERY_CHARS,
+ MAX_GRAPH_VIEW_STRING_CHARS,
+ MAX_GRAPH_VIEW_WORK,
+ GraphViewPlanV1,
+ ProjectionPackageV1,
+)
+
+GraphViewMode = Literal["nodes", "flow", "web", "logic"]
+
+_QUERY_TOKEN = re.compile(r"\w+", re.UNICODE)
+_DETAIL_FIELDS = (
+ "node_id",
+ "title",
+ "family",
+ "authority",
+ "status",
+ "tags",
+ "summary",
+ "content_hash",
+)
+
+
+@dataclass(frozen=True)
+class GraphViewRequestV1:
+ """One closed, inert graph selection request."""
+
+ view_id: str
+ title: str
+ root_node_id: str | None = None
+ query: str | None = None
+ initial_mode: GraphViewMode = "nodes"
+ depth: int = 1
+ max_nodes: int = 100
+ max_edges: int = 400
+ max_work: int = 100_000
+ families: tuple[str, ...] = ()
+ relations: tuple[str, ...] = ()
+ authorities: tuple[str, ...] = ()
+ statuses: tuple[str, ...] = ()
+ tags: tuple[str, ...] = ()
+ include_logic: bool = False
+
+
+@dataclass(frozen=True)
+class _ValidatedRequest:
+ view_id: str
+ title: str
+ root_node_id: str | None
+ query: str | None
+ initial_mode: GraphViewMode
+ depth: int
+ max_nodes: int
+ max_edges: int
+ max_work: int
+ families: tuple[str, ...]
+ relations: tuple[str, ...]
+ authorities: tuple[str, ...]
+ statuses: tuple[str, ...]
+ tags: tuple[str, ...]
+ include_logic: bool
+
+
+def _invalid(message: str, **details: object) -> DocForgeError:
+ return DocForgeError("invalid_graph_view_request", message, **details)
+
+
+def _string(value: object, *, field: str, maximum: int = MAX_GRAPH_VIEW_STRING_CHARS) -> str:
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\0" in value:
+ raise _invalid("Graph view request string is invalid", field=field)
+ return value.strip()
+
+
+def _filter_values(values: object, *, field: str) -> tuple[str, ...]:
+ if not isinstance(values, tuple):
+ raise _invalid("Graph view filter is invalid", field=field)
+ tuple_values = cast(tuple[object, ...], values)
+ if not all(isinstance(value, str) for value in tuple_values):
+ raise _invalid("Graph view filter is invalid", field=field)
+ if len(tuple_values) > MAX_GRAPH_VIEW_FILTERS:
+ raise _invalid("Graph view filter is invalid", field=field)
+ normalized = tuple(_string(value, field=field) for value in cast(tuple[str, ...], tuple_values))
+ if len(normalized) != len(set(normalized)):
+ raise _invalid("Graph view filter contains duplicates", field=field)
+ return tuple(sorted(normalized))
+
+
+def _validated_request(request: GraphViewRequestV1) -> _ValidatedRequest:
+ view_id = _string(request.view_id, field="view_id")
+ title = _string(request.title, field="title")
+ if (request.root_node_id is None) == (request.query is None):
+ raise _invalid("Choose exactly one exact root or lexical query")
+ root_node_id = (
+ None
+ if request.root_node_id is None
+ else _string(request.root_node_id, field="root_node_id")
+ )
+ query = (
+ None
+ if request.query is None
+ else _string(
+ request.query,
+ field="query",
+ maximum=MAX_GRAPH_VIEW_QUERY_CHARS,
+ )
+ )
+ if request.initial_mode not in {"nodes", "flow", "web", "logic"}:
+ raise _invalid("Graph view initial mode is unsupported")
+ if type(request.depth) is not int or not 1 <= request.depth <= MAX_GRAPH_VIEW_DEPTH:
+ raise _invalid(
+ "Graph view depth is outside the fixed boundary",
+ maximum=MAX_GRAPH_VIEW_DEPTH,
+ )
+ for field, value, minimum, maximum in (
+ ("max_nodes", request.max_nodes, 1, MAX_GRAPH_VIEW_NODES),
+ ("max_edges", request.max_edges, 0, MAX_GRAPH_VIEW_EDGES),
+ ("max_work", request.max_work, 1, MAX_GRAPH_VIEW_WORK),
+ ):
+ if type(value) is not int or not minimum <= value <= maximum:
+ raise _invalid(
+ "Graph view bound is outside the fixed boundary",
+ field=field,
+ minimum=minimum,
+ maximum=maximum,
+ )
+ if type(request.include_logic) is not bool:
+ raise _invalid("Graph view Logic selection must be Boolean")
+ return _ValidatedRequest(
+ view_id=view_id,
+ title=title,
+ root_node_id=root_node_id,
+ query=query,
+ initial_mode=request.initial_mode,
+ depth=request.depth,
+ max_nodes=request.max_nodes,
+ max_edges=request.max_edges,
+ max_work=request.max_work,
+ families=_filter_values(request.families, field="families"),
+ relations=_filter_values(request.relations, field="relations"),
+ authorities=_filter_values(request.authorities, field="authorities"),
+ statuses=_filter_values(request.statuses, field="statuses"),
+ tags=_filter_values(request.tags, field="tags"),
+ include_logic=request.include_logic,
+ )
+
+
+def _validated_graph(
+ snapshot: ProjectSnapshot,
+) -> tuple[tuple[Node, ...], tuple[Edge, ...], dict[str, Node]]:
+ nodes = tuple(sorted(snapshot.nodes, key=lambda node: node.node_id))
+ node_by_id = {node.node_id: node for node in nodes}
+ if len(node_by_id) != len(nodes):
+ raise DocForgeError(
+ "invalid_projection",
+ "Graph view snapshot contains duplicate node identities",
+ )
+ edges = tuple(
+ sorted(
+ snapshot.edges,
+ key=lambda edge: (edge.source_id, edge.relation, edge.target_id),
+ )
+ )
+ edge_keys = {(edge.source_id, edge.relation, edge.target_id) for edge in edges}
+ if len(edge_keys) != len(edges) or any(
+ edge.source_id not in node_by_id or edge.target_id not in node_by_id for edge in edges
+ ):
+ raise DocForgeError(
+ "invalid_projection",
+ "Graph view snapshot contains invalid relationships",
+ )
+ return nodes, edges, node_by_id
+
+
+def _eligible(node: Node, request: _ValidatedRequest) -> bool:
+ return (
+ (not request.families or node.family in request.families)
+ and (not request.authorities or node.authority in request.authorities)
+ and (not request.statuses or node.status in request.statuses)
+ and (not request.tags or set(request.tags).issubset(node.tags))
+ )
+
+
+def _relation_allowed(edge: Edge, request: _ValidatedRequest) -> bool:
+ return not request.relations or edge.relation in request.relations
+
+
+def _lexical_text(node: Node) -> str:
+ return " ".join(
+ (
+ node.node_id,
+ node.title,
+ node.summary,
+ node.family,
+ node.authority,
+ node.status,
+ *node.tags,
+ )
+ ).casefold()
+
+
+def _lexical_nodes(
+ nodes: tuple[Node, ...],
+ request: _ValidatedRequest,
+ omissions: list[dict[str, object]],
+) -> tuple[set[str], int, bool]:
+ query = request.query
+ maximum_nodes = request.max_nodes
+ maximum_work = request.max_work
+ assert query is not None
+ terms = tuple(dict.fromkeys(_QUERY_TOKEN.findall(query.casefold())))
+ if not terms:
+ raise _invalid("Lexical graph scope contains no searchable text")
+ selected: set[str] = set()
+ work = 0
+ work_limited = False
+ for node in nodes:
+ if work >= maximum_work:
+ work_limited = True
+ break
+ work += 1
+ if not _eligible(node, request) or not all(term in _lexical_text(node) for term in terms):
+ continue
+ if len(selected) >= maximum_nodes:
+ omissions.append(
+ {
+ "code": "node_result_limit",
+ "subject": "nodes",
+ "limit": maximum_nodes,
+ "minimum_omitted": 1,
+ }
+ )
+ break
+ selected.add(node.node_id)
+ return selected, work, work_limited
+
+
+def _root_nodes(
+ edges: tuple[Edge, ...],
+ node_by_id: dict[str, Node],
+ request: _ValidatedRequest,
+ omissions: list[dict[str, object]],
+) -> tuple[set[str], int, bool]:
+ root_node_id = request.root_node_id
+ maximum_nodes = request.max_nodes
+ maximum_work = request.max_work
+ depth = request.depth
+ assert root_node_id is not None
+ root = node_by_id.get(root_node_id)
+ if root is None:
+ raise DocForgeError(
+ "missing_node",
+ "No node has the requested stable ID",
+ node_id=root_node_id,
+ )
+ if not _eligible(root, request):
+ raise _invalid("Exact graph root is excluded by the closed node filters")
+
+ selected = {root_node_id}
+ frontier = {root_node_id}
+ work = 0
+ work_limited = False
+ node_limited = False
+ for _ in range(depth):
+ if not frontier:
+ break
+ next_frontier: set[str] = set()
+ for edge in edges:
+ if work >= maximum_work:
+ work_limited = True
+ break
+ work += 1
+ if not _relation_allowed(edge, request):
+ continue
+ candidate: str | None = None
+ if edge.source_id in frontier:
+ candidate = edge.target_id
+ elif edge.target_id in frontier:
+ candidate = edge.source_id
+ if candidate is None or candidate in selected:
+ continue
+ node = node_by_id[candidate]
+ if not _eligible(node, request):
+ continue
+ if len(selected) >= maximum_nodes:
+ node_limited = True
+ continue
+ selected.add(candidate)
+ next_frontier.add(candidate)
+ if work_limited:
+ break
+ frontier = next_frontier
+ if node_limited:
+ omissions.append(
+ {
+ "code": "node_result_limit",
+ "subject": "nodes",
+ "limit": maximum_nodes,
+ "minimum_omitted": 1,
+ }
+ )
+ return selected, work, work_limited
+
+
+def _selected_edges(
+ edges: tuple[Edge, ...],
+ selected_ids: set[str],
+ request: _ValidatedRequest,
+ *,
+ initial_work: int,
+ omissions: list[dict[str, object]],
+) -> tuple[list[Edge], int, bool]:
+ maximum_edges = request.max_edges
+ maximum_work = request.max_work
+ selected: list[Edge] = []
+ work = initial_work
+ work_limited = False
+ edge_limited = False
+ for edge in edges:
+ if work >= maximum_work:
+ work_limited = True
+ break
+ work += 1
+ if (
+ edge.source_id not in selected_ids
+ or edge.target_id not in selected_ids
+ or not _relation_allowed(edge, request)
+ ):
+ continue
+ if len(selected) >= maximum_edges:
+ edge_limited = True
+ break
+ selected.append(edge)
+ if edge_limited:
+ omissions.append(
+ {
+ "code": "edge_result_limit",
+ "subject": "edges",
+ "limit": maximum_edges,
+ "minimum_omitted": 1,
+ }
+ )
+ return selected, work, work_limited
+
+
+def _node_payload(node: Node) -> dict[str, object]:
+ return {
+ "node_id": node.node_id,
+ "title": node.title,
+ "family": node.family,
+ "authority": node.authority,
+ "status": node.status,
+ "tags": sorted(node.tags),
+ "summary": node.summary,
+ "content_hash": node.content_hash,
+ }
+
+
+def _edge_payload(edge: Edge) -> dict[str, str]:
+ return {
+ "source_id": edge.source_id,
+ "relation": edge.relation,
+ "target_id": edge.target_id,
+ }
+
+
+def build_graph_view_plan(
+ snapshot: ProjectSnapshot,
+ request: GraphViewRequestV1,
+ allow_logic: bool,
+) -> GraphViewPlanV1:
+ """Build one deterministic, path-free graph plan without rendering or storage access."""
+
+ if type(allow_logic) is not bool:
+ raise _invalid("Graph view Logic policy must be Boolean")
+ normalized = _validated_request(request)
+ nodes, edges, node_by_id = _validated_graph(snapshot)
+ omissions: list[dict[str, object]] = []
+ if normalized.root_node_id is not None:
+ selected_ids, work, work_limited = _root_nodes(
+ edges,
+ node_by_id,
+ normalized,
+ omissions,
+ )
+ scope: dict[str, object] = {
+ "kind": "exact_root",
+ "root_node_id": normalized.root_node_id,
+ "depth": normalized.depth,
+ }
+ else:
+ selected_ids, work, work_limited = _lexical_nodes(
+ nodes,
+ normalized,
+ omissions,
+ )
+ scope = {
+ "kind": "lexical",
+ "query": normalized.query,
+ }
+ selected_edges, work, edge_work_limited = _selected_edges(
+ edges,
+ selected_ids,
+ normalized,
+ initial_work=work,
+ omissions=omissions,
+ )
+ work_limited = work_limited or edge_work_limited
+ if work_limited:
+ omissions.append(
+ {
+ "code": "work_limit",
+ "subject": "selection",
+ "limit": normalized.max_work,
+ "examined": work,
+ "minimum_omitted": 1,
+ }
+ )
+ logic_requested = normalized.include_logic
+ if logic_requested and not allow_logic:
+ omissions.append(
+ {
+ "code": "logic_forbidden",
+ "subject": "logic",
+ "minimum_omitted": 1,
+ }
+ )
+ omissions.sort(key=lambda item: (str(item["code"]), str(item["subject"])))
+ selected_nodes = [node_by_id[node_id] for node_id in sorted(selected_ids)]
+ filters: dict[str, list[str]] = {
+ "families": list(normalized.families),
+ "relations": list(normalized.relations),
+ "authorities": list(normalized.authorities),
+ "statuses": list(normalized.statuses),
+ "tags": list(normalized.tags),
+ }
+ return GraphViewPlanV1.create(
+ {
+ "project": {
+ "project_id": snapshot.descriptor.project_id,
+ "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
+ "adapter": snapshot.descriptor.adapter,
+ "revision": snapshot.revision,
+ "source_hash": snapshot.source_hash,
+ },
+ "view": {
+ "view_id": normalized.view_id,
+ "title": normalized.title,
+ "initial_mode": normalized.initial_mode,
+ "scope": scope,
+ "filters": filters,
+ "detail_fields": list(_DETAIL_FIELDS),
+ },
+ "bounds": {
+ "depth": normalized.depth,
+ "max_nodes": normalized.max_nodes,
+ "max_edges": normalized.max_edges,
+ "max_work": normalized.max_work,
+ },
+ "policy": {
+ "visibility": "selected_graph_only",
+ "source_paths": "excluded",
+ "source_bodies": "excluded",
+ "database_queries": "forbidden",
+ "executable_content": "forbidden",
+ "logic": "allowed" if allow_logic else "forbidden",
+ "logic_requested": logic_requested,
+ },
+ "graph": {
+ "root_node_id": normalized.root_node_id,
+ "nodes": [_node_payload(node) for node in selected_nodes],
+ "edges": [_edge_payload(edge) for edge in selected_edges],
+ "logic_projections": [],
+ },
+ "omissions": omissions,
+ "diagnostics": {
+ "selection": scope["kind"],
+ "returned_nodes": len(selected_nodes),
+ "returned_edges": len(selected_edges),
+ "examined_work_units": work,
+ "truncated": bool(omissions),
+ "ordering": "node_id;source_id,relation,target_id",
+ },
+ }
+ )
+
+
+def build_graph_projection_package(
+ plan: GraphViewPlanV1,
+ *,
+ renderer_id: str,
+ renderer_version: str,
+ max_output_bytes: int,
+) -> ProjectionPackageV1:
+ """Bind a graph plan to the fixed portable renderer without adding runtime authority."""
+
+ return ProjectionPackageV1.create(
+ kind="graph",
+ plan=plan,
+ renderer={"renderer_id": renderer_id, "renderer_version": renderer_version},
+ components=[
+ {"component_id": "graph.portable-document@1"},
+ {"component_id": "graph.accessible-list@1"},
+ {"component_id": "graph.relationship-table@1"},
+ ],
+ assets=[],
+ output_policy={
+ "artifact_ids": ["portable-graph.html"],
+ "max_total_bytes": max_output_bytes,
+ },
+ )
diff --git a/src/docforge/graph_render_config.py b/src/docforge/graph_render_config.py
new file mode 100644
index 0000000..dfd3ade
--- /dev/null
+++ b/src/docforge/graph_render_config.py
@@ -0,0 +1,285 @@
+"""Strict parsing and confinement for optional portable graph artifacts."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal, cast
+
+from .config_validation import (
+ ID_PATTERN,
+ confined_path,
+ positive_int,
+ require_string,
+ string_list,
+)
+from .errors import DocForgeError
+from .models import GraphRenderConfig, GraphRenderView, Limits, RenderConfig
+from .projection_contract import (
+ MAX_GRAPH_VIEW_DEPTH,
+ MAX_GRAPH_VIEW_EDGES,
+ MAX_GRAPH_VIEW_FILTERS,
+ MAX_GRAPH_VIEW_NODES,
+ MAX_GRAPH_VIEW_QUERY_CHARS,
+ MAX_GRAPH_VIEW_STRING_CHARS,
+ MAX_GRAPH_VIEW_WORK,
+)
+
+_CONFIG_KEYS = frozenset({"output_root", "views"})
+_VIEW_KEYS = frozenset(
+ {
+ "id",
+ "renderer",
+ "output",
+ "title",
+ "root",
+ "query",
+ "initial_mode",
+ "depth",
+ "max_nodes",
+ "max_edges",
+ "max_work",
+ "families",
+ "relations",
+ "authorities",
+ "statuses",
+ "tags",
+ "include_logic",
+ }
+)
+_MODES = frozenset({"nodes", "flow", "web"})
+
+
+def _overlaps(first: Path, second: Path) -> bool:
+ return first == second or first.is_relative_to(second) or second.is_relative_to(first)
+
+
+def _optional_string(document: dict[str, object], key: str, source: Path) -> str | None:
+ if key not in document:
+ return None
+ return require_string(document, key, source)
+
+
+def _bounded_string(
+ document: dict[str, object],
+ key: str,
+ source: Path,
+ *,
+ maximum: int,
+) -> str:
+ value = require_string(document, key, source)
+ if len(value) > maximum:
+ raise DocForgeError("invalid_config", f"{key} exceeds its fixed character limit")
+ return value
+
+
+def _bounded_strings(
+ value: object,
+ *,
+ key: str,
+ source: Path,
+) -> tuple[str, ...]:
+ values = string_list(value, key=key, source=source)
+ if len(values) > MAX_GRAPH_VIEW_FILTERS or any(
+ len(item) > MAX_GRAPH_VIEW_STRING_CHARS for item in values
+ ):
+ raise DocForgeError("invalid_config", f"{key} exceeds its fixed bounds")
+ return values
+
+
+def load_graph_render_config(
+ root: Path,
+ document: object,
+ *,
+ descriptor_path: Path,
+ content_roots: tuple[Path, ...],
+ authority_files: tuple[Path, ...],
+ cache_root: Path,
+ index_path: Path,
+ changeset_root: Path,
+ manual_render: RenderConfig | None,
+ limits: Limits,
+) -> GraphRenderConfig | None:
+ if document is None:
+ return None
+ if not isinstance(document, dict):
+ raise DocForgeError("invalid_config", "graph_render must be a table")
+ document = cast(dict[str, object], document)
+ unknown = sorted(set(document) - _CONFIG_KEYS)
+ if unknown:
+ raise DocForgeError("invalid_config", "graph_render has unknown fields", fields=unknown)
+ output_root = confined_path(
+ root,
+ document.get("output_root"),
+ field="graph_render.output_root",
+ must_exist=False,
+ )
+ protected = [*content_roots, cache_root, changeset_root]
+ if manual_render is not None:
+ protected.extend((manual_render.template_root, manual_render.preview_root))
+ protected.extend(view.output_path for view in manual_render.views)
+ if any(_overlaps(output_root, path) for path in protected):
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph output must not overlap canonical or other derived roots",
+ )
+ protected_files = (descriptor_path, index_path, *authority_files)
+ if any(path == output_root or path.is_relative_to(output_root) for path in protected_files):
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph output overlaps a protected project path",
+ )
+ view_values_value = document.get("views")
+ if not isinstance(view_values_value, list) or not view_values_value:
+ raise DocForgeError(
+ "invalid_config",
+ "graph_render.views must contain at least one view",
+ )
+ view_values = cast(list[object], view_values_value)
+ if len(view_values) > limits.max_render_views:
+ raise DocForgeError("invalid_config", "graph_render.views exceeds the configured limit")
+ views: list[GraphRenderView] = []
+ view_ids: set[str] = set()
+ outputs: set[Path] = set()
+ for value in view_values:
+ if not isinstance(value, dict):
+ raise DocForgeError("invalid_config", "Each portable graph view must be a table")
+ view = cast(dict[str, object], value)
+ unknown_view = sorted(set(view) - _VIEW_KEYS)
+ if unknown_view:
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph view has unknown fields",
+ fields=unknown_view,
+ )
+ view_id = require_string(view, "id", descriptor_path)
+ if ID_PATTERN.fullmatch(view_id) is None or view_id in view_ids:
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph view ID is invalid or duplicated",
+ id=view_id,
+ )
+ view_ids.add(view_id)
+ renderer = require_string(view, "renderer", descriptor_path)
+ if renderer != "portable_graph_html":
+ raise DocForgeError(
+ "unsupported_renderer",
+ "Portable graph view names an unsupported built-in renderer",
+ renderer=renderer,
+ )
+ output = confined_path(
+ output_root,
+ view.get("output"),
+ field="graph_render.view.output",
+ must_exist=False,
+ )
+ if output.suffix != ".html" or output in outputs:
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph outputs must be unique HTML files",
+ )
+ outputs.add(output)
+ root_node_id = _optional_string(view, "root", descriptor_path)
+ query = _optional_string(view, "query", descriptor_path)
+ if (root_node_id is None) == (query is None):
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph view requires exactly one root or query",
+ )
+ if root_node_id is not None and len(root_node_id) > MAX_GRAPH_VIEW_STRING_CHARS:
+ raise DocForgeError("invalid_config", "Portable graph root exceeds its fixed limit")
+ if query is not None and len(query) > MAX_GRAPH_VIEW_QUERY_CHARS:
+ raise DocForgeError("invalid_config", "Portable graph query exceeds its fixed limit")
+ initial_mode_value = view.get("initial_mode", "nodes")
+ if not isinstance(initial_mode_value, str):
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph initial mode is unsupported",
+ )
+ initial_mode = cast(
+ Literal["nodes", "flow", "web", "logic"],
+ initial_mode_value,
+ )
+ if initial_mode not in _MODES:
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph initial mode is unsupported",
+ )
+ depth = positive_int(view.get("depth", 1), "graph_render.view.depth")
+ max_nodes = positive_int(view.get("max_nodes", 100), "graph_render.view.max_nodes")
+ max_edges = positive_int(
+ view.get("max_edges", 400),
+ "graph_render.view.max_edges",
+ allow_zero=True,
+ )
+ max_work = positive_int(view.get("max_work", 100_000), "graph_render.view.max_work")
+ if (
+ depth > min(limits.max_traversal_depth, MAX_GRAPH_VIEW_DEPTH)
+ or max_nodes > min(limits.max_nodes, MAX_GRAPH_VIEW_NODES)
+ or max_edges > MAX_GRAPH_VIEW_EDGES
+ or max_work > MAX_GRAPH_VIEW_WORK
+ ):
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph view exceeds project or fixed safety limits",
+ )
+ include_logic = view.get("include_logic", False)
+ if type(include_logic) is not bool:
+ raise DocForgeError(
+ "invalid_config",
+ "Portable graph include_logic must be Boolean",
+ )
+ if include_logic:
+ raise DocForgeError(
+ "unsupported_renderer",
+ "Portable graph renderer version 1 does not support Logic projections",
+ )
+ views.append(
+ GraphRenderView(
+ view_id=view_id,
+ renderer=renderer,
+ output_path=output,
+ title=_bounded_string(
+ view,
+ "title",
+ descriptor_path,
+ maximum=MAX_GRAPH_VIEW_STRING_CHARS,
+ ),
+ root_node_id=root_node_id,
+ query=query,
+ initial_mode=initial_mode,
+ depth=depth,
+ max_nodes=max_nodes,
+ max_edges=max_edges,
+ max_work=max_work,
+ families=_bounded_strings(
+ view.get("families", []),
+ key="graph_render.view.families",
+ source=descriptor_path,
+ ),
+ relations=_bounded_strings(
+ view.get("relations", []),
+ key="graph_render.view.relations",
+ source=descriptor_path,
+ ),
+ authorities=_bounded_strings(
+ view.get("authorities", []),
+ key="graph_render.view.authorities",
+ source=descriptor_path,
+ ),
+ statuses=_bounded_strings(
+ view.get("statuses", []),
+ key="graph_render.view.statuses",
+ source=descriptor_path,
+ ),
+ tags=_bounded_strings(
+ view.get("tags", []),
+ key="graph_render.view.tags",
+ source=descriptor_path,
+ ),
+ include_logic=include_logic,
+ )
+ )
+ return GraphRenderConfig(
+ output_root=output_root,
+ views=tuple(sorted(views, key=lambda item: item.view_id)),
+ )
diff --git a/src/docforge/graph_rendering.py b/src/docforge/graph_rendering.py
new file mode 100644
index 0000000..ee5c1a7
--- /dev/null
+++ b/src/docforge/graph_rendering.py
@@ -0,0 +1,815 @@
+"""Declared portable graph planning, publication, and receipt-only status."""
+
+from __future__ import annotations
+
+import fcntl
+import json
+import os
+from collections.abc import Callable, Generator
+from contextlib import contextmanager
+from pathlib import Path
+from typing import cast
+
+from ._fs_safety import (
+ atomic_replace_bytes_at,
+ open_confined_directory,
+ read_bounded_file_at,
+ require_bound_directory,
+ safe_file_identity_at,
+)
+from .errors import DocForgeError
+from .graph_projection import (
+ GraphViewRequestV1,
+ build_graph_projection_package,
+ build_graph_view_plan,
+)
+from .models import (
+ GenerationRecordingProject,
+ GraphRenderConfig,
+ GraphRenderView,
+ IncrementalStateProject,
+ ProjectService,
+ ProjectSnapshot,
+ ProjectState,
+)
+from .project import project_root_fingerprint
+from .projection_contract import GraphViewPlanV1, ProjectionReceiptV1, projection_hash
+from .projection_policy import (
+ PortableGraphProjectionMode,
+ validate_portable_graph_projection_mode,
+)
+from .projection_worker import render_projection_in_worker
+
+GRAPH_RENDERER_ID = "portable_graph_html"
+GRAPH_RENDERER_VERSION = "1"
+GRAPH_PUBLICATION_MANIFEST_VERSION = 1
+GRAPH_PUBLICATION_CONTRACT = "docforge.graph-publication"
+MAX_GRAPH_PUBLICATION_BYTES = 256_000
+
+
+class GraphRenderService:
+ """Publish one declared artifact while keeping planning and rendering independent."""
+
+ def __init__(
+ self,
+ project: ProjectService,
+ *,
+ allow_logic: bool = False,
+ portable_graph_policy: PortableGraphProjectionMode = "explicit",
+ ) -> None:
+ self.project = project
+ self.allow_logic = allow_logic
+ self.portable_graph_policy = validate_portable_graph_projection_mode(portable_graph_policy)
+
+ def _require_rendering(self, operation: str) -> None:
+ if self.portable_graph_policy == "disabled":
+ raise DocForgeError(
+ "projection_policy_forbids_operation",
+ "Portable graph projection policy disables rendering work",
+ projection="portable_graph",
+ mode=self.portable_graph_policy,
+ operation=operation,
+ )
+
+ def plan(self, view_id: str) -> dict[str, object]:
+ self._require_rendering("plan")
+ snapshot = self.project.load()
+ view = self._view(self._config(snapshot), view_id)
+ plan = self._plan(snapshot, view)
+ return {
+ "status": "ok",
+ **self._identity(snapshot),
+ "view_id": view.view_id,
+ "plan": plan.as_dict(),
+ }
+
+ def status(self, view_id: str | None = None) -> dict[str, object]:
+ config = self.project.descriptor.graph_render
+ current = self._current_state()
+ if config is None:
+ return self._status_result(
+ current,
+ configured=False,
+ state="not_configured",
+ outputs=[],
+ )
+ views = config.views if view_id is None else (self._view(config, view_id),)
+ first_outputs = [self._manifest_status(view, current) for view in views]
+ outputs = [self._manifest_status(view, current) for view in views]
+ if outputs != first_outputs:
+ for output in outputs:
+ if output["state"] == "current":
+ output["state"] = "stale"
+ output["reason"] = "publication_changed_during_status"
+ final = self._current_state()
+ if final != current:
+ for output in outputs:
+ if output["state"] == "current":
+ output["state"] = "stale"
+ output["reason"] = "source_changed_during_status"
+ identity = final if final is not None else current
+ return self._status_result(
+ identity,
+ configured=True,
+ state="current" if all(item["state"] == "current" for item in outputs) else "stale",
+ outputs=outputs,
+ )
+
+ def render(self, view_id: str) -> dict[str, object]:
+ self._require_rendering("render")
+ with self._lock():
+ current_status = self.status(view_id)
+ current_outputs = cast(list[dict[str, object]], current_status["outputs"])
+ if current_status["state"] == "current" and current_outputs:
+ return {
+ **current_status,
+ "publication": "unchanged",
+ "output": current_outputs[0],
+ }
+ snapshot = self.project.load()
+ view = self._view(self._config(snapshot), view_id)
+ plan = self._plan(snapshot, view)
+ package = build_graph_projection_package(
+ plan,
+ renderer_id=GRAPH_RENDERER_ID,
+ renderer_version=GRAPH_RENDERER_VERSION,
+ max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
+ )
+ result = render_projection_in_worker(package)
+ if len(result.artifacts) != 1:
+ raise DocForgeError(
+ "invalid_projection",
+ "Portable graph renderer returned an unsupported artifact set",
+ )
+ artifact = result.artifacts[0]
+
+ def verify() -> None:
+ current = self.project.load()
+ if (
+ current.revision != snapshot.revision
+ or current.source_hash != snapshot.source_hash
+ ):
+ raise DocForgeError(
+ "render_input_changed",
+ "Canonical input changed during portable graph rendering",
+ )
+
+ verify()
+ if isinstance(self.project, GenerationRecordingProject):
+ self.project.record_generation(snapshot)
+ artifact_evidence = artifact.evidence()
+ try:
+ store_identity = self._publish_artifact(
+ snapshot,
+ artifact_evidence["sha256"],
+ artifact.content,
+ verify=verify,
+ )
+ except DocForgeError as error:
+ if self._mutation_committed(error):
+ return self._degraded_publication(
+ snapshot,
+ view,
+ plan,
+ package.package_id,
+ result.receipt.as_dict(),
+ artifact_evidence,
+ stage="artifact_store",
+ error=error,
+ output_published=False,
+ )
+ raise
+ try:
+ output_identity = self._publish_output(
+ snapshot,
+ view,
+ artifact.content,
+ verify=verify,
+ )
+ except DocForgeError as error:
+ if self._mutation_committed(error):
+ return self._degraded_publication(
+ snapshot,
+ view,
+ plan,
+ package.package_id,
+ result.receipt.as_dict(),
+ artifact_evidence,
+ stage="output",
+ error=error,
+ output_published=True,
+ )
+ raise
+ manifest = self._manifest(
+ snapshot,
+ view,
+ plan,
+ package.package_id,
+ result.receipt.as_dict(),
+ artifact_evidence,
+ store_identity,
+ output_identity,
+ )
+ try:
+ self._publish_manifest(snapshot, view, manifest, verify=verify)
+ except DocForgeError as error:
+ return self._degraded_publication(
+ snapshot,
+ view,
+ plan,
+ package.package_id,
+ result.receipt.as_dict(),
+ artifact_evidence,
+ stage="manifest",
+ error=error,
+ output_published=True,
+ )
+ return {
+ "status": "ok",
+ **self._identity(snapshot),
+ "view_id": view.view_id,
+ "state": "current",
+ "publication": "published",
+ "plan_id": plan.plan_id,
+ "package_id": package.package_id,
+ "output": {
+ **artifact.evidence(),
+ "path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(),
+ },
+ "receipt": result.receipt.as_dict(),
+ "manifest": {
+ "state": "current",
+ "publication_id": manifest["publication_id"],
+ },
+ }
+
+ @staticmethod
+ def _mutation_committed(error: DocForgeError) -> bool:
+ return error.details.get("mutation_committed") is True
+
+ def _degraded_publication(
+ self,
+ snapshot: ProjectSnapshot,
+ view: GraphRenderView,
+ plan: GraphViewPlanV1,
+ package_id: str,
+ receipt: dict[str, object],
+ artifact: dict[str, object],
+ *,
+ stage: str,
+ error: DocForgeError,
+ output_published: bool,
+ ) -> dict[str, object]:
+ return {
+ "status": "ok",
+ **self._identity(snapshot),
+ "view_id": view.view_id,
+ "state": "degraded",
+ "publication": "published" if output_published else "partial",
+ "committed_stage": stage,
+ "plan_id": plan.plan_id,
+ "package_id": package_id,
+ "artifact": artifact,
+ "output": {
+ **artifact,
+ "path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(),
+ "state": "unverified" if output_published else "not_published",
+ },
+ "receipt": receipt,
+ "manifest": {
+ "state": "failed",
+ "error": error.as_dict(),
+ },
+ }
+
+ def _manifest_status(
+ self,
+ view: GraphRenderView,
+ current: ProjectState | None,
+ ) -> dict[str, object]:
+ manifest = self._read_manifest(view)
+ base = {
+ "view_id": view.view_id,
+ "renderer": GRAPH_RENDERER_ID,
+ "renderer_version": GRAPH_RENDERER_VERSION,
+ "path": view.output_path.relative_to(self.project.descriptor.root).as_posix(),
+ "verification": "manifest",
+ }
+ if manifest is None:
+ return {**base, "state": "missing", "reason": "manifest_missing"}
+ if not self._valid_manifest(view, manifest):
+ return {**base, "state": "unverified", "reason": "manifest_invalid"}
+ if current is None:
+ reason = (
+ "source_generation_changed"
+ if isinstance(self.project, GenerationRecordingProject)
+ else "source_generation_unavailable"
+ )
+ return {
+ **base,
+ "state": (
+ "stale"
+ if isinstance(self.project, GenerationRecordingProject)
+ else "unverified"
+ ),
+ "reason": reason,
+ "plan_id": manifest.get("plan_id"),
+ "package_id": manifest.get("package_id"),
+ }
+ project = cast(dict[str, object], manifest["project"])
+ if project["revision"] != current.revision or project["source_hash"] != current.source_hash:
+ return {
+ **base,
+ "state": "stale",
+ "reason": "source_generation_changed",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ artifact = cast(dict[str, object], manifest["artifact"])
+ store = cast(dict[str, object], manifest["store"])
+ artifact_root = self.project.descriptor.cache_root / "projection-artifacts"
+ if not artifact_root.exists():
+ return {
+ **base,
+ "state": "stale",
+ "reason": "artifact_store_missing",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ if artifact_root.is_symlink() or not artifact_root.is_dir():
+ return {
+ **base,
+ "state": "unsafe",
+ "reason": "artifact_store_unsafe",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ artifact_directory: int | None = None
+ try:
+ artifact_directory = open_confined_directory(
+ self.project.descriptor.root,
+ artifact_root,
+ create=False,
+ )
+ artifact_identity = safe_file_identity_at(
+ artifact_root,
+ artifact_directory,
+ f"{artifact['sha256']}.html",
+ )
+ except DocForgeError:
+ return {
+ **base,
+ "state": "unsafe",
+ "reason": "artifact_store_unsafe",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ finally:
+ if artifact_directory is not None:
+ os.close(artifact_directory)
+ if artifact_identity is None:
+ return {
+ **base,
+ "state": "stale",
+ "reason": "artifact_store_missing",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ if artifact_identity != store:
+ return {
+ **base,
+ "state": "stale",
+ "reason": "artifact_store_changed",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ try:
+ directory = open_confined_directory(
+ self.project.descriptor.root,
+ view.output_path.parent,
+ create=False,
+ )
+ except DocForgeError:
+ return {**base, "state": "unsafe", "reason": "output_root_unsafe"}
+ try:
+ identity = safe_file_identity_at(
+ view.output_path.parent, directory, view.output_path.name
+ )
+ except DocForgeError:
+ return {**base, "state": "unsafe", "reason": "output_unsafe"}
+ finally:
+ os.close(directory)
+ expected = cast(dict[str, object], manifest["output"])
+ if identity != expected:
+ return {
+ **base,
+ "state": "stale",
+ "reason": "output_changed",
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ }
+ return {
+ **base,
+ "state": "current",
+ "reason": None,
+ "plan_id": manifest["plan_id"],
+ "package_id": manifest["package_id"],
+ "publication_id": manifest["publication_id"],
+ "artifact": manifest["artifact"],
+ }
+
+ def _read_manifest(self, view: GraphRenderView) -> dict[str, object] | None:
+ root = self._manifest_root()
+ if not root.is_dir() or root.is_symlink():
+ return None
+ try:
+ descriptor = open_confined_directory(
+ self.project.descriptor.root,
+ root,
+ create=False,
+ )
+ except DocForgeError:
+ return None
+ try:
+ raw = read_bounded_file_at(
+ descriptor,
+ f"{view.view_id}.json",
+ MAX_GRAPH_PUBLICATION_BYTES,
+ )
+ except DocForgeError:
+ return None
+ finally:
+ os.close(descriptor)
+ if raw is None:
+ return None
+ try:
+ value: object = json.loads(raw)
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ return cast(dict[str, object], value) if isinstance(value, dict) else None
+
+ def _valid_manifest(self, view: GraphRenderView, manifest: dict[str, object]) -> bool:
+ required = {
+ "schema_version",
+ "contract",
+ "publication_id",
+ "project",
+ "view_id",
+ "view_config_hash",
+ "plan_id",
+ "package_id",
+ "renderer",
+ "receipt",
+ "artifact",
+ "store",
+ "output",
+ }
+ try:
+ if (
+ set(manifest) != required
+ or manifest.get("schema_version") != GRAPH_PUBLICATION_MANIFEST_VERSION
+ or manifest.get("contract") != GRAPH_PUBLICATION_CONTRACT
+ or manifest.get("view_id") != view.view_id
+ or manifest.get("view_config_hash") != self._view_hash(view)
+ or not self._hash(manifest.get("plan_id"))
+ or not self._hash(manifest.get("package_id"))
+ ):
+ return False
+ project = manifest.get("project")
+ descriptor = self.project.descriptor
+ if not isinstance(project, dict):
+ return False
+ project_document = cast(dict[str, object], project)
+ if (
+ set(project_document)
+ != {
+ "project_id",
+ "project_root_fingerprint",
+ "adapter",
+ "revision",
+ "source_hash",
+ }
+ or project_document.get("project_id") != descriptor.project_id
+ or project_document.get("project_root_fingerprint")
+ != project_root_fingerprint(descriptor.root)
+ or project_document.get("adapter") != descriptor.adapter
+ or not isinstance(project_document.get("revision"), str)
+ or not project_document["revision"]
+ or not self._hash(project_document.get("source_hash"))
+ ):
+ return False
+ renderer = manifest.get("renderer")
+ if renderer != {
+ "renderer_id": GRAPH_RENDERER_ID,
+ "renderer_version": GRAPH_RENDERER_VERSION,
+ }:
+ return False
+ receipt_value = manifest.get("receipt")
+ if not isinstance(receipt_value, dict):
+ return False
+ receipt = ProjectionReceiptV1.from_dict(
+ dict(cast(dict[str, object], receipt_value))
+ ).as_dict()
+ artifacts = receipt.get("artifacts")
+ if not isinstance(artifacts, list):
+ return False
+ artifact_values = cast(list[object], artifacts)
+ if (
+ receipt.get("kind") != "graph"
+ or receipt.get("plan_id") != manifest["plan_id"]
+ or receipt.get("package_id") != manifest["package_id"]
+ or receipt.get("renderer") != renderer
+ or len(artifact_values) != 1
+ or manifest.get("artifact") != artifact_values[0]
+ ):
+ return False
+ artifact = artifact_values[0]
+ if not isinstance(artifact, dict):
+ return False
+ artifact_document = cast(dict[str, object], artifact)
+ if (
+ artifact_document.get("artifact_id") != "portable-graph.html"
+ or artifact_document.get("media_type") != "text/html; charset=utf-8"
+ ):
+ return False
+ artifact_hash = artifact_document.get("sha256")
+ artifact_bytes = artifact_document.get("bytes")
+ store = manifest.get("store")
+ output = manifest.get("output")
+ if (
+ not self._file_identity(store, expected_name=f"{artifact_hash}.html")
+ or not self._file_identity(output, expected_name=view.output_path.name)
+ or type(artifact_bytes) is not int
+ or cast(dict[str, object], store)["size"] != artifact_bytes
+ or cast(dict[str, object], output)["size"] != artifact_bytes
+ ):
+ return False
+ body = dict(manifest)
+ publication_id = body.pop("publication_id", None)
+ return self._hash(publication_id) and publication_id == projection_hash(body)
+ except (DocForgeError, KeyError, TypeError, ValueError):
+ return False
+
+ @staticmethod
+ def _hash(value: object) -> bool:
+ return (
+ isinstance(value, str)
+ and len(value) == 64
+ and all(character in "0123456789abcdef" for character in value)
+ )
+
+ @staticmethod
+ def _file_identity(value: object, *, expected_name: str) -> bool:
+ if not isinstance(value, dict):
+ return False
+ document = cast(dict[str, object], value)
+ required = {"path", "device", "inode", "mode", "size", "mtime_ns", "ctime_ns"}
+ return (
+ set(document) == required
+ and document.get("path") == expected_name
+ and all(
+ type(document.get(field)) is int and cast(int, document[field]) >= 0
+ for field in required - {"path"}
+ )
+ )
+
+ def _manifest(
+ self,
+ snapshot: ProjectSnapshot,
+ view: GraphRenderView,
+ plan: GraphViewPlanV1,
+ package_id: str,
+ receipt: dict[str, object],
+ artifact: dict[str, object],
+ store: dict[str, object],
+ output: dict[str, object],
+ ) -> dict[str, object]:
+ body: dict[str, object] = {
+ "schema_version": GRAPH_PUBLICATION_MANIFEST_VERSION,
+ "contract": GRAPH_PUBLICATION_CONTRACT,
+ "project": self._identity(snapshot),
+ "view_id": view.view_id,
+ "view_config_hash": self._view_hash(view),
+ "plan_id": plan.plan_id,
+ "package_id": package_id,
+ "renderer": {
+ "renderer_id": GRAPH_RENDERER_ID,
+ "renderer_version": GRAPH_RENDERER_VERSION,
+ },
+ "receipt": receipt,
+ "artifact": artifact,
+ "store": store,
+ "output": output,
+ }
+ return {**body, "publication_id": projection_hash(body)}
+
+ def _publish_artifact(
+ self,
+ snapshot: ProjectSnapshot,
+ artifact_hash: object,
+ content: bytes,
+ *,
+ verify: Callable[[], None],
+ ) -> dict[str, object]:
+ if not isinstance(artifact_hash, str):
+ raise DocForgeError("invalid_projection", "Artifact hash is invalid")
+ root = snapshot.descriptor.cache_root / "projection-artifacts"
+ descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
+ name = f"{artifact_hash}.html"
+ try:
+ try:
+ existing = read_bounded_file_at(descriptor, name, len(content))
+ except DocForgeError as error:
+ if error.code != "invalid_projection":
+ raise
+ existing = None
+ if existing == content:
+ identity = safe_file_identity_at(root, descriptor, name)
+ assert identity is not None
+ return identity
+ return atomic_replace_bytes_at(root, descriptor, name, content, verify=verify)
+ finally:
+ os.close(descriptor)
+
+ def _publish_output(
+ self,
+ snapshot: ProjectSnapshot,
+ view: GraphRenderView,
+ content: bytes,
+ *,
+ verify: Callable[[], None],
+ ) -> dict[str, object]:
+ root = view.output_path.parent
+ descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
+ try:
+ try:
+ existing = read_bounded_file_at(
+ descriptor,
+ view.output_path.name,
+ len(content),
+ )
+ except DocForgeError as error:
+ if error.code != "invalid_projection":
+ raise
+ existing = None
+ if existing == content:
+ identity = safe_file_identity_at(root, descriptor, view.output_path.name)
+ assert identity is not None
+ return identity
+ return atomic_replace_bytes_at(
+ root,
+ descriptor,
+ view.output_path.name,
+ content,
+ verify=verify,
+ )
+ finally:
+ os.close(descriptor)
+
+ def _publish_manifest(
+ self,
+ snapshot: ProjectSnapshot,
+ view: GraphRenderView,
+ manifest: dict[str, object],
+ *,
+ verify: Callable[[], None],
+ ) -> None:
+ raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
+ if len(raw) > MAX_GRAPH_PUBLICATION_BYTES:
+ raise DocForgeError(
+ "projection_too_large",
+ "Portable graph publication manifest exceeds its fixed limit",
+ )
+ root = self._manifest_root()
+ descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
+ try:
+ atomic_replace_bytes_at(
+ root,
+ descriptor,
+ f"{view.view_id}.json",
+ raw,
+ verify=verify,
+ )
+ finally:
+ os.close(descriptor)
+
+ @contextmanager
+ def _lock(self) -> Generator[None]:
+ root = self.project.descriptor.cache_root
+ descriptor = open_confined_directory(self.project.descriptor.root, root, create=True)
+ lock_descriptor: int | None = None
+ try:
+ lock_descriptor = os.open(
+ "graph-render.lock",
+ os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
+ 0o600,
+ dir_fd=descriptor,
+ )
+ fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
+ require_bound_directory(root, descriptor)
+ yield
+ except OSError as error:
+ raise DocForgeError(
+ "publication_failure",
+ "Portable graph render lock is unavailable",
+ ) from error
+ finally:
+ if lock_descriptor is not None:
+ os.close(lock_descriptor)
+ os.close(descriptor)
+
+ def _plan(self, snapshot: ProjectSnapshot, view: GraphRenderView) -> GraphViewPlanV1:
+ return build_graph_view_plan(
+ snapshot,
+ GraphViewRequestV1(
+ view_id=view.view_id,
+ title=view.title,
+ root_node_id=view.root_node_id,
+ query=view.query,
+ initial_mode=view.initial_mode,
+ depth=view.depth,
+ max_nodes=view.max_nodes,
+ max_edges=view.max_edges,
+ max_work=view.max_work,
+ families=view.families,
+ relations=view.relations,
+ authorities=view.authorities,
+ statuses=view.statuses,
+ tags=view.tags,
+ include_logic=view.include_logic,
+ ),
+ self.allow_logic,
+ )
+
+ def _current_state(self) -> ProjectState | None:
+ if isinstance(self.project, IncrementalStateProject):
+ return self.project.incremental_state()
+ return None
+
+ def _config(self, snapshot: ProjectSnapshot) -> GraphRenderConfig:
+ config = snapshot.descriptor.graph_render
+ if config is None:
+ raise DocForgeError(
+ "graph_render_not_configured",
+ "Project has no portable graph render configuration",
+ )
+ return config
+
+ @staticmethod
+ def _view(config: GraphRenderConfig, view_id: str) -> GraphRenderView:
+ for view in config.views:
+ if view.view_id == view_id:
+ return view
+ raise DocForgeError(
+ "unknown_graph_render_view",
+ "Portable graph view is not declared",
+ view_id=view_id,
+ )
+
+ def _manifest_root(self) -> Path:
+ return self.project.descriptor.cache_root / "projection-publications" / "graph"
+
+ @staticmethod
+ def _view_hash(view: GraphRenderView) -> str:
+ return projection_hash(
+ {
+ "view_id": view.view_id,
+ "renderer": view.renderer,
+ "title": view.title,
+ "root_node_id": view.root_node_id,
+ "query": view.query,
+ "initial_mode": view.initial_mode,
+ "depth": view.depth,
+ "max_nodes": view.max_nodes,
+ "max_edges": view.max_edges,
+ "max_work": view.max_work,
+ "families": list(view.families),
+ "relations": list(view.relations),
+ "authorities": list(view.authorities),
+ "statuses": list(view.statuses),
+ "tags": list(view.tags),
+ "include_logic": view.include_logic,
+ }
+ )
+
+ def _status_result(self, current: ProjectState | None, **payload: object) -> dict[str, object]:
+ descriptor = self.project.descriptor
+ return {
+ "status": "ok",
+ "project_id": descriptor.project_id,
+ "project_root_fingerprint": project_root_fingerprint(descriptor.root),
+ "adapter": descriptor.adapter,
+ "revision": current.revision if current is not None else "unknown",
+ "source_hash": current.source_hash if current is not None else None,
+ **payload,
+ }
+
+ @staticmethod
+ def _identity(snapshot: ProjectSnapshot) -> dict[str, object]:
+ return {
+ "project_id": snapshot.descriptor.project_id,
+ "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
+ "adapter": snapshot.descriptor.adapter,
+ "revision": snapshot.revision,
+ "source_hash": snapshot.source_hash,
+ }
diff --git a/src/docforge/manual_projection.py b/src/docforge/manual_projection.py
new file mode 100644
index 0000000..a249d38
--- /dev/null
+++ b/src/docforge/manual_projection.py
@@ -0,0 +1,208 @@
+"""Pure manual planning over one immutable validated graph generation."""
+
+from __future__ import annotations
+
+import hashlib
+from collections import defaultdict
+from collections.abc import Sequence
+
+from .errors import DocForgeError
+from .models import Edge, ProjectSnapshot, RenderView
+from .project import project_root_fingerprint
+from .projection_contract import ManualRenderPlanV1, ProjectionPackageV1
+
+
+def _edge_dict(edge: Edge) -> dict[str, str]:
+ return {
+ "source_id": edge.source_id,
+ "relation": edge.relation,
+ "target_id": edge.target_id,
+ }
+
+
+def _cycles(node_ids: tuple[str, ...], edges: tuple[Edge, ...]) -> list[list[str]]:
+ """Return deterministic strongly connected components that represent cycles."""
+
+ adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
+ reverse_adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
+ for edge in edges:
+ adjacency[edge.source_id].append(edge.target_id)
+ reverse_adjacency[edge.target_id].append(edge.source_id)
+ for targets in (*adjacency.values(), *reverse_adjacency.values()):
+ targets.sort()
+
+ visited: set[str] = set()
+ finished: list[str] = []
+ for node_id in node_ids:
+ if node_id in visited:
+ continue
+ visited.add(node_id)
+ traversal: list[tuple[str, int]] = [(node_id, 0)]
+ while traversal:
+ current, position = traversal[-1]
+ targets = adjacency[current]
+ if position < len(targets):
+ target = targets[position]
+ traversal[-1] = (current, position + 1)
+ if target not in visited:
+ visited.add(target)
+ traversal.append((target, 0))
+ continue
+ finished.append(current)
+ traversal.pop()
+
+ assigned: set[str] = set()
+ components: list[list[str]] = []
+ for node_id in reversed(finished):
+ if node_id in assigned:
+ continue
+ assigned.add(node_id)
+ component: list[str] = []
+ component_stack = [node_id]
+ while component_stack:
+ current = component_stack.pop()
+ component.append(current)
+ for target in reversed(reverse_adjacency[current]):
+ if target not in assigned:
+ assigned.add(target)
+ component_stack.append(target)
+ component.sort()
+ if len(component) > 1 or component[0] in adjacency[component[0]]:
+ components.append(component)
+ return sorted(components)
+
+
+def build_manual_render_plan(
+ snapshot: ProjectSnapshot,
+ view: RenderView,
+ *,
+ changeset_hash: str | None,
+) -> ManualRenderPlanV1:
+ """Select and describe a complete manual without rendering markup."""
+
+ selected = tuple(
+ node for node in snapshot.nodes if not view.families or node.family in view.families
+ )
+ selected_ids = {node.node_id for node in selected}
+ edges = tuple(
+ edge
+ for edge in snapshot.edges
+ if edge.source_id in selected_ids and edge.target_id in selected_ids
+ )
+ outgoing: dict[str, list[Edge]] = defaultdict(list)
+ incoming: dict[str, list[Edge]] = defaultdict(list)
+ for edge in edges:
+ outgoing[edge.source_id].append(edge)
+ incoming[edge.target_id].append(edge)
+ for values in (*outgoing.values(), *incoming.values()):
+ values.sort(key=lambda edge: (edge.source_id, edge.relation, edge.target_id))
+
+ pages = [
+ {
+ "node_id": node.node_id,
+ "title": node.title,
+ "family": node.family,
+ "authority": node.authority,
+ "status": node.status,
+ "tags": list(node.tags),
+ "summary": node.summary,
+ "content": node.content,
+ "content_hash": node.content_hash,
+ "components": [
+ "manual.node-metadata@1",
+ "manual.summary@1",
+ "manual.commonmark@1",
+ "manual.relationships@1",
+ ],
+ "breadcrumbs": [],
+ "cross_references": [_edge_dict(edge) for edge in outgoing[node.node_id]],
+ "backlinks": [_edge_dict(edge) for edge in incoming[node.node_id]],
+ }
+ for node in selected
+ ]
+ node_ids = tuple(node.node_id for node in selected)
+ connected = {endpoint for edge in edges for endpoint in (edge.source_id, edge.target_id)}
+ return ManualRenderPlanV1.create(
+ {
+ "project": {
+ "project_id": snapshot.descriptor.project_id,
+ "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
+ "adapter": snapshot.descriptor.adapter,
+ "revision": snapshot.revision,
+ "source_hash": snapshot.source_hash,
+ },
+ "view": {
+ "view_id": view.view_id,
+ "title": view.title,
+ "families": list(view.families),
+ "renderer": view.renderer,
+ },
+ "changeset_hash": changeset_hash,
+ "pages": pages,
+ "navigation": [{"node_id": node.node_id, "title": node.title} for node in selected],
+ "search_documents": [
+ {
+ "node_id": node.node_id,
+ "title": node.title,
+ "summary": node.summary,
+ "family": node.family,
+ "status": node.status,
+ "tags": list(node.tags),
+ }
+ for node in selected
+ ],
+ "diagnostics": {
+ "orphans": [node_id for node_id in node_ids if node_id not in connected],
+ "cycles": _cycles(node_ids, edges),
+ },
+ }
+ )
+
+
+def build_manual_projection_package(
+ plan: ManualRenderPlanV1,
+ template_bytes: bytes,
+ *,
+ renderer_id: str,
+ renderer_version: str,
+ max_output_bytes: int,
+ render_identity: str | None = None,
+ fragment_records: Sequence[dict[str, object]] = (),
+) -> ProjectionPackageV1:
+ """Bind one plan and inert template asset for a path-free manual renderer."""
+
+ try:
+ template = template_bytes.decode("utf-8")
+ except UnicodeDecodeError as error:
+ raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error
+ template_asset: dict[str, object] = {
+ "asset_id": "manual.template",
+ "media_type": "text/html; charset=utf-8",
+ "sha256": hashlib.sha256(template_bytes).hexdigest(),
+ "text": template,
+ }
+ if render_identity is not None:
+ template_asset["render_identity"] = render_identity
+ assets = [template_asset]
+ if fragment_records:
+ assets.append(
+ {
+ "asset_id": "manual.fragments",
+ "media_type": "application/vnd.docforge.projection-fragments.v1+json",
+ "records": list(fragment_records),
+ }
+ )
+ return ProjectionPackageV1.create(
+ kind="manual",
+ plan=plan,
+ renderer={"renderer_id": renderer_id, "renderer_version": renderer_version},
+ components=[
+ {"component_id": "manual.document@1"},
+ {"component_id": "manual.commonmark@1"},
+ ],
+ assets=assets,
+ output_policy={
+ "artifact_ids": ["manual.html"],
+ "max_total_bytes": max_output_bytes,
+ },
+ )
diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py
index d90e7b2..21e6544 100644
--- a/src/docforge/mcp_server.py
+++ b/src/docforge/mcp_server.py
@@ -15,11 +15,13 @@ from .application import CanonicalApplicationService, CanonicalApplier, GenericC
from .changesets import ChangesetStore
from .context import compile_context
from .errors import DocForgeError
+from .graph_rendering import GraphRenderService
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 .projection_policy import compose_projection_policy
from .rendering import RenderService
from .retrieval import MAX_TASK_EVIDENCE, TaskKind, build_retrieval_plan
from .telemetry import request, stage
@@ -47,6 +49,8 @@ READ_TOOLS = (
"docforge_get_task_context",
"docforge_validate_project",
"docforge_render_status",
+ "docforge_graph_plan",
+ "docforge_graph_render_status",
"docforge_visualize",
"docforge_stop_visualization",
"docforge_visualization_status",
@@ -134,6 +138,9 @@ class DocForgeService:
no_ast: bool = False,
diagnostics: bool = False,
capability_mode_name: str | None = None,
+ manual_projection_policy: str | None = None,
+ portable_graph_policy: str | None = None,
+ live_viewer_policy: str | None = None,
) -> None:
self.project = project
default_mode: CapabilityMode = (
@@ -153,19 +160,40 @@ class DocForgeService:
render_configured=project.descriptor.render is not None,
application_enabled=application_enabled,
)
+ self.projection_policy = compose_projection_policy(
+ manual=manual_projection_policy,
+ portable_graph=portable_graph_policy,
+ live_viewer=live_viewer_policy,
+ manual_configured=project.descriptor.render is not None,
+ portable_graph_configured=project.descriptor.graph_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,
+ manual_policy=self.projection_policy.manual,
+ )
+ self.graph_rendering = GraphRenderService(
+ self.project,
+ allow_logic=not self.policy.no_ast,
+ portable_graph_policy=self.projection_policy.portable_graph,
+ )
self.application = CanonicalApplicationService(
self.project,
applier_id=canonical_applier_id if application_enabled else None,
applier=canonical_applier if application_enabled else None,
index=self.index,
+ manual_policy=self.projection_policy.manual,
+ )
+ self.visualization = ViewerManagerClient(
+ self.index,
+ live_viewer_policy=self.projection_policy.live_viewer,
)
- self.visualization = ViewerManagerClient(self.index)
self.context_provider = context_provider
self.task_context_available = context_provider is compile_context
self.binding_metadata = dict(binding_metadata or {})
@@ -621,6 +649,7 @@ class DocForgeService:
}
capabilities = self.capabilities()
effective_policy = self.policy.as_dict()
+ projection_policy = self.projection_policy.as_dict()
session_contract: dict[str, object] = {
"schema_version": 1,
"binding": binding,
@@ -630,6 +659,8 @@ class DocForgeService:
"freshness": "current",
},
"effective_policy": effective_policy,
+ "projection_policy": projection_policy,
+ "projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": capabilities,
"render_policies": {
"manual": effective_policy["manual_render"],
@@ -652,6 +683,8 @@ class DocForgeService:
"canonical_paths": [str(path) for path in descriptor.content_roots],
"adapter_policy": self.adapter_policy(),
"effective_policy": effective_policy,
+ "projection_policy": projection_policy,
+ "projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": capabilities,
"session_contract": session_contract,
"proposal_access": proposal_access,
@@ -714,6 +747,8 @@ class DocForgeService:
),
"adapter_policy": self.adapter_policy(),
"effective_policy": self.policy.as_dict(),
+ "projection_policy": self.projection_policy.as_dict(),
+ "projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": self.capabilities(),
"canonical_paths": [
*(relative(path) for path in snapshot.descriptor.content_roots),
@@ -829,6 +864,29 @@ class DocForgeService:
operation_name="mcp.render_status",
)
+ def graph_plan(self, view_id: str) -> dict[str, object]:
+ """Plan one declared portable graph without publishing derived output."""
+
+ return self.invoke(
+ lambda: self.graph_rendering.plan(view_id),
+ synchronize=False,
+ load_error_identity=False,
+ operation_name="mcp.graph_plan",
+ )
+
+ def graph_render_status(
+ self,
+ view_id: str | None = None,
+ ) -> dict[str, object]:
+ """Report portable-graph publication state without planning or rendering."""
+
+ return self.invoke(
+ lambda: self.graph_rendering.status(view_id),
+ synchronize=False,
+ load_error_identity=False,
+ operation_name="mcp.graph_render_status",
+ )
+
def context(
self,
profile: str,
@@ -1575,6 +1633,18 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
return service.render_status(view_id, deep=deep)
+ @server.tool(name="docforge_graph_plan")
+ def graph_plan(view_id: str) -> dict[str, Any]:
+ """Plan one declared portable graph without publishing derived output."""
+
+ return service.graph_plan(view_id)
+
+ @server.tool(name="docforge_graph_render_status")
+ def graph_render_status(view_id: str | None = None) -> dict[str, Any]:
+ """Report portable-graph publication state without rendering."""
+
+ return service.graph_render_status(view_id)
+
@server.tool(name="docforge_visualize")
def visualize(
node_id: str | None = None,
@@ -1622,6 +1692,8 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
get_task_context,
validate_project,
render_status,
+ graph_plan,
+ graph_render_status,
visualize,
stop_visualization,
visualization_status,
@@ -2004,6 +2076,9 @@ def create_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
+ manual_projection_policy: str | None = None,
+ portable_graph_policy: str | None = None,
+ live_viewer_policy: str | None = None,
) -> FastMCP:
project = Project.open(project_root)
return create_project_server(
@@ -2020,6 +2095,9 @@ def create_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode=capability_mode,
+ manual_projection_policy=manual_projection_policy,
+ portable_graph_policy=portable_graph_policy,
+ live_viewer_policy=live_viewer_policy,
)
@@ -2034,6 +2112,9 @@ def create_project_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
+ manual_projection_policy: str | None = None,
+ portable_graph_policy: str | None = None,
+ live_viewer_policy: str | None = None,
) -> FastMCP:
"""Create the full fixed MCP surface for one explicitly configured project service."""
@@ -2047,6 +2128,9 @@ def create_project_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode_name=capability_mode,
+ manual_projection_policy=manual_projection_policy,
+ portable_graph_policy=portable_graph_policy,
+ live_viewer_policy=live_viewer_policy,
)
return _create_bound_server(
service,
@@ -2062,6 +2146,9 @@ def create_read_only_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
+ manual_projection_policy: str | None = None,
+ portable_graph_policy: str | None = None,
+ live_viewer_policy: str | None = None,
) -> FastMCP:
"""Create an adapter-capable MCP server exposing only the fixed read tool surface."""
@@ -2073,6 +2160,9 @@ def create_read_only_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode_name="read" if capability_mode is None else capability_mode,
+ manual_projection_policy=manual_projection_policy,
+ portable_graph_policy=portable_graph_policy,
+ live_viewer_policy=live_viewer_policy,
)
if service.policy.capability_mode != "read":
raise DocForgeError(
@@ -2106,6 +2196,18 @@ def main() -> None:
choices=("read", "proposal", "application", "operator"),
help="Expose the versioned project-bound capability surface",
)
+ parser.add_argument(
+ "--manual-render-policy",
+ choices=("auto", "explicit", "disabled"),
+ )
+ parser.add_argument(
+ "--portable-graph-policy",
+ choices=("explicit", "disabled"),
+ )
+ parser.add_argument(
+ "--live-viewer-policy",
+ choices=("on-demand", "disabled"),
+ )
arguments = parser.parse_args()
create_server(
arguments.project_root,
@@ -2114,6 +2216,9 @@ def main() -> None:
no_ast=arguments.no_ast,
diagnostics=arguments.diagnostics,
capability_mode=arguments.capability_mode,
+ manual_projection_policy=arguments.manual_render_policy,
+ portable_graph_policy=arguments.portable_graph_policy,
+ live_viewer_policy=arguments.live_viewer_policy,
).run(transport="stdio")
diff --git a/src/docforge/models.py b/src/docforge/models.py
index 47d5339..efcd71f 100644
--- a/src/docforge/models.py
+++ b/src/docforge/models.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from pathlib import Path
-from typing import Protocol, runtime_checkable
+from typing import Literal, Protocol, runtime_checkable
@dataclass(frozen=True)
@@ -49,6 +49,33 @@ class RenderConfig:
views: tuple[RenderView, ...]
+@dataclass(frozen=True)
+class GraphRenderView:
+ view_id: str
+ renderer: str
+ output_path: Path
+ title: str
+ root_node_id: str | None
+ query: str | None
+ initial_mode: Literal["nodes", "flow", "web", "logic"]
+ depth: int
+ max_nodes: int
+ max_edges: int
+ max_work: int
+ families: tuple[str, ...]
+ relations: tuple[str, ...]
+ authorities: tuple[str, ...]
+ statuses: tuple[str, ...]
+ tags: tuple[str, ...]
+ include_logic: bool
+
+
+@dataclass(frozen=True)
+class GraphRenderConfig:
+ output_root: Path
+ views: tuple[GraphRenderView, ...]
+
+
@dataclass(frozen=True)
class ContextProfile:
profile_id: str
@@ -78,6 +105,7 @@ class ProjectDescriptor:
allowed_relations: tuple[str, ...]
profiles: tuple[ContextProfile, ...]
limits: Limits
+ graph_render: GraphRenderConfig | None = None
@dataclass(frozen=True)
diff --git a/src/docforge/project.py b/src/docforge/project.py
index 3d689bd..17052b3 100644
--- a/src/docforge/project.py
+++ b/src/docforge/project.py
@@ -25,6 +25,7 @@ from .config_validation import (
string_list,
)
from .errors import DocForgeError
+from .graph_render_config import load_graph_render_config
from .models import (
ContextProfile,
Edge,
@@ -66,6 +67,7 @@ _DESCRIPTOR_KEYS = frozenset(
"derived",
"changesets",
"render",
+ "graph_render",
"graph",
"limits",
"profiles",
@@ -548,7 +550,6 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
for field in defaults.__dataclass_fields__
}
)
-
render = load_render_config(
root,
document.get("render"),
@@ -560,6 +561,18 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
changeset_root=changeset_root,
limits=limits,
)
+ graph_render = load_graph_render_config(
+ root,
+ document.get("graph_render"),
+ descriptor_path=descriptor_path,
+ content_roots=content_roots,
+ authority_files=authority_files,
+ cache_root=cache_root,
+ index_path=index_path,
+ changeset_root=changeset_root,
+ manual_render=render,
+ limits=limits,
+ )
profile_documents = document.get("profiles", [])
if not isinstance(profile_documents, list):
@@ -623,6 +636,7 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
changeset_root=changeset_root,
proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)),
render=render,
+ graph_render=graph_render,
allowed_relations=allowed_relations,
profiles=tuple(profiles),
limits=limits,
diff --git a/src/docforge/projection_contract.py b/src/docforge/projection_contract.py
new file mode 100644
index 0000000..937d84d
--- /dev/null
+++ b/src/docforge/projection_contract.py
@@ -0,0 +1,536 @@
+"""Versioned, canonical contracts shared by independent projection renderers."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from typing import Literal, cast
+
+from .errors import DocForgeError
+
+MANUAL_RENDER_PLAN_CONTRACT = "docforge.manual-render-plan"
+GRAPH_VIEW_PLAN_CONTRACT = "docforge.graph-view-plan"
+PROJECTION_PACKAGE_CONTRACT = "docforge.projection-package"
+PROJECTION_RECEIPT_CONTRACT = "docforge.projection-receipt"
+
+PROJECTION_SCHEMA_VERSION = 1
+MAX_PLAN_BYTES = 16_000_000
+MAX_PACKAGE_BYTES = 24_000_000
+MAX_RECEIPT_BYTES = 128_000
+MAX_PROJECTION_ARTIFACTS = 32
+MAX_GRAPH_VIEW_DEPTH = 32
+MAX_GRAPH_VIEW_NODES = 1_000
+MAX_GRAPH_VIEW_EDGES = 4_000
+MAX_GRAPH_VIEW_WORK = 1_000_000
+MAX_GRAPH_VIEW_FILTERS = 64
+MAX_GRAPH_VIEW_STRING_CHARS = 1_024
+MAX_GRAPH_VIEW_QUERY_CHARS = 10_000
+
+ProjectionKind = Literal["manual", "graph"]
+
+
+def canonical_projection_bytes(value: object) -> bytes:
+ """Return the one canonical UTF-8 representation used for projection identities."""
+
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
+ "utf-8"
+ )
+
+
+def projection_hash(value: object) -> str:
+ return hashlib.sha256(canonical_projection_bytes(value)).hexdigest()
+
+
+def _is_hash(value: object) -> bool:
+ return (
+ isinstance(value, str)
+ and len(value) == 64
+ and all(character in "0123456789abcdef" for character in value)
+ )
+
+
+def _bounded(document: dict[str, object], maximum: int, *, kind: str) -> None:
+ size = len(canonical_projection_bytes(document))
+ if size > maximum:
+ raise DocForgeError(
+ "projection_too_large",
+ f"{kind} exceeds its fixed serialized-size limit",
+ maximum_bytes=maximum,
+ actual_bytes=size,
+ )
+
+
+def _validated_identity(
+ document: dict[str, object],
+ *,
+ identity_field: str,
+ maximum: int,
+ kind: str,
+) -> dict[str, object]:
+ _bounded(document, maximum, kind=kind)
+ identity = document.get(identity_field)
+ if not _is_hash(identity):
+ raise DocForgeError("invalid_projection", f"{kind} identity is invalid")
+ body = dict(document)
+ body.pop(identity_field)
+ if projection_hash(body) != identity:
+ raise DocForgeError("invalid_projection", f"{kind} identity does not match its content")
+ return document
+
+
+def _reject_runtime_authority(value: object) -> None:
+ """Reject structural capabilities while treating selected content as inert data."""
+
+ forbidden_keys = {
+ "command",
+ "database",
+ "database_path",
+ "index_path",
+ "project_root",
+ "project_path",
+ "sql",
+ }
+ if isinstance(value, dict):
+ payload = cast(dict[object, object], value)
+ for key, item in payload.items():
+ if isinstance(key, str) and key in forbidden_keys:
+ raise DocForgeError(
+ "invalid_projection",
+ "Projection package contains forbidden runtime authority",
+ field=key,
+ )
+ if (
+ isinstance(key, str)
+ and (key == "path" or key.endswith("_path"))
+ and isinstance(item, str)
+ and item.startswith("/")
+ ):
+ raise DocForgeError(
+ "invalid_projection",
+ "Projection package contains an absolute runtime path",
+ field=key,
+ )
+ _reject_runtime_authority(item)
+ elif isinstance(value, list):
+ for item in cast(list[object], value):
+ _reject_runtime_authority(item)
+
+
+@dataclass(frozen=True)
+class ManualRenderPlanV1:
+ """One immutable, bounded manual plan prepared from a validated graph generation."""
+
+ document: dict[str, object]
+
+ @property
+ def plan_id(self) -> str:
+ return cast(str, self.document["plan_id"])
+
+ def as_dict(self) -> dict[str, object]:
+ return dict(self.document)
+
+ @classmethod
+ def create(cls, payload: dict[str, object]) -> ManualRenderPlanV1:
+ body = {
+ "schema_version": PROJECTION_SCHEMA_VERSION,
+ "contract": MANUAL_RENDER_PLAN_CONTRACT,
+ **payload,
+ }
+ document = {**body, "plan_id": projection_hash(body)}
+ return cls(validate_manual_render_plan(document))
+
+ @classmethod
+ def from_dict(cls, document: dict[str, object]) -> ManualRenderPlanV1:
+ return cls(validate_manual_render_plan(dict(document)))
+
+
+@dataclass(frozen=True)
+class GraphViewPlanV1:
+ """One immutable, bounded portable-graph plan."""
+
+ document: dict[str, object]
+
+ @property
+ def plan_id(self) -> str:
+ return cast(str, self.document["plan_id"])
+
+ def as_dict(self) -> dict[str, object]:
+ return dict(self.document)
+
+ @classmethod
+ def create(cls, payload: dict[str, object]) -> GraphViewPlanV1:
+ body = {
+ "schema_version": PROJECTION_SCHEMA_VERSION,
+ "contract": GRAPH_VIEW_PLAN_CONTRACT,
+ **payload,
+ }
+ document = {**body, "plan_id": projection_hash(body)}
+ return cls(validate_graph_view_plan(document))
+
+ @classmethod
+ def from_dict(cls, document: dict[str, object]) -> GraphViewPlanV1:
+ return cls(validate_graph_view_plan(dict(document)))
+
+
+@dataclass(frozen=True)
+class ProjectionPackageV1:
+ """Path-free package supplied to one capability-isolated renderer."""
+
+ document: dict[str, object]
+
+ @property
+ def package_id(self) -> str:
+ return cast(str, self.document["package_id"])
+
+ @property
+ def kind(self) -> ProjectionKind:
+ return cast(ProjectionKind, self.document["kind"])
+
+ def as_dict(self) -> dict[str, object]:
+ return dict(self.document)
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ kind: ProjectionKind,
+ plan: ManualRenderPlanV1 | GraphViewPlanV1,
+ renderer: dict[str, object],
+ components: list[dict[str, object]],
+ assets: list[dict[str, object]],
+ output_policy: dict[str, object],
+ ) -> ProjectionPackageV1:
+ body: dict[str, object] = {
+ "schema_version": PROJECTION_SCHEMA_VERSION,
+ "contract": PROJECTION_PACKAGE_CONTRACT,
+ "kind": kind,
+ "plan_id": plan.plan_id,
+ "plan": plan.as_dict(),
+ "renderer": renderer,
+ "components": components,
+ "assets": assets,
+ "output_policy": output_policy,
+ }
+ document = {**body, "package_id": projection_hash(body)}
+ return cls(validate_projection_package(document))
+
+ @classmethod
+ def from_dict(cls, document: dict[str, object]) -> ProjectionPackageV1:
+ return cls(validate_projection_package(dict(document)))
+
+
+@dataclass(frozen=True)
+class ProjectionReceiptV1:
+ """Renderer evidence that contains identities and sizes, never artifact bytes."""
+
+ document: dict[str, object]
+
+ @property
+ def receipt_id(self) -> str:
+ return cast(str, self.document["receipt_id"])
+
+ def as_dict(self) -> dict[str, object]:
+ return dict(self.document)
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ kind: ProjectionKind,
+ package_id: str,
+ plan_id: str,
+ renderer: dict[str, object],
+ artifacts: list[dict[str, object]],
+ diagnostics: dict[str, object],
+ timing: dict[str, object],
+ peak_memory_bytes: int | None,
+ ) -> ProjectionReceiptV1:
+ body: dict[str, object] = {
+ "schema_version": PROJECTION_SCHEMA_VERSION,
+ "contract": PROJECTION_RECEIPT_CONTRACT,
+ "kind": kind,
+ "package_id": package_id,
+ "plan_id": plan_id,
+ "renderer": renderer,
+ "artifacts": artifacts,
+ "diagnostics": diagnostics,
+ "timing": timing,
+ "peak_memory_bytes": peak_memory_bytes,
+ }
+ document = {**body, "receipt_id": projection_hash(body)}
+ return cls(validate_projection_receipt(document))
+
+ @classmethod
+ def from_dict(cls, document: dict[str, object]) -> ProjectionReceiptV1:
+ return cls(validate_projection_receipt(dict(document)))
+
+
+@dataclass(frozen=True)
+class ProjectionArtifact:
+ """One renderer-produced artifact addressed by a logical identifier."""
+
+ artifact_id: str
+ media_type: str
+ content: bytes
+
+ def evidence(self) -> dict[str, object]:
+ return {
+ "artifact_id": self.artifact_id,
+ "media_type": self.media_type,
+ "sha256": hashlib.sha256(self.content).hexdigest(),
+ "bytes": len(self.content),
+ }
+
+
+@dataclass(frozen=True)
+class ProjectionRenderResult:
+ """Artifact bytes plus the bounded renderer receipt that attests them."""
+
+ artifacts: tuple[ProjectionArtifact, ...]
+ receipt: ProjectionReceiptV1
+
+
+def _validate_project_identity(value: object) -> None:
+ if not isinstance(value, dict):
+ raise DocForgeError("invalid_projection", "Projection project identity is invalid")
+ project = cast(dict[str, object], value)
+ if set(project) != {
+ "project_id",
+ "project_root_fingerprint",
+ "adapter",
+ "revision",
+ "source_hash",
+ }:
+ raise DocForgeError("invalid_projection", "Projection project identity is invalid")
+ if not all(
+ isinstance(project.get(key), str) and bool(project[key])
+ for key in ("project_id", "project_root_fingerprint", "adapter", "revision")
+ ) or not _is_hash(project.get("source_hash")):
+ raise DocForgeError("invalid_projection", "Projection project identity is invalid")
+
+
+def validate_manual_render_plan(document: dict[str, object]) -> dict[str, object]:
+ required = {
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "changeset_hash",
+ "pages",
+ "navigation",
+ "search_documents",
+ "diagnostics",
+ }
+ if set(document) != required:
+ raise DocForgeError("invalid_projection", "Manual render plan fields are invalid")
+ if (
+ document.get("schema_version") != PROJECTION_SCHEMA_VERSION
+ or document.get("contract") != MANUAL_RENDER_PLAN_CONTRACT
+ ):
+ raise DocForgeError("invalid_projection", "Manual render plan version is unsupported")
+ _validate_project_identity(document.get("project"))
+ if not all(
+ isinstance(document.get(key), expected)
+ for key, expected in (
+ ("view", dict),
+ ("pages", list),
+ ("navigation", list),
+ ("search_documents", list),
+ ("diagnostics", dict),
+ )
+ ):
+ raise DocForgeError("invalid_projection", "Manual render plan structure is invalid")
+ changeset_hash = document.get("changeset_hash")
+ if changeset_hash is not None and not _is_hash(changeset_hash):
+ raise DocForgeError("invalid_projection", "Manual render plan changeset hash is invalid")
+ return _validated_identity(
+ document,
+ identity_field="plan_id",
+ maximum=MAX_PLAN_BYTES,
+ kind="Manual render plan",
+ )
+
+
+def validate_graph_view_plan(document: dict[str, object]) -> dict[str, object]:
+ required = {
+ "schema_version",
+ "contract",
+ "plan_id",
+ "project",
+ "view",
+ "bounds",
+ "policy",
+ "graph",
+ "omissions",
+ "diagnostics",
+ }
+ if set(document) != required:
+ raise DocForgeError("invalid_projection", "Graph view plan fields are invalid")
+ if (
+ document.get("schema_version") != PROJECTION_SCHEMA_VERSION
+ or document.get("contract") != GRAPH_VIEW_PLAN_CONTRACT
+ ):
+ raise DocForgeError("invalid_projection", "Graph view plan version is unsupported")
+ _validate_project_identity(document.get("project"))
+ if not all(
+ isinstance(document.get(key), expected)
+ for key, expected in (
+ ("view", dict),
+ ("bounds", dict),
+ ("policy", dict),
+ ("graph", dict),
+ ("omissions", list),
+ ("diagnostics", dict),
+ )
+ ):
+ raise DocForgeError("invalid_projection", "Graph view plan structure is invalid")
+ return _validated_identity(
+ document,
+ identity_field="plan_id",
+ maximum=MAX_PLAN_BYTES,
+ kind="Graph view plan",
+ )
+
+
+def validate_projection_package(document: dict[str, object]) -> dict[str, object]:
+ required = {
+ "schema_version",
+ "contract",
+ "package_id",
+ "kind",
+ "plan_id",
+ "plan",
+ "renderer",
+ "components",
+ "assets",
+ "output_policy",
+ }
+ if set(document) != required:
+ raise DocForgeError("invalid_projection", "Projection package fields are invalid")
+ kind = document.get("kind")
+ if (
+ document.get("schema_version") != PROJECTION_SCHEMA_VERSION
+ or document.get("contract") != PROJECTION_PACKAGE_CONTRACT
+ or kind not in {"manual", "graph"}
+ ):
+ raise DocForgeError("invalid_projection", "Projection package version or kind is invalid")
+ plan = document.get("plan")
+ if not isinstance(plan, dict):
+ raise DocForgeError("invalid_projection", "Projection package plan is invalid")
+ validated_plan = (
+ validate_manual_render_plan(cast(dict[str, object], plan))
+ if kind == "manual"
+ else validate_graph_view_plan(cast(dict[str, object], plan))
+ )
+ if document.get("plan_id") != validated_plan.get("plan_id"):
+ raise DocForgeError("invalid_projection", "Projection package plan identity is invalid")
+ if not all(
+ isinstance(document.get(key), expected)
+ for key, expected in (
+ ("renderer", dict),
+ ("components", list),
+ ("assets", list),
+ ("output_policy", dict),
+ )
+ ):
+ raise DocForgeError("invalid_projection", "Projection package structure is invalid")
+ if len(cast(list[object], document["assets"])) > MAX_PROJECTION_ARTIFACTS:
+ raise DocForgeError("projection_too_large", "Projection package has too many assets")
+ _reject_runtime_authority(document)
+ return _validated_identity(
+ document,
+ identity_field="package_id",
+ maximum=MAX_PACKAGE_BYTES,
+ kind="Projection package",
+ )
+
+
+def validate_projection_receipt(document: dict[str, object]) -> dict[str, object]:
+ required = {
+ "schema_version",
+ "contract",
+ "receipt_id",
+ "kind",
+ "package_id",
+ "plan_id",
+ "renderer",
+ "artifacts",
+ "diagnostics",
+ "timing",
+ "peak_memory_bytes",
+ }
+ if set(document) != required:
+ raise DocForgeError("invalid_projection", "Projection receipt fields are invalid")
+ if (
+ document.get("schema_version") != PROJECTION_SCHEMA_VERSION
+ or document.get("contract") != PROJECTION_RECEIPT_CONTRACT
+ or document.get("kind") not in {"manual", "graph"}
+ or not _is_hash(document.get("package_id"))
+ or not _is_hash(document.get("plan_id"))
+ ):
+ raise DocForgeError("invalid_projection", "Projection receipt identity is invalid")
+ artifacts_value = document.get("artifacts")
+ if not isinstance(artifacts_value, list):
+ raise DocForgeError("invalid_projection", "Projection receipt structure is invalid")
+ artifacts = cast(list[object], artifacts_value)
+ renderer = document.get("renderer")
+ diagnostics = document.get("diagnostics")
+ timing = document.get("timing")
+ if (
+ not isinstance(renderer, dict)
+ or not isinstance(diagnostics, dict)
+ or not isinstance(timing, dict)
+ ):
+ raise DocForgeError("invalid_projection", "Projection receipt structure is invalid")
+ renderer_document = cast(dict[str, object], renderer)
+ diagnostics_document = cast(dict[str, object], diagnostics)
+ timing_document = cast(dict[str, object], timing)
+ warnings = diagnostics_document.get("warnings")
+ if (
+ len(artifacts) > MAX_PROJECTION_ARTIFACTS
+ or set(renderer_document) != {"renderer_id", "renderer_version"}
+ or not all(
+ isinstance(renderer_document.get(field), str) and renderer_document[field]
+ for field in ("renderer_id", "renderer_version")
+ )
+ or set(diagnostics_document) != {"warnings"}
+ or not isinstance(warnings, list)
+ or len(cast(list[object], warnings)) > 10_000
+ or not all(isinstance(item, str) for item in cast(list[object], warnings))
+ or set(timing_document) != {"elapsed_ns"}
+ or type(timing_document.get("elapsed_ns")) is not int
+ or cast(int, timing_document["elapsed_ns"]) < 0
+ ):
+ raise DocForgeError("invalid_projection", "Projection receipt structure is invalid")
+ peak = document.get("peak_memory_bytes")
+ if peak is not None and (type(peak) is not int or peak < 0):
+ raise DocForgeError("invalid_projection", "Projection receipt memory value is invalid")
+ artifact_ids: set[str] = set()
+ for artifact in artifacts:
+ if not isinstance(artifact, dict):
+ raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid")
+ item = cast(dict[str, object], artifact)
+ if (
+ set(item) != {"artifact_id", "media_type", "sha256", "bytes"}
+ or not isinstance(item.get("artifact_id"), str)
+ or not item["artifact_id"]
+ or "/" in cast(str, item["artifact_id"])
+ or not isinstance(item.get("media_type"), str)
+ or not item["media_type"]
+ or not _is_hash(item.get("sha256"))
+ or type(item.get("bytes")) is not int
+ or cast(int, item["bytes"]) < 0
+ ):
+ raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid")
+ artifact_id = cast(str, item["artifact_id"])
+ if artifact_id in artifact_ids:
+ raise DocForgeError("invalid_projection", "Projection receipt artifacts are duplicated")
+ artifact_ids.add(artifact_id)
+ return _validated_identity(
+ document,
+ identity_field="receipt_id",
+ maximum=MAX_RECEIPT_BYTES,
+ kind="Projection receipt",
+ )
diff --git a/src/docforge/projection_fragments.py b/src/docforge/projection_fragments.py
new file mode 100644
index 0000000..b2fe3d2
--- /dev/null
+++ b/src/docforge/projection_fragments.py
@@ -0,0 +1,489 @@
+"""Bounded, path-free, disposable projection fragment caching."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal, cast
+
+from ._fs_safety import (
+ atomic_replace_bytes_at,
+ open_confined_directory,
+ read_bounded_file_at,
+ require_bound_directory,
+)
+from .errors import DocForgeError
+from .projection_contract import canonical_projection_bytes, projection_hash
+
+FRAGMENT_SCHEMA_VERSION = 1
+FRAGMENT_KEY_CONTRACT = "docforge.projection-fragment-key"
+FRAGMENT_RECORD_CONTRACT = "docforge.projection-fragment-record"
+FRAGMENT_CACHE_DIRECTORY = "projection-fragments-v1"
+MAX_FRAGMENT_CONTENT_BYTES = 4_000_000
+MAX_FRAGMENT_ID_CHARS = 256
+MAX_FRAGMENT_CACHE_ENTRIES = 10_000
+MAX_FRAGMENT_CACHE_BYTES = 64_000_000
+_MAX_RECORD_OVERHEAD_BYTES = 8_192
+
+ProjectionKind = Literal["manual", "graph"]
+
+
+def _is_hash(value: object) -> bool:
+ return (
+ isinstance(value, str)
+ and len(value) == 64
+ and all(character in "0123456789abcdef" for character in value)
+ )
+
+
+def _version_string(value: object, *, field: str) -> str:
+ if (
+ not isinstance(value, str)
+ or not value
+ or value != value.strip()
+ or len(value) > MAX_FRAGMENT_ID_CHARS
+ or any(ord(character) < 32 for character in value)
+ ):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment identity is invalid",
+ field=field,
+ )
+ return value
+
+
+def fragment_semantic_hash(value: object) -> str:
+ """Hash one complete semantic input using the projection canonical JSON form."""
+
+ try:
+ return hashlib.sha256(canonical_projection_bytes(value)).hexdigest()
+ except (TypeError, ValueError) as error:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment semantic input is not canonical JSON",
+ ) from error
+
+
+@dataclass(frozen=True)
+class FragmentKey:
+ """Versioned identity for one renderer component's complete semantics."""
+
+ projection_kind: ProjectionKind
+ renderer_id: str
+ renderer_version: str
+ component_version: str
+ semantic_input_hash: str
+ key_id: str
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ projection_kind: ProjectionKind,
+ renderer_id: str,
+ renderer_version: str,
+ component_version: str,
+ semantic_input_hash: str,
+ ) -> FragmentKey:
+ body = cls._body(
+ projection_kind=projection_kind,
+ renderer_id=renderer_id,
+ renderer_version=renderer_version,
+ component_version=component_version,
+ semantic_input_hash=semantic_input_hash,
+ )
+ return cls._from_validated({**body, "key_id": projection_hash(body)})
+
+ @classmethod
+ def from_dict(cls, value: object) -> FragmentKey:
+ if not isinstance(value, dict):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment key is invalid",
+ )
+ return cls._from_validated(dict(cast(dict[str, object], value)))
+
+ @staticmethod
+ def _body(
+ *,
+ projection_kind: object,
+ renderer_id: object,
+ renderer_version: object,
+ component_version: object,
+ semantic_input_hash: object,
+ ) -> dict[str, object]:
+ if projection_kind not in {"manual", "graph"}:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment kind is invalid",
+ )
+ if not _is_hash(semantic_input_hash):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment semantic input hash is invalid",
+ )
+ return {
+ "schema_version": FRAGMENT_SCHEMA_VERSION,
+ "contract": FRAGMENT_KEY_CONTRACT,
+ "projection_kind": projection_kind,
+ "renderer_id": _version_string(renderer_id, field="renderer_id"),
+ "renderer_version": _version_string(
+ renderer_version,
+ field="renderer_version",
+ ),
+ "component_version": _version_string(
+ component_version,
+ field="component_version",
+ ),
+ "semantic_input_hash": semantic_input_hash,
+ }
+
+ @classmethod
+ def _from_validated(cls, value: dict[str, object]) -> FragmentKey:
+ required = {
+ "schema_version",
+ "contract",
+ "projection_kind",
+ "renderer_id",
+ "renderer_version",
+ "component_version",
+ "semantic_input_hash",
+ "key_id",
+ }
+ if (
+ set(value) != required
+ or value.get("schema_version") != FRAGMENT_SCHEMA_VERSION
+ or value.get("contract") != FRAGMENT_KEY_CONTRACT
+ ):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment key contract is incompatible",
+ )
+ body = cls._body(
+ projection_kind=value.get("projection_kind"),
+ renderer_id=value.get("renderer_id"),
+ renderer_version=value.get("renderer_version"),
+ component_version=value.get("component_version"),
+ semantic_input_hash=value.get("semantic_input_hash"),
+ )
+ key_id = value.get("key_id")
+ if not _is_hash(key_id) or key_id != projection_hash(body):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment key does not match its semantics",
+ )
+ return cls(
+ projection_kind=cast(ProjectionKind, body["projection_kind"]),
+ renderer_id=cast(str, body["renderer_id"]),
+ renderer_version=cast(str, body["renderer_version"]),
+ component_version=cast(str, body["component_version"]),
+ semantic_input_hash=cast(str, body["semantic_input_hash"]),
+ key_id=cast(str, key_id),
+ )
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ "schema_version": FRAGMENT_SCHEMA_VERSION,
+ "contract": FRAGMENT_KEY_CONTRACT,
+ "projection_kind": self.projection_kind,
+ "renderer_id": self.renderer_id,
+ "renderer_version": self.renderer_version,
+ "component_version": self.component_version,
+ "semantic_input_hash": self.semantic_input_hash,
+ "key_id": self.key_id,
+ }
+
+
+@dataclass(frozen=True)
+class FragmentRecord:
+ """One path-free fragment payload with complete byte evidence."""
+
+ key: FragmentKey
+ content: bytes
+ byte_count: int
+ content_sha256: str
+ record_id: str
+
+ @classmethod
+ def create(cls, key: FragmentKey, content: bytes) -> FragmentRecord:
+ if len(content) > MAX_FRAGMENT_CONTENT_BYTES:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment content is invalid or oversized",
+ maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
+ )
+ body = cls._body(key, content)
+ return cls(
+ key=key,
+ content=content,
+ byte_count=len(content),
+ content_sha256=hashlib.sha256(content).hexdigest(),
+ record_id=projection_hash(body),
+ )
+
+ @classmethod
+ def from_dict(cls, value: object) -> FragmentRecord:
+ if not isinstance(value, dict):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment record is invalid",
+ )
+ document = dict(cast(dict[str, object], value))
+ required = {
+ "schema_version",
+ "contract",
+ "record_id",
+ "key",
+ "content_encoding",
+ "content",
+ "byte_count",
+ "content_sha256",
+ }
+ if (
+ set(document) != required
+ or document.get("schema_version") != FRAGMENT_SCHEMA_VERSION
+ or document.get("contract") != FRAGMENT_RECORD_CONTRACT
+ or document.get("content_encoding") != "base64"
+ ):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment record contract is incompatible",
+ )
+ encoded = document.get("content")
+ if not isinstance(encoded, str):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment content encoding is invalid",
+ )
+ try:
+ content = base64.b64decode(encoded.encode("ascii"), validate=True)
+ except (UnicodeEncodeError, ValueError) as error:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment content encoding is invalid",
+ ) from error
+ if len(content) > MAX_FRAGMENT_CONTENT_BYTES:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment content is oversized",
+ maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
+ )
+ key = FragmentKey.from_dict(document.get("key"))
+ body = cls._body(key, content)
+ record_id = document.get("record_id")
+ if (
+ type(document.get("byte_count")) is not int
+ or document.get("byte_count") != len(content)
+ or document.get("content_sha256") != hashlib.sha256(content).hexdigest()
+ or not _is_hash(record_id)
+ or record_id != projection_hash(body)
+ ):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment byte evidence is invalid",
+ )
+ return cls(
+ key=key,
+ content=content,
+ byte_count=len(content),
+ content_sha256=hashlib.sha256(content).hexdigest(),
+ record_id=cast(str, record_id),
+ )
+
+ @classmethod
+ def from_bytes(cls, raw: bytes) -> FragmentRecord:
+ try:
+ value: object = json.loads(raw)
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment record is not valid JSON",
+ ) from error
+ record = cls.from_dict(value)
+ if record.to_bytes() != raw:
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment record is not canonically serialized",
+ )
+ return record
+
+ @staticmethod
+ def _body(key: FragmentKey, content: bytes) -> dict[str, object]:
+ return {
+ "schema_version": FRAGMENT_SCHEMA_VERSION,
+ "contract": FRAGMENT_RECORD_CONTRACT,
+ "key": key.as_dict(),
+ "content_encoding": "base64",
+ "content": base64.b64encode(content).decode("ascii"),
+ "byte_count": len(content),
+ "content_sha256": hashlib.sha256(content).hexdigest(),
+ }
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ **self._body(self.key, self.content),
+ "record_id": self.record_id,
+ }
+
+ def to_bytes(self) -> bytes:
+ return canonical_projection_bytes(self.as_dict())
+
+
+class ProjectionFragmentCache:
+ """Confined best-effort storage for immutable projection fragments."""
+
+ def __init__(
+ self,
+ project_root: Path,
+ cache_root: Path,
+ *,
+ maximum_content_bytes: int = MAX_FRAGMENT_CONTENT_BYTES,
+ ) -> None:
+ if (
+ type(maximum_content_bytes) is not int
+ or not 1 <= maximum_content_bytes <= MAX_FRAGMENT_CONTENT_BYTES
+ ):
+ raise DocForgeError(
+ "invalid_projection_fragment",
+ "Projection fragment cache byte limit is invalid",
+ maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
+ )
+ self.project_root = project_root
+ self.cache_root = cache_root
+ self.fragment_root = cache_root / FRAGMENT_CACHE_DIRECTORY
+ self.maximum_content_bytes = maximum_content_bytes
+
+ @property
+ def maximum_record_bytes(self) -> int:
+ encoded = ((self.maximum_content_bytes + 2) // 3) * 4
+ return encoded + _MAX_RECORD_OVERHEAD_BYTES
+
+ def get(self, key: FragmentKey) -> FragmentRecord | None:
+ """Return one exact compatible fragment, treating every cache defect as a miss."""
+
+ descriptor: int | None = None
+ try:
+ descriptor = self._open(create=False)
+ raw = read_bounded_file_at(
+ descriptor,
+ f"{key.key_id}.json",
+ self.maximum_record_bytes,
+ )
+ if raw is None:
+ return None
+ record = FragmentRecord.from_bytes(raw)
+ if record.key != key or record.byte_count > self.maximum_content_bytes:
+ return None
+ require_bound_directory(self.fragment_root, descriptor)
+ return record
+ except (DocForgeError, OSError):
+ return None
+ finally:
+ if descriptor is not None:
+ os.close(descriptor)
+
+ def put(self, key: FragmentKey, content: bytes) -> FragmentRecord | None:
+ """Durably publish one fragment, returning ``None`` on disposable cache failure."""
+
+ if len(content) > self.maximum_content_bytes:
+ return None
+ try:
+ record = FragmentRecord.create(key, content)
+ except DocForgeError:
+ return None
+ raw = record.to_bytes()
+ if len(raw) > self.maximum_record_bytes:
+ return None
+ descriptor: int | None = None
+ try:
+ descriptor = self._open(create=True)
+ name = f"{key.key_id}.json"
+ try:
+ existing = read_bounded_file_at(
+ descriptor,
+ name,
+ self.maximum_record_bytes,
+ )
+ except DocForgeError as error:
+ if error.code == "path_escape":
+ return None
+ existing = None
+ if existing == raw:
+ require_bound_directory(self.fragment_root, descriptor)
+ return record
+ atomic_replace_bytes_at(
+ self.fragment_root,
+ descriptor,
+ name,
+ raw,
+ verify=lambda: require_bound_directory(self.fragment_root, descriptor),
+ )
+ return record
+ except (DocForgeError, OSError):
+ return None
+ finally:
+ if descriptor is not None:
+ os.close(descriptor)
+
+ def prune(self, keep: tuple[FragmentKey, ...]) -> bool:
+ """Remove every stale entry and prove the retained inventory is bounded."""
+
+ keep_ids = {key.key_id for key in keep}
+ if len(keep_ids) > MAX_FRAGMENT_CACHE_ENTRIES:
+ return False
+ descriptor: int | None = None
+ try:
+ descriptor = self._open(create=False)
+ retained_entries = 0
+ retained_bytes = 0
+ with os.scandir(descriptor) as entries:
+ for entry in entries:
+ name = entry.name
+ retained = (
+ len(name) == 69
+ and name.endswith(".json")
+ and name[:-5] in keep_ids
+ and all(character in "0123456789abcdef" for character in name[:-5])
+ )
+ if retained:
+ identity = entry.stat(follow_symlinks=False)
+ if not entry.is_file(follow_symlinks=False):
+ return False
+ retained_entries += 1
+ retained_bytes += identity.st_size
+ continue
+ try:
+ os.unlink(name, dir_fd=descriptor)
+ except OSError:
+ return False
+ if (
+ retained_entries > MAX_FRAGMENT_CACHE_ENTRIES
+ or retained_bytes > MAX_FRAGMENT_CACHE_BYTES
+ ):
+ return False
+ require_bound_directory(self.fragment_root, descriptor)
+ os.fsync(descriptor)
+ return True
+ except (DocForgeError, OSError):
+ return False
+ finally:
+ if descriptor is not None:
+ os.close(descriptor)
+
+ def _open(self, *, create: bool) -> int:
+ if self.cache_root == self.project_root or not self.cache_root.is_relative_to(
+ self.project_root
+ ):
+ raise DocForgeError(
+ "path_escape",
+ "Projection fragment cache is not confined to a derived project root",
+ )
+ return open_confined_directory(
+ self.project_root,
+ self.fragment_root,
+ create=create,
+ )
diff --git a/src/docforge/projection_policy.py b/src/docforge/projection_policy.py
new file mode 100644
index 0000000..5c7321d
--- /dev/null
+++ b/src/docforge/projection_policy.py
@@ -0,0 +1,234 @@
+"""Independent version-2 policy for manual, portable graph, and live projections."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from typing import Literal, cast
+
+from .errors import DocForgeError
+
+ManualProjectionMode = Literal["auto", "explicit", "disabled"]
+PortableGraphProjectionMode = Literal["explicit", "disabled"]
+LiveViewerProjectionMode = Literal["on-demand", "disabled"]
+
+MANUAL_PROJECTION_MODES: tuple[ManualProjectionMode, ...] = (
+ "auto",
+ "explicit",
+ "disabled",
+)
+PORTABLE_GRAPH_PROJECTION_MODES: tuple[PortableGraphProjectionMode, ...] = (
+ "explicit",
+ "disabled",
+)
+LIVE_VIEWER_PROJECTION_MODES: tuple[LiveViewerProjectionMode, ...] = (
+ "on-demand",
+ "disabled",
+)
+
+
+def _invalid_mode(field: str, value: object, allowed: tuple[str, ...]) -> DocForgeError:
+ return DocForgeError(
+ "invalid_projection_policy",
+ "Projection policy mode is unsupported",
+ projection=field,
+ mode=value,
+ allowed=list(allowed),
+ )
+
+
+def _select_mode(
+ value: object | None,
+ *,
+ field: str,
+ default: str,
+ allowed: tuple[str, ...],
+) -> str:
+ selected: object = default if value is None else value
+ if not isinstance(selected, str) or selected not in allowed:
+ raise _invalid_mode(field, selected, allowed)
+ return selected
+
+
+def _require_boolean(field: str, value: object) -> bool:
+ if type(value) is not bool:
+ raise DocForgeError(
+ "invalid_projection_policy",
+ "Projection policy availability must be Boolean",
+ field=field,
+ )
+ return value
+
+
+def _unavailable(field: str, mode: str, required: str) -> DocForgeError:
+ return DocForgeError(
+ "projection_policy_unavailable",
+ "Projection policy mode is unavailable",
+ projection=field,
+ mode=mode,
+ required=required,
+ )
+
+
+def validate_manual_projection_mode(value: object) -> ManualProjectionMode:
+ """Validate one direct manual-service policy selection."""
+
+ return cast(
+ ManualProjectionMode,
+ _select_mode(
+ value,
+ field="manual",
+ default="explicit",
+ allowed=MANUAL_PROJECTION_MODES,
+ ),
+ )
+
+
+def validate_portable_graph_projection_mode(
+ value: object,
+) -> PortableGraphProjectionMode:
+ """Validate one direct portable-graph service policy selection."""
+
+ return cast(
+ PortableGraphProjectionMode,
+ _select_mode(
+ value,
+ field="portable_graph",
+ default="explicit",
+ allowed=PORTABLE_GRAPH_PROJECTION_MODES,
+ ),
+ )
+
+
+def validate_live_viewer_projection_mode(value: object) -> LiveViewerProjectionMode:
+ """Validate one direct live-viewer service policy selection."""
+
+ return cast(
+ LiveViewerProjectionMode,
+ _select_mode(
+ value,
+ field="live_viewer",
+ default="on-demand",
+ allowed=LIVE_VIEWER_PROJECTION_MODES,
+ ),
+ )
+
+
+@dataclass(frozen=True)
+class ProjectionPolicyV2:
+ """One immutable policy for three independent projection consumers."""
+
+ manual: ManualProjectionMode
+ portable_graph: PortableGraphProjectionMode
+ live_viewer: LiveViewerProjectionMode
+
+ def __post_init__(self) -> None:
+ if self.manual not in MANUAL_PROJECTION_MODES:
+ raise _invalid_mode("manual", self.manual, MANUAL_PROJECTION_MODES)
+ if self.portable_graph not in PORTABLE_GRAPH_PROJECTION_MODES:
+ raise _invalid_mode(
+ "portable_graph",
+ self.portable_graph,
+ PORTABLE_GRAPH_PROJECTION_MODES,
+ )
+ if self.live_viewer not in LIVE_VIEWER_PROJECTION_MODES:
+ raise _invalid_mode(
+ "live_viewer",
+ self.live_viewer,
+ LIVE_VIEWER_PROJECTION_MODES,
+ )
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ "schema_version": 2,
+ "manual": self.manual,
+ "portable_graph": self.portable_graph,
+ "live_viewer": self.live_viewer,
+ }
+
+ @property
+ def policy_hash(self) -> str:
+ raw = json.dumps(
+ self.as_dict(),
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ return hashlib.sha256(raw).hexdigest()
+
+
+def compose_projection_policy(
+ *,
+ manual: str | None = None,
+ portable_graph: str | None = None,
+ live_viewer: str | None = None,
+ manual_configured: bool,
+ portable_graph_configured: bool,
+ application_enabled: bool,
+ live_viewer_available: bool = True,
+) -> ProjectionPolicyV2:
+ """Compose compatible defaults with explicit availability-checked selections."""
+
+ manual_available = _require_boolean("manual_configured", manual_configured)
+ graph_available = _require_boolean(
+ "portable_graph_configured",
+ portable_graph_configured,
+ )
+ application_available = _require_boolean("application_enabled", application_enabled)
+ viewer_available = _require_boolean("live_viewer_available", live_viewer_available)
+
+ default_manual = (
+ "auto"
+ if manual_available and application_available
+ else ("explicit" if manual_available else "disabled")
+ )
+ default_graph = "explicit" if graph_available else "disabled"
+ default_viewer = "on-demand" if viewer_available else "disabled"
+
+ manual_mode = cast(
+ ManualProjectionMode,
+ _select_mode(
+ manual,
+ field="manual",
+ default=default_manual,
+ allowed=MANUAL_PROJECTION_MODES,
+ ),
+ )
+ graph_mode = cast(
+ PortableGraphProjectionMode,
+ _select_mode(
+ portable_graph,
+ field="portable_graph",
+ default=default_graph,
+ allowed=PORTABLE_GRAPH_PROJECTION_MODES,
+ ),
+ )
+ viewer_mode = cast(
+ LiveViewerProjectionMode,
+ _select_mode(
+ live_viewer,
+ field="live_viewer",
+ default=default_viewer,
+ allowed=LIVE_VIEWER_PROJECTION_MODES,
+ ),
+ )
+
+ if manual_mode != "disabled" and not manual_available:
+ raise _unavailable("manual", manual_mode, "manual_render_config")
+ if manual_mode == "auto" and not application_available:
+ raise _unavailable("manual", manual_mode, "canonical_application")
+ if graph_mode == "explicit" and not graph_available:
+ raise _unavailable(
+ "portable_graph",
+ graph_mode,
+ "portable_graph_render_config",
+ )
+ if viewer_mode == "on-demand" and not viewer_available:
+ raise _unavailable("live_viewer", viewer_mode, "live_viewer_runtime")
+
+ return ProjectionPolicyV2(
+ manual=manual_mode,
+ portable_graph=graph_mode,
+ live_viewer=viewer_mode,
+ )
diff --git a/src/docforge/projection_worker.py b/src/docforge/projection_worker.py
new file mode 100644
index 0000000..dc6d48a
--- /dev/null
+++ b/src/docforge/projection_worker.py
@@ -0,0 +1,436 @@
+"""One-shot detached execution for the fixed built-in projection renderers."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+import os
+import resource
+import subprocess
+import sys
+import tempfile
+from importlib.metadata import version
+from typing import cast
+
+from .errors import DocForgeError
+from .projection_contract import (
+ MAX_PACKAGE_BYTES,
+ MAX_PROJECTION_ARTIFACTS,
+ MAX_RECEIPT_BYTES,
+ ProjectionArtifact,
+ ProjectionPackageV1,
+ ProjectionReceiptV1,
+ ProjectionRenderResult,
+ canonical_projection_bytes,
+)
+
+WORKER_PROTOCOL_VERSION = 1
+MAX_WORKER_ARTIFACT_BYTES = 20_000_000
+MAX_WORKER_REQUEST_BYTES = MAX_PACKAGE_BYTES + 1
+MAX_WORKER_RESPONSE_BYTES = 4 * ((MAX_WORKER_ARTIFACT_BYTES + 2) // 3) + MAX_RECEIPT_BYTES + 256_000
+WORKER_TIMEOUT_SECONDS = 30
+
+_GENERIC_HTML_RENDERER_ID = "generic_html"
+_PORTABLE_GRAPH_RENDERER_ID = "portable_graph_html"
+_PORTABLE_GRAPH_RENDERER_VERSION = "1"
+
+
+def _generic_html_renderer_version() -> str:
+ return f"1+markdown-it-py-{version('markdown-it-py')}"
+
+
+def _worker_failure(message: str, **details: object) -> DocForgeError:
+ return DocForgeError("projection_worker_failure", message, **details)
+
+
+def _renderer_identity(package: ProjectionPackageV1) -> dict[str, object]:
+ renderer_value = package.document.get("renderer")
+ if not isinstance(renderer_value, dict):
+ raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
+ renderer = cast(dict[str, object], renderer_value)
+ if set(renderer) != {"renderer_id", "renderer_version"}:
+ raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
+ renderer_id = renderer.get("renderer_id")
+ renderer_version = renderer.get("renderer_version")
+ if not isinstance(renderer_id, str) or not isinstance(renderer_version, str):
+ raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
+ supported = (
+ package.kind == "manual"
+ and renderer_id == _GENERIC_HTML_RENDERER_ID
+ and renderer_version == _generic_html_renderer_version()
+ ) or (
+ package.kind == "graph"
+ and renderer_id == _PORTABLE_GRAPH_RENDERER_ID
+ and renderer_version == _PORTABLE_GRAPH_RENDERER_VERSION
+ )
+ if not supported:
+ raise DocForgeError(
+ "unsupported_renderer",
+ "Projection worker supports only the fixed built-in renderer versions",
+ )
+ return dict(renderer)
+
+
+def _output_policy(package: ProjectionPackageV1) -> tuple[tuple[str, ...], int]:
+ policy_value = package.document.get("output_policy")
+ if not isinstance(policy_value, dict):
+ raise DocForgeError("invalid_projection", "Projection output policy is invalid")
+ policy = cast(dict[str, object], policy_value)
+ if set(policy) != {"artifact_ids", "max_total_bytes"}:
+ raise DocForgeError("invalid_projection", "Projection output policy is invalid")
+ artifact_ids_value = policy.get("artifact_ids")
+ maximum = policy.get("max_total_bytes")
+ if not isinstance(artifact_ids_value, list):
+ raise DocForgeError("invalid_projection", "Projection artifact inventory is invalid")
+ artifact_ids_objects = cast(list[object], artifact_ids_value)
+ if (
+ not artifact_ids_objects
+ or len(artifact_ids_objects) > MAX_PROJECTION_ARTIFACTS
+ or not all(
+ isinstance(artifact_id, str)
+ and bool(artifact_id)
+ and "/" not in artifact_id
+ and artifact_id not in {".", ".."}
+ for artifact_id in artifact_ids_objects
+ )
+ ):
+ raise DocForgeError("invalid_projection", "Projection artifact inventory is invalid")
+ artifact_ids = tuple(cast(list[str], artifact_ids_objects))
+ if len(set(artifact_ids)) != len(artifact_ids):
+ raise DocForgeError("invalid_projection", "Projection artifact inventory is duplicated")
+ if type(maximum) is not int or maximum < 1:
+ raise DocForgeError(
+ "invalid_projection",
+ "Projection output byte allowance is invalid",
+ )
+ return artifact_ids, maximum
+
+
+def _validated_package(package: object) -> ProjectionPackageV1:
+ if not isinstance(package, ProjectionPackageV1):
+ raise TypeError("Projection worker requires ProjectionPackageV1")
+ validated = ProjectionPackageV1.from_dict(package.as_dict())
+ _renderer_identity(validated)
+ _output_policy(validated)
+ return validated
+
+
+def _validate_result(
+ package: ProjectionPackageV1,
+ result: ProjectionRenderResult,
+ *,
+ require_peak_memory: bool,
+) -> ProjectionRenderResult:
+ artifact_ids, maximum = _output_policy(package)
+ if (
+ len(result.artifacts) != len(artifact_ids)
+ or tuple(artifact.artifact_id for artifact in result.artifacts) != artifact_ids
+ ):
+ raise _worker_failure("Projection worker returned an invalid artifact inventory")
+ total_bytes = 0
+ evidence: list[dict[str, object]] = []
+ for artifact in result.artifacts:
+ if not artifact.media_type or type(artifact.content) is not bytes:
+ raise _worker_failure("Projection worker returned an invalid artifact")
+ total_bytes += len(artifact.content)
+ if total_bytes > maximum or total_bytes > MAX_WORKER_ARTIFACT_BYTES:
+ raise _worker_failure("Projection worker artifact transfer exceeded its fixed boundary")
+ evidence.append(artifact.evidence())
+
+ try:
+ receipt = ProjectionReceiptV1.from_dict(result.receipt.as_dict())
+ except DocForgeError as error:
+ raise _worker_failure("Projection worker receipt is invalid") from error
+ receipt_document = receipt.document
+ renderer = _renderer_identity(package)
+ timing_value = receipt_document.get("timing")
+ if not isinstance(timing_value, dict):
+ raise _worker_failure("Projection worker receipt timing is invalid")
+ timing = cast(dict[str, object], timing_value)
+ elapsed = timing.get("elapsed_ns")
+ peak_memory = receipt_document.get("peak_memory_bytes")
+ if (
+ receipt_document.get("kind") != package.kind
+ or receipt_document.get("package_id") != package.package_id
+ or receipt_document.get("plan_id") != package.document.get("plan_id")
+ or receipt_document.get("renderer") != renderer
+ or receipt_document.get("artifacts") != evidence
+ or type(elapsed) is not int
+ or elapsed < 0
+ or (require_peak_memory and (type(peak_memory) is not int or peak_memory <= 0))
+ ):
+ raise _worker_failure("Projection worker receipt does not attest the requested package")
+ return ProjectionRenderResult(tuple(result.artifacts), receipt)
+
+
+def _decode_canonical_line(raw: bytes, *, maximum: int, label: str) -> dict[str, object]:
+ if type(raw) is not bytes or len(raw) > maximum:
+ raise _worker_failure(f"{label} exceeded its fixed boundary", maximum_bytes=maximum)
+ if not raw or not raw.endswith(b"\n") or raw.count(b"\n") != 1:
+ raise _worker_failure(f"{label} framing is invalid")
+ payload = raw[:-1]
+ try:
+ value: object = json.loads(payload)
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise _worker_failure(f"{label} is not valid JSON") from error
+ if not isinstance(value, dict):
+ raise _worker_failure(f"{label} must be one JSON object")
+ document = cast(dict[str, object], value)
+ if canonical_projection_bytes(document) != payload:
+ raise _worker_failure(f"{label} is not canonical JSON")
+ return document
+
+
+def _encode_request(package: ProjectionPackageV1) -> bytes:
+ encoded = canonical_projection_bytes(package.as_dict()) + b"\n"
+ if len(encoded) > MAX_WORKER_REQUEST_BYTES:
+ raise DocForgeError(
+ "projection_too_large",
+ "Projection worker request exceeds its fixed boundary",
+ maximum_bytes=MAX_WORKER_REQUEST_BYTES,
+ )
+ return encoded
+
+
+def _invoke_worker(request: bytes) -> subprocess.CompletedProcess[bytes]:
+ environment = {key: os.environ[key] for key in ("SYSTEMROOT", "WINDIR") if key in os.environ}
+ environment.update(
+ {
+ "PYTHONIOENCODING": "utf-8",
+ "PYTHONUTF8": "1",
+ }
+ )
+ command = [sys.executable, "-I", "-m", "docforge._projection_worker_main"]
+ with tempfile.TemporaryFile() as output:
+ completed = subprocess.run(
+ command,
+ input=request,
+ stdout=output,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ timeout=WORKER_TIMEOUT_SECONDS,
+ shell=False,
+ cwd=sys.prefix,
+ env=environment,
+ )
+ output.seek(0)
+ stdout = output.read(MAX_WORKER_RESPONSE_BYTES + 1)
+ return subprocess.CompletedProcess(
+ command,
+ completed.returncode,
+ stdout=stdout,
+ )
+
+
+def _decode_response(package: ProjectionPackageV1, raw: bytes) -> ProjectionRenderResult:
+ document = _decode_canonical_line(
+ raw,
+ maximum=MAX_WORKER_RESPONSE_BYTES,
+ label="Projection worker response",
+ )
+ if (
+ set(document) != {"schema_version", "artifacts", "receipt"}
+ or document.get("schema_version") != WORKER_PROTOCOL_VERSION
+ ):
+ raise _worker_failure("Projection worker response contract is invalid")
+ artifact_values = document.get("artifacts")
+ receipt_value = document.get("receipt")
+ if not isinstance(artifact_values, list) or not isinstance(receipt_value, dict):
+ raise _worker_failure("Projection worker response structure is invalid")
+ artifacts: list[ProjectionArtifact] = []
+ total_bytes = 0
+ for value in cast(list[object], artifact_values):
+ if not isinstance(value, dict):
+ raise _worker_failure("Projection worker artifact envelope is invalid")
+ artifact = cast(dict[str, object], value)
+ if set(artifact) != {"artifact_id", "media_type", "content_base64"}:
+ raise _worker_failure("Projection worker artifact envelope is invalid")
+ artifact_id = artifact.get("artifact_id")
+ media_type = artifact.get("media_type")
+ encoded = artifact.get("content_base64")
+ if (
+ not isinstance(artifact_id, str)
+ or not isinstance(media_type, str)
+ or not isinstance(encoded, str)
+ ):
+ raise _worker_failure("Projection worker artifact envelope is invalid")
+ try:
+ content = base64.b64decode(encoded.encode("ascii"), validate=True)
+ except (UnicodeEncodeError, binascii.Error, ValueError) as error:
+ raise _worker_failure("Projection worker artifact encoding is invalid") from error
+ total_bytes += len(content)
+ if total_bytes > MAX_WORKER_ARTIFACT_BYTES:
+ raise _worker_failure("Projection worker artifact transfer exceeded its fixed boundary")
+ artifacts.append(ProjectionArtifact(artifact_id, media_type, content))
+ try:
+ receipt = ProjectionReceiptV1.from_dict(cast(dict[str, object], receipt_value))
+ except DocForgeError as error:
+ raise _worker_failure("Projection worker receipt is invalid") from error
+ return _validate_result(
+ package,
+ ProjectionRenderResult(tuple(artifacts), receipt),
+ require_peak_memory=True,
+ )
+
+
+def render_projection_in_worker(package: ProjectionPackageV1) -> ProjectionRenderResult:
+ """Render one validated path-free package in a fixed one-shot child process."""
+
+ validated = _validated_package(package)
+ request = _encode_request(validated)
+ try:
+ completed = _invoke_worker(request)
+ except subprocess.TimeoutExpired as error:
+ raise DocForgeError(
+ "projection_worker_timeout",
+ "Detached projection worker exceeded its fixed timeout",
+ timeout_seconds=WORKER_TIMEOUT_SECONDS,
+ ) from error
+ except OSError as error:
+ raise _worker_failure("Detached projection worker could not be launched") from error
+ if completed.returncode != 0:
+ if completed.returncode == 3 and completed.stdout:
+ try:
+ failure = _decode_canonical_line(
+ completed.stdout,
+ maximum=MAX_RECEIPT_BYTES,
+ label="Projection worker error response",
+ )
+ error = failure.get("error")
+ error_document = cast(dict[str, object], error) if isinstance(error, dict) else None
+ if (
+ set(failure) == {"schema_version", "error"}
+ and failure.get("schema_version") == WORKER_PROTOCOL_VERSION
+ and error_document is not None
+ and set(error_document) == {"code", "message", "details"}
+ and isinstance(error_document.get("code"), str)
+ and bool(error_document["code"])
+ and isinstance(error_document.get("message"), str)
+ and bool(error_document["message"])
+ and isinstance(error_document.get("details"), dict)
+ ):
+ raise DocForgeError(
+ cast(str, error_document["code"]),
+ cast(str, error_document["message"]),
+ **cast(dict[str, object], error_document["details"]),
+ )
+ except DocForgeError as error:
+ if error.code != "projection_worker_failure":
+ raise
+ if completed.returncode < 0:
+ raise _worker_failure(
+ "Detached projection worker terminated by signal",
+ signal=-completed.returncode,
+ )
+ raise _worker_failure(
+ "Detached projection worker exited unsuccessfully",
+ exit_code=completed.returncode,
+ )
+ if type(completed.stdout) is not bytes:
+ raise _worker_failure("Detached projection worker returned invalid output")
+ return _decode_response(validated, completed.stdout)
+
+
+def _render_package(package: ProjectionPackageV1) -> ProjectionRenderResult:
+ renderer = _renderer_identity(package)
+ if renderer["renderer_id"] == _GENERIC_HTML_RENDERER_ID:
+ from docforge_renderers.manual import ManualHtmlRenderer
+
+ result = ManualHtmlRenderer(cast(str, renderer["renderer_version"])).render(package)
+ else:
+ from docforge_renderers.graph import PortableGraphHtmlRenderer
+
+ result = PortableGraphHtmlRenderer().render(package)
+ return _validate_result(package, result, require_peak_memory=False)
+
+
+def _peak_memory_bytes() -> int:
+ peak = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
+ return max(1, peak if sys.platform == "darwin" else peak * 1024)
+
+
+def _child_response(package: ProjectionPackageV1) -> bytes:
+ result = _render_package(package)
+ original = result.receipt.document
+ receipt = ProjectionReceiptV1.create(
+ kind=package.kind,
+ package_id=package.package_id,
+ plan_id=cast(str, package.document["plan_id"]),
+ renderer=cast(dict[str, object], original["renderer"]),
+ artifacts=[artifact.evidence() for artifact in result.artifacts],
+ diagnostics=cast(dict[str, object], original["diagnostics"]),
+ timing=cast(dict[str, object], original["timing"]),
+ peak_memory_bytes=_peak_memory_bytes(),
+ )
+ validated = _validate_result(
+ package,
+ ProjectionRenderResult(result.artifacts, receipt),
+ require_peak_memory=True,
+ )
+ document: dict[str, object] = {
+ "schema_version": WORKER_PROTOCOL_VERSION,
+ "artifacts": [
+ {
+ "artifact_id": artifact.artifact_id,
+ "media_type": artifact.media_type,
+ "content_base64": base64.b64encode(artifact.content).decode("ascii"),
+ }
+ for artifact in validated.artifacts
+ ],
+ "receipt": validated.receipt.as_dict(),
+ }
+ encoded = canonical_projection_bytes(document) + b"\n"
+ if len(encoded) > MAX_WORKER_RESPONSE_BYTES:
+ raise _worker_failure(
+ "Projection worker response exceeded its fixed boundary",
+ maximum_bytes=MAX_WORKER_RESPONSE_BYTES,
+ )
+ return encoded
+
+
+def _read_child_request() -> ProjectionPackageV1:
+ raw = sys.stdin.buffer.read(MAX_WORKER_REQUEST_BYTES + 1)
+ document = _decode_canonical_line(
+ raw,
+ maximum=MAX_WORKER_REQUEST_BYTES,
+ label="Projection worker request",
+ )
+ return _validated_package(ProjectionPackageV1.from_dict(document))
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run the closed one-request child protocol."""
+
+ arguments = sys.argv[1:] if argv is None else argv
+ if arguments:
+ return 2
+ try:
+ package = _read_child_request()
+ except Exception:
+ return 2
+ try:
+ response = _child_response(package)
+ sys.stdout.buffer.write(response)
+ sys.stdout.buffer.flush()
+ except DocForgeError as error:
+ response = (
+ canonical_projection_bytes(
+ {
+ "schema_version": WORKER_PROTOCOL_VERSION,
+ "error": error.as_dict(),
+ }
+ )
+ + b"\n"
+ )
+ if len(response) <= MAX_RECEIPT_BYTES:
+ sys.stdout.buffer.write(response)
+ sys.stdout.buffer.flush()
+ return 3
+ except Exception:
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/docforge/py.typed b/src/docforge/py.typed
new file mode 100644
index 0000000..e4798ff
--- /dev/null
+++ b/src/docforge/py.typed
@@ -0,0 +1 @@
+# PEP 561 marker for the typed DocForge public package.
diff --git a/src/docforge/render_contract.py b/src/docforge/render_contract.py
index f24ee9b..430dc26 100644
--- a/src/docforge/render_contract.py
+++ b/src/docforge/render_contract.py
@@ -1,31 +1,30 @@
-"""Deterministic built-in renderer contract and safe template primitives."""
+"""Compatibility shim over the versioned manual projection boundary."""
from __future__ import annotations
import hashlib
import html
import json
-import re
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
-from typing import Protocol
-
-from markdown_it import MarkdownIt
+from typing import Protocol, cast
from .errors import DocForgeError
-from .models import Edge, Node, ProjectSnapshot, RenderView
-
-_TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}")
-_ALLOWED_TOKENS = frozenset(
- {
- "docforge_content",
- "docforge_project_id",
- "docforge_render_identity",
- "docforge_title",
- "docforge_view_id",
- }
+from .manual_projection import build_manual_projection_package, build_manual_render_plan
+from .models import ProjectSnapshot, RenderView
+from .projection_contract import (
+ ManualRenderPlanV1,
+ ProjectionPackageV1,
+ ProjectionRenderResult,
)
+from .projection_fragments import (
+ FragmentKey,
+ FragmentRecord,
+ ProjectionFragmentCache,
+ fragment_semantic_hash,
+)
+from .projection_worker import render_projection_in_worker
@dataclass(frozen=True)
@@ -36,6 +35,7 @@ class PreparedRender:
renderer: str
renderer_version: str
template_hash: str
+ projection_receipt: dict[str, object] | None = None
class Renderer(Protocol):
@@ -55,16 +55,18 @@ class Renderer(Protocol):
class GenericHtmlRenderer:
- """Render validated nodes through escaped CommonMark and a strict token template."""
+ """Preserve the public v1 renderer API over the plan-only manual renderer."""
renderer_id = "generic_html"
contract_version = "1"
- def __init__(self) -> None:
- self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False})
+ page_component_version = "manual.page@1"
+
+ def __init__(self, *, incremental: bool = True) -> None:
self.renderer_version = (
f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}"
)
+ self.incremental = incremental
def prepare(
self,
@@ -74,22 +76,6 @@ class GenericHtmlRenderer:
*,
changeset_hash: str | None,
) -> PreparedRender:
- try:
- template = template_bytes.decode("utf-8")
- except UnicodeDecodeError as error:
- raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error
- tokens = _TEMPLATE_TOKEN.findall(template)
- unknown = sorted(set(tokens) - _ALLOWED_TOKENS)
- remainder = _TEMPLATE_TOKEN.sub("", template)
- if unknown or "{{" in remainder or "}}" in remainder:
- raise DocForgeError(
- "invalid_template", "Render template contains unsupported tokens", tokens=unknown
- )
- if tokens.count("docforge_content") != 1:
- raise DocForgeError(
- "invalid_template", "Render template must contain docforge_content exactly once"
- )
-
selected = tuple(
node for node in snapshot.nodes if not view.families or node.family in view.families
)
@@ -128,16 +114,31 @@ class GenericHtmlRenderer:
render_identity = hashlib.sha256(
json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
- content = self._content(selected, selected_edges)
- replacements = {
- "docforge_content": content,
- "docforge_project_id": html.escape(snapshot.descriptor.project_id, quote=True),
- "docforge_render_identity": render_identity,
- "docforge_title": html.escape(view.title, quote=True),
- "docforge_view_id": html.escape(view.view_id, quote=True),
- }
- rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template)
- output = rendered.rstrip().encode("utf-8") + b"\n"
+ plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash)
+ full_package = build_manual_projection_package(
+ plan,
+ template_bytes,
+ renderer_id=self.renderer_id,
+ renderer_version=self.renderer_version,
+ max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
+ render_identity=render_identity,
+ )
+ if self.incremental:
+ result = self._incremental_result(
+ snapshot,
+ plan,
+ template_bytes,
+ full_package=full_package,
+ render_identity=render_identity,
+ )
+ else:
+ result = render_projection_in_worker(full_package)
+ if len(result.artifacts) != 1:
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual renderer returned an unsupported artifact set",
+ )
+ output = result.artifacts[0].content
return PreparedRender(
render_identity=render_identity,
output_hash=hashlib.sha256(output).hexdigest(),
@@ -145,45 +146,114 @@ class GenericHtmlRenderer:
renderer=self.renderer_id,
renderer_version=self.renderer_version,
template_hash=template_hash,
+ projection_receipt=result.receipt.as_dict(),
)
- def _content(self, nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> str:
- navigation = ['
")
- sections = [*navigation]
- edge_map: dict[str, list[Edge]] = {}
- for edge in edges:
- edge_map.setdefault(edge.source_id, []).append(edge)
- for node in nodes:
- sections.extend(
- [
- f'
',
- f"{html.escape(node.title)}
",
- '',
- f"- ID
- {html.escape(node.node_id)}
",
- f"- Family
- {html.escape(node.family)}
",
- f"- Status
- {html.escape(node.status)}
",
- f"- Authority
- {html.escape(node.authority)}
",
- "
",
- f'{html.escape(node.summary)}
',
- self.markdown.render(node.content).rstrip(),
- ]
+ record = cache.get(key)
+ if record is None:
+ missing.append((len(records), key))
+ records.append(record)
+ if not records:
+ return render_projection_in_worker(full_package)
+ full_result: ProjectionRenderResult | None = None
+ new_records: list[FragmentRecord] = []
+ if missing:
+ full_result = render_projection_in_worker(full_package)
+ fragments = self._extract_page_fragments(
+ pages,
+ full_result.artifacts[0].content,
)
- relationships = edge_map.get(node.node_id, [])
- if relationships:
- sections.append('')
- for edge in relationships:
- sections.append(
- f"- {html.escape(edge.relation)}: {html.escape(edge.target_id)}
"
- )
- sections.append("
")
- sections.append("")
- return "\n".join(sections)
+ try:
+ for position, key in missing:
+ record = FragmentRecord.create(key, fragments[position])
+ records[position] = record
+ new_records.append(record)
+ except DocForgeError:
+ return full_result
+ complete_records = [record for record in records if record is not None]
+ if len(complete_records) != len(records):
+ return full_result or render_projection_in_worker(full_package)
+ try:
+ incremental_package = build_manual_projection_package(
+ plan,
+ template_bytes,
+ fragment_records=[record.as_dict() for record in complete_records],
+ renderer_id=self.renderer_id,
+ renderer_version=self.renderer_version,
+ max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
+ render_identity=render_identity,
+ )
+ incremental_result = render_projection_in_worker(incremental_package)
+ except DocForgeError:
+ cache.prune(())
+ return full_result or render_projection_in_worker(full_package)
+ if full_result is None:
+ cache.prune(tuple(record.key for record in complete_records))
+ return incremental_result
+ if tuple(
+ (artifact.artifact_id, artifact.media_type, artifact.content)
+ for artifact in incremental_result.artifacts
+ ) != tuple(
+ (artifact.artifact_id, artifact.media_type, artifact.content)
+ for artifact in full_result.artifacts
+ ):
+ cache.prune(())
+ return full_result
+ for record in new_records:
+ cache.put(record.key, record.content)
+ cache.prune(tuple(record.key for record in complete_records))
+ return incremental_result
+
+ @staticmethod
+ def _extract_page_fragments(
+ pages: list[object],
+ output: bytes,
+ ) -> list[bytes]:
+ """Extract exact deterministic page sections from one trusted full artifact."""
+
+ fragments: list[bytes] = []
+ cursor = 0
+ closing = b""
+ for value in pages:
+ page = cast(dict[str, object], value)
+ node_id = cast(str, page["node_id"])
+ marker = f'
'.encode()
+ start = output.find(marker, cursor)
+ end = output.find(closing, start + len(marker)) if start >= 0 else -1
+ if start < 0 or end < 0:
+ raise DocForgeError(
+ "invalid_projection",
+ "Full manual artifact does not contain its planned page fragments",
+ )
+ end += len(closing)
+ fragments.append(output[start:end])
+ cursor = end
+ return fragments
_RENDERERS: dict[str, type[GenericHtmlRenderer]] = {
@@ -191,13 +261,13 @@ _RENDERERS: dict[str, type[GenericHtmlRenderer]] = {
}
-def renderer_for(view: RenderView) -> Renderer:
+def renderer_for(view: RenderView, *, incremental: bool = True) -> Renderer:
factory = _RENDERERS.get(view.renderer)
if factory is None:
raise DocForgeError(
"unsupported_renderer", "View does not name a supported built-in renderer"
)
- return factory()
+ return factory(incremental=incremental)
def relative_output(snapshot: ProjectSnapshot, path: Path) -> str:
diff --git a/src/docforge/rendering.py b/src/docforge/rendering.py
index 79e4116..86738d3 100644
--- a/src/docforge/rendering.py
+++ b/src/docforge/rendering.py
@@ -26,6 +26,8 @@ from .models import (
RenderView,
)
from .project import project_root_fingerprint
+from .projection_contract import ProjectionReceiptV1
+from .projection_policy import ManualProjectionMode, validate_manual_projection_mode
from .render_contract import PreparedRender, relative_output, renderer_for
from .telemetry import increment, stage
@@ -36,9 +38,26 @@ MAX_RENDER_RECEIPT_BYTES = 64_000
class RenderService:
"""Render only declared views through fixed built-in renderer implementations."""
- def __init__(self, project: ProjectService, changesets: ChangesetStore | None = None) -> None:
+ def __init__(
+ self,
+ project: ProjectService,
+ changesets: ChangesetStore | None = None,
+ *,
+ manual_policy: ManualProjectionMode = "explicit",
+ ) -> None:
self.project = project
self.changesets = changesets or ChangesetStore(project)
+ self.manual_policy = validate_manual_projection_mode(manual_policy)
+
+ def _require_rendering(self, operation: str) -> None:
+ if self.manual_policy == "disabled":
+ raise DocForgeError(
+ "projection_policy_forbids_operation",
+ "Manual projection policy disables rendering work",
+ projection="manual",
+ mode=self.manual_policy,
+ operation=operation,
+ )
def status(self, view_id: str | None = None) -> dict[str, object]:
"""Report publication state from bounded receipts without rendering canonical content."""
@@ -86,6 +105,7 @@ class RenderService:
def deep_status(self, view_id: str | None = None) -> dict[str, object]:
"""Recompute render output as the explicit side-effect-free equivalence oracle."""
+ self._require_rendering("deep_status")
snapshot = self.project.load()
config = snapshot.descriptor.render
if config is None:
@@ -107,7 +127,12 @@ class RenderService:
snapshot.descriptor.root,
view.output_path,
)
- prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
+ prepared, _ = self._prepare(
+ snapshot,
+ view,
+ changeset_hash=None,
+ incremental=False,
+ )
state = "missing"
actual_hash: str | None = None
output = view.output_path
@@ -151,6 +176,7 @@ class RenderService:
)
def render(self, view_id: str) -> dict[str, object]:
+ self._require_rendering("render")
with self._lock():
snapshot = self.project.load()
config = self._config(snapshot)
@@ -373,6 +399,8 @@ class RenderService:
"template_file": final_template_file,
"output_file": final_output_file,
}
+ if prepared.projection_receipt is not None:
+ payload["projection_receipt"] = prepared.projection_receipt
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
raise DocForgeError(
@@ -572,8 +600,10 @@ class RenderService:
renderer = renderer_for(view)
template_file = receipt.get("template_file")
output_file = receipt.get("output_file")
+ fields = set(receipt)
+ projection_receipt = receipt.get("projection_receipt")
return (
- set(receipt) == required
+ fields in (required, required | {"projection_receipt"})
and receipt.get("schema_version") == RENDER_RECEIPT_SCHEMA_VERSION
and receipt.get("project_id") == descriptor.project_id
and receipt.get("project_root_fingerprint") == project_root_fingerprint(descriptor.root)
@@ -602,6 +632,50 @@ class RenderService:
descriptor.limits.max_render_bytes,
)
and cast(dict[str, object], output_file)["size"] == receipt.get("output_bytes")
+ and (
+ projection_receipt is None
+ or self._valid_projection_receipt(
+ projection_receipt,
+ renderer_id=renderer.renderer_id,
+ renderer_version=renderer.renderer_version,
+ output_hash=cast(str, receipt["output_hash"]),
+ output_bytes=cast(int, receipt["output_bytes"]),
+ )
+ )
+ )
+
+ @staticmethod
+ def _valid_projection_receipt(
+ value: object,
+ *,
+ renderer_id: str,
+ renderer_version: str,
+ output_hash: str,
+ output_bytes: int,
+ ) -> bool:
+ if not isinstance(value, dict):
+ return False
+ try:
+ receipt = ProjectionReceiptV1.from_dict(cast(dict[str, object], value))
+ except DocForgeError:
+ return False
+ document = receipt.document
+ return (
+ document.get("kind") == "manual"
+ and document.get("renderer")
+ == {
+ "renderer_id": renderer_id,
+ "renderer_version": renderer_version,
+ }
+ and document.get("artifacts")
+ == [
+ {
+ "artifact_id": "manual.html",
+ "media_type": "text/html; charset=utf-8",
+ "sha256": output_hash,
+ "bytes": output_bytes,
+ }
+ ]
)
@staticmethod
@@ -663,6 +737,7 @@ class RenderService:
"reason": reason,
"verification": "receipt",
"receipt_schema_version": payload.get("schema_version"),
+ "projection_receipt": payload.get("projection_receipt"),
}
def _current_state(self) -> ProjectState | None:
@@ -687,6 +762,7 @@ class RenderService:
}
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
+ self._require_rendering("preview")
with self._lock():
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
config = self._config(snapshot)
@@ -731,11 +807,12 @@ class RenderService:
view: RenderView,
*,
changeset_hash: str | None,
+ incremental: bool = True,
) -> tuple[PreparedRender, bytes]:
increment("render_prepare_calls")
template = self._template_bytes(snapshot, view)
with stage("render.prepare"):
- prepared = renderer_for(view).prepare(
+ prepared = renderer_for(view, incremental=incremental).prepare(
snapshot,
view,
template,
@@ -850,6 +927,7 @@ class RenderService:
"expected_output_hash": prepared.output_hash,
"actual_output_hash": actual_hash,
"template_hash": prepared.template_hash,
+ "projection_receipt": prepared.projection_receipt,
"path": relative_output(snapshot, view.output_path),
"state": state,
}
diff --git a/src/docforge/telemetry.py b/src/docforge/telemetry.py
index 4c9c6d8..1571c24 100644
--- a/src/docforge/telemetry.py
+++ b/src/docforge/telemetry.py
@@ -79,6 +79,7 @@ OPERATION_NAMES = frozenset(
"test",
"benchmark.m1",
"benchmark.m2",
+ "benchmark.m3",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
@@ -96,6 +97,8 @@ OPERATION_NAMES = frozenset(
"mcp.generation_diff",
"mcp.validate_project",
"mcp.render_status",
+ "mcp.graph_plan",
+ "mcp.graph_render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
@@ -117,6 +120,9 @@ OPERATION_NAMES = frozenset(
"cli.impact",
"cli.context",
"cli.generation-diff",
+ "cli.graph-plan",
+ "cli.graph-render",
+ "cli.graph-render-status",
"cli.configure",
"cli.doctor",
"cli.render",
diff --git a/src/docforge/viewer_manager.py b/src/docforge/viewer_manager.py
index cd8ca88..44f0d63 100644
--- a/src/docforge/viewer_manager.py
+++ b/src/docforge/viewer_manager.py
@@ -28,6 +28,10 @@ from .errors import DocForgeError
from .index import ProjectIndex
from .models import IncrementalStateProject
from .project import project_root_fingerprint
+from .projection_policy import (
+ LiveViewerProjectionMode,
+ validate_live_viewer_projection_mode,
+)
from .telemetry import increment, stage
from .visualization import VISUALIZATION_TEMPLATE, VisualizationIndexSnapshot
@@ -623,9 +627,16 @@ class ViewerManager:
class ViewerManagerClient:
"""Project-bound MCP-side client for the separately supervised manager service."""
- def __init__(self, index: ProjectIndex, *, state_path: Path | None = None) -> None:
+ def __init__(
+ self,
+ index: ProjectIndex,
+ *,
+ state_path: Path | None = None,
+ live_viewer_policy: LiveViewerProjectionMode = "on-demand",
+ ) -> None:
self.index = index
self.state_path = state_path or default_state_path()
+ self.live_viewer_policy = validate_live_viewer_projection_mode(live_viewer_policy)
def start(
self,
@@ -634,6 +645,14 @@ class ViewerManagerClient:
query: str | None = None,
depth: int = 1,
) -> dict[str, object]:
+ if self.live_viewer_policy == "disabled":
+ raise DocForgeError(
+ "projection_policy_forbids_operation",
+ "Live viewer projection policy disables viewer startup",
+ projection="live_viewer",
+ mode=self.live_viewer_policy,
+ operation="start",
+ )
if node_id is not None and query is not None:
raise DocForgeError(
"invalid_visualization_target",
diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py
index 621cb83..9dfcb7e 100644
--- a/src/docforge/visualization.py
+++ b/src/docforge/visualization.py
@@ -506,11 +506,11 @@ class VisualizationIndexSnapshot:
)
def source(self, node_id: str) -> dict[str, object]:
- """Return one node's bounded, project-confined UTF-8 source file."""
+ """Return bounded source evidence stored in the pinned index generation."""
with self._connection() as connection:
row = connection.execute(
- "SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
+ "SELECT node_id, source_path, source_anchor, content FROM nodes WHERE node_id = ?",
(node_id,),
).fetchone()
if row is None:
@@ -518,7 +518,8 @@ class VisualizationIndexSnapshot:
"""
SELECT logic.logic_id AS node_id,
owner.source_path AS source_path,
- logic.source_anchor AS source_anchor
+ logic.source_anchor AS source_anchor,
+ owner.content AS content
FROM logic_nodes AS logic
JOIN nodes AS owner ON owner.node_id = logic.owner_node_id
WHERE logic.logic_id = ?
@@ -533,51 +534,26 @@ class VisualizationIndexSnapshot:
"No node has the requested stable ID",
node_id=node_id,
)
- relative = Path(row["source_path"])
- if relative.is_absolute() or ".." in relative.parts or not relative.parts:
- raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id)
- source = self.project_root / relative
- try:
- resolved = source.resolve(strict=True)
- except OSError as error:
+ content = row["content"]
+ if not isinstance(content, str):
raise DocForgeError(
- "missing_source",
- "Node source file is unavailable",
- node_id=node_id,
- ) from error
- if (
- source.is_symlink()
- or resolved != source
- or not source.is_relative_to(self.project_root)
- or not source.is_file()
- ):
- raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id)
- if source.stat().st_size > self.max_source_bytes:
- raise DocForgeError(
- "source_too_large",
- "Node source exceeds the configured source limit",
+ "invalid_index",
+ "Pinned source evidence is invalid",
node_id=node_id,
)
- raw = source.read_bytes()
+ raw = content.encode("utf-8")
if len(raw) > self.max_source_bytes:
raise DocForgeError(
"source_too_large",
- "Node source exceeds the configured source limit",
+ "Pinned source evidence exceeds the configured source limit",
node_id=node_id,
)
- try:
- content = raw.decode("utf-8")
- except UnicodeDecodeError as error:
- raise DocForgeError(
- "invalid_source",
- "Node source is not UTF-8",
- node_id=node_id,
- ) from error
return self._result(
node_id=node_id,
source_path=row["source_path"],
source_anchor=row["source_anchor"],
content=content,
+ source_provenance="index_snapshot",
snapshot=True,
)
diff --git a/src/docforge_renderers/__init__.py b/src/docforge_renderers/__init__.py
new file mode 100644
index 0000000..94c4706
--- /dev/null
+++ b/src/docforge_renderers/__init__.py
@@ -0,0 +1 @@
+"""Capability-isolated renderer implementations for DocForge projection packages."""
diff --git a/src/docforge_renderers/graph.py b/src/docforge_renderers/graph.py
new file mode 100644
index 0000000..a36528b
--- /dev/null
+++ b/src/docforge_renderers/graph.py
@@ -0,0 +1,321 @@
+"""Deterministic self-contained renderer for one portable graph package."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import html
+import json
+from time import perf_counter_ns
+from typing import cast
+
+from docforge.errors import DocForgeError
+from docforge.projection_contract import (
+ ProjectionArtifact,
+ ProjectionPackageV1,
+ ProjectionReceiptV1,
+ ProjectionRenderResult,
+)
+
+PORTABLE_GRAPH_CSS = """
+:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
+* { box-sizing: border-box; }
+body { margin: 0; background: Canvas; color: CanvasText; }
+.skip { position: absolute; left: -10000px; top: auto; }
+.skip:focus { left: 1rem; top: 1rem; z-index: 2; padding: .5rem; background: Canvas; }
+header, main { width: min(96%, 1100px); margin: 0 auto; }
+header { padding: 1rem 0; }
+.controls { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; }
+label { display: grid; gap: .25rem; font-weight: 600; }
+input, select, button { font: inherit; min-height: 2.75rem; padding: .45rem .65rem; }
+button { cursor: pointer; }
+button:focus-visible, input:focus-visible, select:focus-visible { outline: .2rem solid Highlight; }
+.summary { margin: 1rem 0; }
+.layout { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(20rem, 2fr); gap: 1rem; }
+.panel { border: 1px solid GrayText; border-radius: .5rem; padding: 1rem; overflow: auto; }
+html[data-enhanced="true"] main[data-mode="nodes"] .layout,
+html[data-enhanced="true"] main[data-mode="flow"] .layout { grid-template-columns: 1fr; }
+html[data-enhanced="true"] main[data-mode="nodes"] [data-panel="relationships"] { display: none; }
+html[data-enhanced="true"] main[data-mode="flow"] [data-panel="nodes"] { display: none; }
+.node-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
+.node-list button {
+ width: 100%; text-align: left; border: 1px solid GrayText; border-radius: .35rem;
+}
+.node-list button[aria-current="true"] { border-width: .2rem; }
+table { border-collapse: collapse; width: 100%; }
+th, td { text-align: left; border-bottom: 1px solid GrayText; padding: .5rem; vertical-align: top; }
+caption { text-align: left; font-weight: 700; margin-bottom: .5rem; }
+.muted { color: CanvasText; }
+dialog {
+ max-width: min(42rem, calc(100% - 2rem));
+ border: 1px solid GrayText; border-radius: .5rem;
+}
+dialog::backdrop { background: rgb(0 0 0 / 55%); }
+@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } }
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; }
+}
+@media (forced-colors: active) {
+ .panel, .node-list button, dialog { border: 2px solid CanvasText; }
+}
+""".strip()
+
+PORTABLE_GRAPH_JAVASCRIPT = r"""
+(() => {
+ "use strict";
+ const plan = JSON.parse(document.getElementById("docforge-graph-plan").textContent);
+ const nodes = plan.graph.nodes;
+ const edges = plan.graph.edges;
+ const list = document.getElementById("node-list");
+ const rows = document.getElementById("edge-rows");
+ const filter = document.getElementById("filter");
+ const mode = document.getElementById("mode");
+ const main = document.getElementById("main");
+ const status = document.getElementById("status");
+ const dialog = document.getElementById("node-dialog");
+ const detail = document.getElementById("node-detail");
+ const close = document.getElementById("close-dialog");
+ let opener = null;
+ document.documentElement.dataset.enhanced = "true";
+
+ const matches = (node) => {
+ const query = filter.value.trim().toLocaleLowerCase();
+ const fields = [
+ node.node_id, node.title, node.summary, node.family, node.status, ...node.tags
+ ];
+ return !query || fields
+ .join(" ").toLocaleLowerCase().includes(query);
+ };
+ const selectedIds = () => new Set(nodes.filter(matches).map((node) => node.node_id));
+ const render = () => {
+ main.dataset.mode = mode.value;
+ const visible = nodes.filter(matches);
+ const ids = selectedIds();
+ list.replaceChildren(...visible.map((node) => {
+ const item = document.createElement("li");
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = `${node.title} (${node.node_id})`;
+ button.dataset.nodeId = node.node_id;
+ button.addEventListener("click", () => inspect(node, button));
+ item.append(button);
+ return item;
+ }));
+ const visibleEdges = edges.filter((edge) => ids.has(edge.source_id) && ids.has(edge.target_id));
+ rows.replaceChildren(...visibleEdges.map((edge) => {
+ const row = document.createElement("tr");
+ [edge.source_id, edge.relation, edge.target_id].forEach((value) => {
+ const cell = document.createElement("td");
+ cell.textContent = value;
+ row.append(cell);
+ });
+ return row;
+ }));
+ status.textContent = `${visible.length} nodes and ${visibleEdges.length} relationships `
+ + `shown in ${mode.value} mode.`;
+ };
+ const inspect = (node, button) => {
+ opener = button;
+ detail.replaceChildren();
+ const heading = document.createElement("h2");
+ heading.id = "node-dialog-title";
+ heading.textContent = node.title;
+ const identity = document.createElement("p");
+ identity.textContent = `${node.node_id} · ${node.family} · ${node.status}`;
+ const summary = document.createElement("p");
+ summary.textContent = node.summary;
+ detail.append(heading, identity, summary);
+ dialog.showModal();
+ close.focus();
+ };
+ close.addEventListener("click", () => dialog.close());
+ dialog.addEventListener("close", () => opener?.focus());
+ filter.addEventListener("input", render);
+ mode.addEventListener("change", render);
+ render();
+})();
+""".strip()
+
+
+def _csp_hash(content: str) -> str:
+ digest = hashlib.sha256(content.encode("utf-8")).digest()
+ return base64.b64encode(digest).decode("ascii")
+
+
+def _embedded_json(value: object) -> str:
+ return (
+ json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+ .replace("&", "\\u0026")
+ .replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ )
+
+
+def _static_node_markup(plan: dict[str, object]) -> str:
+ graph = cast(dict[str, object], plan["graph"])
+ nodes = cast(list[dict[str, object]], graph["nodes"])
+ parts: list[str] = []
+ for node in nodes:
+ node_id = html.escape(cast(str, node["node_id"]))
+ attribute_node_id = html.escape(cast(str, node["node_id"]), quote=True)
+ title = html.escape(cast(str, node["title"]))
+ family = html.escape(cast(str, node["family"]))
+ status = html.escape(cast(str, node["status"]))
+ summary = html.escape(cast(str, node["summary"]))
+ parts.append(
+ ""
+ f'"
+ f'{family} · {status}
'
+ f"{summary}
"
+ ""
+ )
+ return "".join(parts)
+
+
+def _static_edge_markup(plan: dict[str, object]) -> str:
+ graph = cast(dict[str, object], plan["graph"])
+ edges = cast(list[dict[str, object]], graph["edges"])
+ return "".join(
+ ""
+ f"| {html.escape(cast(str, edge['source_id']))} | "
+ f"{html.escape(cast(str, edge['relation']))} | "
+ f"{html.escape(cast(str, edge['target_id']))} | "
+ "
"
+ for edge in edges
+ )
+
+
+class PortableGraphHtmlRenderer:
+ """Render a validated graph package without querying or publishing project state."""
+
+ renderer_id = "portable_graph_html"
+ renderer_version = "1"
+
+ def render(self, package: ProjectionPackageV1) -> ProjectionRenderResult:
+ started = perf_counter_ns()
+ package = ProjectionPackageV1.from_dict(package.as_dict())
+ document = package.document
+ if package.kind != "graph":
+ raise DocForgeError("invalid_projection", "Graph renderer requires a graph package")
+ renderer = cast(dict[str, object], document["renderer"])
+ if renderer != {
+ "renderer_id": self.renderer_id,
+ "renderer_version": self.renderer_version,
+ }:
+ raise DocForgeError("unsupported_renderer", "Graph renderer identity is incompatible")
+ if document["components"] != [
+ {"component_id": "graph.portable-document@1"},
+ {"component_id": "graph.accessible-list@1"},
+ {"component_id": "graph.relationship-table@1"},
+ ]:
+ raise DocForgeError(
+ "invalid_projection",
+ "Portable graph renderer component declarations are incompatible",
+ )
+ if document["assets"] != []:
+ raise DocForgeError(
+ "invalid_projection",
+ "Portable graph renderer does not accept project-provided assets",
+ )
+ plan = cast(dict[str, object], document["plan"])
+ view = cast(dict[str, object], plan["view"])
+ initial_mode = view.get("initial_mode")
+ policy = cast(dict[str, object], plan["policy"])
+ if (
+ initial_mode not in {"nodes", "flow", "web"}
+ or policy.get("logic_requested") is not False
+ ):
+ raise DocForgeError(
+ "unsupported_renderer",
+ "Portable graph renderer version 1 does not render Logic projections",
+ )
+ title = html.escape(cast(str, view["title"]))
+ project = cast(dict[str, object], plan["project"])
+ embedded = _embedded_json(plan)
+ static_nodes = _static_node_markup(plan)
+ static_edges = _static_edge_markup(plan)
+ csp = (
+ "default-src 'none'; "
+ f"style-src 'sha256-{_csp_hash(PORTABLE_GRAPH_CSS)}'; "
+ f"script-src 'sha256-{_csp_hash(PORTABLE_GRAPH_JAVASCRIPT)}'; "
+ "img-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; "
+ "form-action 'none'; frame-ancestors 'none'"
+ )
+ output = (
+ "\n"
+ '\n'
+ "\n"
+ '\n'
+ '\n'
+ '\n'
+ f"{title} · DocForge graph\n"
+ f"\n"
+ "\n"
+ "\n"
+ 'Skip to graph content\n'
+ "\n"
+ f"{title}
\n"
+ f'Generation {html.escape(cast(str, project["source_hash"]))}
\n'
+ '\n'
+ '\n'
+ '\n"
+ "
\n"
+ '\n'
+ "\n"
+ f'\n'
+ '\n'
+ '
\n'
+ '
'
+ 'Relationships
'
+ "Selected graph facts"
+ '| Source | Relation | '
+ f'Target |
{static_edges}'
+ "
"
+ "\n"
+ "
\n"
+ "\n"
+ '\n'
+ f'\n'
+ f"\n"
+ "\n"
+ "\n"
+ ).encode()
+ policy = cast(dict[str, object], document["output_policy"])
+ maximum = policy.get("max_total_bytes")
+ if set(policy) != {"artifact_ids", "max_total_bytes"} or policy.get("artifact_ids") != [
+ "portable-graph.html"
+ ]:
+ raise DocForgeError(
+ "invalid_projection",
+ "Portable graph output policy is incompatible",
+ )
+ if type(maximum) is not int or maximum < 1 or len(output) > maximum:
+ raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
+ artifact = ProjectionArtifact(
+ artifact_id="portable-graph.html",
+ media_type="text/html; charset=utf-8",
+ content=output,
+ )
+ receipt = ProjectionReceiptV1.create(
+ kind="graph",
+ package_id=package.package_id,
+ plan_id=cast(str, document["plan_id"]),
+ renderer=dict(renderer),
+ artifacts=[artifact.evidence()],
+ diagnostics={
+ "warnings": [],
+ },
+ timing={"elapsed_ns": perf_counter_ns() - started},
+ peak_memory_bytes=None,
+ )
+ return ProjectionRenderResult((artifact,), receipt)
diff --git a/src/docforge_renderers/manual.py b/src/docforge_renderers/manual.py
new file mode 100644
index 0000000..e816919
--- /dev/null
+++ b/src/docforge_renderers/manual.py
@@ -0,0 +1,264 @@
+"""Plan-only renderer for the built-in DocForge manual artifact."""
+
+from __future__ import annotations
+
+import hashlib
+import html
+import re
+from time import perf_counter_ns
+from typing import cast
+
+from markdown_it import MarkdownIt
+
+from docforge.errors import DocForgeError
+from docforge.projection_contract import (
+ ProjectionArtifact,
+ ProjectionPackageV1,
+ ProjectionReceiptV1,
+ ProjectionRenderResult,
+)
+from docforge.projection_fragments import (
+ FragmentRecord,
+ fragment_semantic_hash,
+)
+
+_TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}")
+_ALLOWED_TOKENS = frozenset(
+ {
+ "docforge_content",
+ "docforge_project_id",
+ "docforge_render_identity",
+ "docforge_title",
+ "docforge_view_id",
+ }
+)
+_ACTIVE_TEMPLATE_CONTENT = re.compile(
+ r"<\s*(?:script|iframe|object|embed)\b"
+ r"|\son[a-z0-9_-]+\s*="
+ r"|javascript\s*:"
+ r"|<\s*meta\b[^>]*\bhttp-equiv\s*=\s*[\"']?\s*refresh\b",
+ re.IGNORECASE,
+)
+_MANUAL_PAGE_COMPONENT = "manual.page@1"
+
+
+class ManualHtmlRenderer:
+ """Transform one validated path-free package without graph-selection authority."""
+
+ renderer_id = "generic_html"
+
+ def __init__(self, renderer_version: str) -> None:
+ self.renderer_version = renderer_version
+ self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False})
+
+ def render(
+ self,
+ package: ProjectionPackageV1,
+ *,
+ render_identity: str | None = None,
+ ) -> ProjectionRenderResult:
+ started = perf_counter_ns()
+ package = ProjectionPackageV1.from_dict(package.as_dict())
+ document = package.document
+ if package.kind != "manual":
+ raise DocForgeError("invalid_projection", "Manual renderer requires a manual package")
+ renderer = cast(dict[str, object], document["renderer"])
+ if renderer != {
+ "renderer_id": self.renderer_id,
+ "renderer_version": self.renderer_version,
+ }:
+ raise DocForgeError("unsupported_renderer", "Manual renderer identity is incompatible")
+ plan = cast(dict[str, object], document["plan"])
+ assets = cast(list[object], document["assets"])
+ if len(assets) not in {1, 2} or not isinstance(assets[0], dict):
+ raise DocForgeError("invalid_projection", "Manual template asset is invalid")
+ asset = cast(dict[str, object], assets[0])
+ if (
+ set(asset)
+ not in (
+ {"asset_id", "media_type", "sha256", "text"},
+ {"asset_id", "media_type", "sha256", "text", "render_identity"},
+ )
+ or asset.get("asset_id") != "manual.template"
+ or asset.get("media_type") != "text/html; charset=utf-8"
+ or not isinstance(asset.get("text"), str)
+ ):
+ raise DocForgeError("invalid_projection", "Manual template asset is invalid")
+ template = cast(str, asset["text"])
+ template_bytes = template.encode("utf-8")
+ if hashlib.sha256(template_bytes).hexdigest() != asset.get("sha256"):
+ raise DocForgeError("invalid_projection", "Manual template asset hash is invalid")
+ if _ACTIVE_TEMPLATE_CONTENT.search(template):
+ raise DocForgeError(
+ "invalid_template",
+ "Render template contains active or executable content",
+ )
+ tokens = _TEMPLATE_TOKEN.findall(template)
+ unknown = sorted(set(tokens) - _ALLOWED_TOKENS)
+ remainder = _TEMPLATE_TOKEN.sub("", template)
+ if unknown or "{{" in remainder or "}}" in remainder:
+ raise DocForgeError(
+ "invalid_template",
+ "Render template contains unsupported tokens",
+ tokens=unknown,
+ )
+ if tokens.count("docforge_content") != 1:
+ raise DocForgeError(
+ "invalid_template",
+ "Render template must contain docforge_content exactly once",
+ )
+ packaged_identity = asset.get("render_identity")
+ if packaged_identity is not None and (
+ not isinstance(packaged_identity, str)
+ or len(packaged_identity) != 64
+ or any(character not in "0123456789abcdef" for character in packaged_identity)
+ ):
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual render identity is invalid",
+ )
+ if render_identity is not None and packaged_identity not in {None, render_identity}:
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual render identity is inconsistent",
+ )
+ identity = render_identity or packaged_identity or cast(str, plan["plan_id"])
+ fragments = self._fragments(plan, assets[1:] if len(assets) == 2 else [])
+ project = cast(dict[str, object], plan["project"])
+ view = cast(dict[str, object], plan["view"])
+ replacements = {
+ "docforge_content": self._content(plan, fragments),
+ "docforge_project_id": html.escape(cast(str, project["project_id"]), quote=True),
+ "docforge_render_identity": identity,
+ "docforge_title": html.escape(cast(str, view["title"]), quote=True),
+ "docforge_view_id": html.escape(cast(str, view["view_id"]), quote=True),
+ }
+ rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template)
+ output = rendered.rstrip().encode("utf-8") + b"\n"
+ policy = cast(dict[str, object], document["output_policy"])
+ maximum = policy.get("max_total_bytes")
+ if type(maximum) is not int or maximum < 1 or len(output) > maximum:
+ raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
+ artifact = ProjectionArtifact(
+ artifact_id="manual.html",
+ media_type="text/html; charset=utf-8",
+ content=output,
+ )
+ receipt = ProjectionReceiptV1.create(
+ kind="manual",
+ package_id=package.package_id,
+ plan_id=cast(str, document["plan_id"]),
+ renderer=dict(renderer),
+ artifacts=[artifact.evidence()],
+ diagnostics={"warnings": []},
+ timing={"elapsed_ns": perf_counter_ns() - started},
+ peak_memory_bytes=None,
+ )
+ return ProjectionRenderResult((artifact,), receipt)
+
+ def _fragments(
+ self,
+ plan: dict[str, object],
+ assets: list[object],
+ ) -> dict[str, str]:
+ if not assets:
+ return {}
+ asset = assets[0]
+ if not isinstance(asset, dict):
+ raise DocForgeError("invalid_projection", "Manual fragment asset is invalid")
+ document = cast(dict[str, object], asset)
+ if (
+ set(document) != {"asset_id", "media_type", "records"}
+ or document.get("asset_id") != "manual.fragments"
+ or document.get("media_type") != "application/vnd.docforge.projection-fragments.v1+json"
+ or not isinstance(document.get("records"), list)
+ ):
+ raise DocForgeError("invalid_projection", "Manual fragment asset is invalid")
+ pages = cast(list[object], plan["pages"])
+ records = cast(list[object], document["records"])
+ if len(records) != len(pages):
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual fragment inventory is incomplete",
+ )
+ fragments: dict[str, str] = {}
+ for page_value, record_value in zip(pages, records, strict=True):
+ if not isinstance(page_value, dict):
+ raise DocForgeError("invalid_projection", "Manual page is invalid")
+ page = cast(dict[str, object], page_value)
+ record = FragmentRecord.from_dict(record_value)
+ if (
+ record.key.projection_kind != "manual"
+ or record.key.renderer_id != self.renderer_id
+ or record.key.renderer_version != self.renderer_version
+ or record.key.component_version != _MANUAL_PAGE_COMPONENT
+ or record.key.semantic_input_hash != fragment_semantic_hash(page)
+ ):
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual fragment identity does not match its page semantics",
+ )
+ try:
+ fragment = record.content.decode("utf-8")
+ except UnicodeDecodeError as error:
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual fragment content is not valid UTF-8",
+ ) from error
+ node_id = cast(str, page["node_id"])
+ if node_id in fragments or fragment != self.render_page_fragment(page):
+ raise DocForgeError(
+ "invalid_projection",
+ "Manual fragment content does not match its page semantics",
+ )
+ fragments[node_id] = fragment
+ return fragments
+
+ def render_page_fragment(self, page: dict[str, object]) -> str:
+ """Render one page from complete plan semantics without project authority."""
+
+ node_id = cast(str, page["node_id"])
+ sections = [
+ f'',
+ f"{html.escape(cast(str, page['title']))}
",
+ '',
+ f"- ID
- {html.escape(node_id)}
",
+ f"- Family
- {html.escape(cast(str, page['family']))}
",
+ f"- Status
- {html.escape(cast(str, page['status']))}
",
+ f"- Authority
- {html.escape(cast(str, page['authority']))}
",
+ "
",
+ f'{html.escape(cast(str, page["summary"]))}
',
+ self.markdown.render(cast(str, page["content"])).rstrip(),
+ ]
+ relationships = cast(list[object], page["cross_references"])
+ if relationships:
+ sections.append('')
+ for relationship_value in relationships:
+ relationship = cast(dict[str, object], relationship_value)
+ sections.append(
+ f"- {html.escape(cast(str, relationship['relation']))}: "
+ f"{html.escape(cast(str, relationship['target_id']))}
"
+ )
+ sections.append("
")
+ sections.append("")
+ return "\n".join(sections)
+
+ def _content(
+ self,
+ plan: dict[str, object],
+ fragments: dict[str, str],
+ ) -> str:
+ navigation = ['")
+ sections = [*navigation]
+ for value in cast(list[object], plan["pages"]):
+ page = cast(dict[str, object], value)
+ node_id = cast(str, page["node_id"])
+ sections.append(fragments.get(node_id, self.render_page_fragment(page)))
+ return "\n".join(sections)
diff --git a/src/docforge_renderers/py.typed b/src/docforge_renderers/py.typed
new file mode 100644
index 0000000..e75b43f
--- /dev/null
+++ b/src/docforge_renderers/py.typed
@@ -0,0 +1 @@
+# PEP 561 marker for the typed DocForge renderer package.
diff --git a/tests/accessibility.spec.mjs b/tests/accessibility.spec.mjs
new file mode 100644
index 0000000..82f92c3
--- /dev/null
+++ b/tests/accessibility.spec.mjs
@@ -0,0 +1,187 @@
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test } from "@playwright/test";
+import { spawn } from "node:child_process";
+import { createInterface } from "node:readline";
+
+const AXE_TAGS = [
+ "wcag2a",
+ "wcag2aa",
+ "wcag21a",
+ "wcag21aa",
+ "wcag22a",
+ "wcag22aa",
+];
+const MANUAL_AXE_TAGS = AXE_TAGS.filter((tag) => !tag.startsWith("wcag22"));
+
+let fixtureProcess;
+let surfaces;
+
+function startFixture() {
+ const python = process.env.DOCFORGE_PYTHON || ".venv/bin/python";
+ const child = spawn(python, ["tools/accessibility_fixture.py"], {
+ cwd: process.cwd(),
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ let stderr = "";
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ const lines = createInterface({ input: child.stdout });
+ const ready = new Promise((resolve, reject) => {
+ let settled = false;
+ lines.once("line", (line) => {
+ settled = true;
+ try {
+ const payload = JSON.parse(line);
+ if (
+ payload.schema_version !== 1
+ || typeof payload.manual_html !== "string"
+ || typeof payload.portable_html !== "string"
+ || typeof payload.live_url !== "string"
+ ) {
+ throw new Error("Accessibility fixture returned an invalid payload");
+ }
+ resolve(payload);
+ } catch (error) {
+ reject(error);
+ } finally {
+ lines.close();
+ }
+ });
+ child.once("exit", (code, signal) => {
+ if (!settled) {
+ reject(
+ new Error(
+ `Accessibility fixture exited before readiness `
+ + `(code=${code}, signal=${signal}):\n${stderr}`,
+ ),
+ );
+ }
+ });
+ });
+ return { child, ready };
+}
+
+async function stopFixture(child) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return;
+ }
+ const exited = new Promise((resolve) => child.once("exit", resolve));
+ child.stdin.end();
+ await Promise.race([
+ exited,
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("Accessibility fixture did not stop")), 5_000);
+ }),
+ ]);
+}
+
+function violationReport(violations) {
+ return violations.map((violation) => {
+ const targets = violation.nodes
+ .flatMap((node) => node.target)
+ .join(", ");
+ return `${violation.id} (${violation.impact}): ${violation.help}\n ${targets}`;
+ }).join("\n");
+}
+
+async function expectNoAxeViolations(page, tags = AXE_TAGS) {
+ const results = await new AxeBuilder({ page }).withTags(tags).analyze();
+ expect(results.violations, violationReport(results.violations)).toEqual([]);
+}
+
+async function tabTo(page, selector, maximumTabs = 40) {
+ for (let count = 0; count < maximumTabs; count += 1) {
+ await page.keyboard.press("Tab");
+ if (await page.evaluate((target) => document.activeElement?.matches(target), selector)) {
+ return page.locator(selector).filter({ visible: true }).first();
+ }
+ }
+ throw new Error(`Keyboard focus did not reach ${selector}`);
+}
+
+test.beforeAll(async () => {
+ const fixture = startFixture();
+ fixtureProcess = fixture.child;
+ surfaces = await fixture.ready;
+});
+
+test.afterAll(async () => {
+ await stopFixture(fixtureProcess);
+});
+
+test("generated manual has no axe violations and its navigation works by keyboard", async ({
+ page,
+}) => {
+ await page.setContent(surfaces.manual_html, { waitUntil: "load" });
+ await expect(page.locator("main section")).not.toHaveCount(0);
+ // The generic renderer owns structure, while this frozen project template owns target sizing.
+ await expectNoAxeViolations(page, MANUAL_AXE_TAGS);
+
+ await page.keyboard.press("Tab");
+ const firstNavigationLink = page.locator("nav[aria-label='Documentation'] a").first();
+ await expect(firstNavigationLink).toBeFocused();
+ const target = await firstNavigationLink.getAttribute("href");
+ expect(target).toMatch(/^#[A-Za-z0-9_.-]+$/);
+ await page.keyboard.press("Enter");
+ await expect.poll(() => page.evaluate(() => window.location.hash)).toBe(target);
+});
+
+test("portable graph supports skip, filter, view, and dialog keyboard flows", async ({ page }) => {
+ await page.goto("about:blank");
+ await page.setContent(surfaces.portable_html, { waitUntil: "load" });
+ await expect(page.locator("#status")).toContainText("nodes and");
+ await expectNoAxeViolations(page);
+
+ await page.keyboard.press("Tab");
+ await expect(page.locator("a.skip")).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.locator("main#main")).toBeFocused();
+
+ await page.goto("about:blank");
+ await page.setContent(surfaces.portable_html, { waitUntil: "load" });
+ await page.keyboard.press("Tab");
+ await page.keyboard.press("Tab");
+ await expect(page.locator("#filter")).toBeFocused();
+ await page.keyboard.type("guide.workflow");
+ await expect(page.locator("#status")).toContainText("1 nodes and");
+
+ await page.keyboard.press("Tab");
+ await expect(page.locator("#mode")).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(page.locator("main#main")).toHaveAttribute("data-mode", "flow");
+ await page.keyboard.press("ArrowUp");
+ await expect(page.locator("main#main")).toHaveAttribute("data-mode", "nodes");
+
+ await page.keyboard.press("Tab");
+ const nodeButton = page.locator("#node-list button").first();
+ await expect(nodeButton).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
+ await expect(page.locator("#close-dialog")).toBeFocused();
+ await expectNoAxeViolations(page);
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
+ await expect(nodeButton).toBeFocused();
+});
+
+test("live viewer passes axe and exposes keyboard graph and resize controls", async ({ page }) => {
+ await page.goto(surfaces.live_url);
+ await expect(page.locator("#status")).toContainText("nodes ·");
+ await expect(page.locator("#graph g.node[role='button']").first()).toBeVisible();
+ await expectNoAxeViolations(page);
+
+ const resizer = await tabTo(page, "#left-resizer");
+ const originalWidth = Number(await resizer.getAttribute("aria-valuenow"));
+ await page.keyboard.press("ArrowRight");
+ await expect(resizer).toHaveAttribute("aria-valuenow", String(originalWidth + 16));
+
+ await tabTo(page, "#graph g.node[role='button']");
+ await page.keyboard.press("Shift+Enter");
+ await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
+ await expect(page.locator("#close-node-dialog")).toBeFocused();
+ await expectNoAxeViolations(page);
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
+});
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 92e3cda..bd5a039 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -17,6 +17,25 @@ from docforge.viewer_manager import ViewerManager
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
+GRAPH_CONFIG = """
+
+[graph_render]
+output_root = ".docforge/portable-graph"
+
+[[graph_render.views]]
+id = "architecture"
+renderer = "portable_graph_html"
+output = "architecture.html"
+title = "Alpha architecture"
+root = "guide.workflow"
+initial_mode = "nodes"
+depth = 2
+max_nodes = 20
+max_edges = 40
+max_work = 1000
+include_logic = false
+"""
+
class DocForgeCliTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
@@ -134,6 +153,30 @@ class DocForgeCliTests(unittest.TestCase):
else:
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
+ def test_portable_graph_plan_render_and_status_are_self_service(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.copy_fixture(Path(directory))
+ descriptor = root / ".docforge/project.toml"
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
+ encoding="utf-8",
+ )
+ parser = _parser()
+ planned = _run(
+ parser.parse_args(["--project-root", str(root), "graph-plan", "architecture"])
+ )
+ self.assertEqual("architecture", planned["view_id"])
+ rendered = _run(
+ parser.parse_args(["--project-root", str(root), "graph-render", "architecture"])
+ )
+ self.assertEqual("current", rendered["state"])
+ status = _run(
+ parser.parse_args(
+ ["--project-root", str(root), "graph-render-status", "architecture"]
+ )
+ )
+ self.assertEqual("current", status["state"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py
index 778b96a..539f089 100644
--- a/tests/test_client_integration.py
+++ b/tests/test_client_integration.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
+import hashlib
import io
import json
import os
@@ -18,6 +19,7 @@ from jsonschema import Draft202012Validator
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
+from docforge.changeset_contract import document_hash
from docforge.cli import _parser, _run, main
from docforge.client_config import (
_read_existing,
@@ -39,6 +41,19 @@ CONFIGURATION_SCHEMA = json.loads(
DOCTOR_SCHEMA = json.loads((SCHEMAS / "doctor-result.schema.json").read_text(encoding="utf-8"))
POLICY_SCHEMA = json.loads((SCHEMAS / "policy.schema.json").read_text(encoding="utf-8"))
+GRAPH_RENDER_CONFIG = """
+
+[graph_render]
+output_root = ".docforge/portable-graph"
+
+[[graph_render.views]]
+id = "architecture"
+renderer = "portable_graph_html"
+output = "architecture.html"
+title = "Alpha architecture"
+root = "guide.workflow"
+"""
+
class ClientIntegrationTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
@@ -156,6 +171,256 @@ class ClientIntegrationTests(unittest.TestCase):
)
self.assertEqual("invalid_capability_binding", escalated.exception.code)
+ def test_nondefault_projection_policy_selectors_serialize_and_validate(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.copy_fixture(Path(directory))
+ descriptor = root / ".docforge" / "project.toml"
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
+ encoding="utf-8",
+ )
+ project = Project.open(root)
+ expected_arguments = [
+ "-I",
+ "-m",
+ "docforge.mcp_server",
+ "--project-root",
+ str(root),
+ "--capability-mode",
+ "read",
+ "--manual-render-policy",
+ "disabled",
+ "--portable-graph-policy",
+ "disabled",
+ "--live-viewer-policy",
+ "disabled",
+ ]
+ for client in ("codex", "claude", "openclaw"):
+ with self.subTest(client=client):
+ result = generate_client_configuration(
+ project,
+ client,
+ manual_render_policy="disabled",
+ portable_graph_policy="disabled",
+ live_viewer_policy="disabled",
+ )
+ Draft202012Validator(CONFIGURATION_SCHEMA).validate(result)
+ _validate_configuration_result(result)
+ self.assertEqual(
+ {
+ "schema_version": 2,
+ "manual": "disabled",
+ "portable_graph": "disabled",
+ "live_viewer": "disabled",
+ },
+ result["projection_policy"],
+ )
+ self.assertEqual(expected_arguments, result["binding"]["args"])
+ content = result["artifact"]["content"]
+ if client == "codex":
+ document = tomllib.loads(content)
+ serialized = document["mcp_servers"][result["server_name"]]["args"]
+ elif client == "claude":
+ document = json.loads(content)
+ serialized = document["mcpServers"][result["server_name"]]["args"]
+ else:
+ document = json.loads(content)
+ serialized = document["mcp"]["servers"][result["server_name"]]["args"]
+ self.assertEqual(expected_arguments, serialized)
+ canonical = json.dumps(
+ result["projection_policy"],
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ self.assertEqual(
+ hashlib.sha256(canonical).hexdigest(),
+ result["projection_policy_hash"],
+ )
+
+ def test_projection_policy_schema_and_configuration_validation_reject_drift(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ project = Project.open(self.copy_fixture(Path(directory)))
+ result = generate_client_configuration(
+ project,
+ "codex",
+ manual_render_policy="disabled",
+ live_viewer_policy="disabled",
+ )
+ validator = Draft202012Validator(CONFIGURATION_SCHEMA)
+ validator.validate(result)
+ _validate_configuration_result(result)
+
+ for field, value in (
+ ("schema_version", 1),
+ ("manual", "on-demand"),
+ ("portable_graph", "auto"),
+ ("live_viewer", "explicit"),
+ ):
+ with self.subTest(field=field):
+ drifted = json.loads(json.dumps(result))
+ drifted["projection_policy"][field] = value
+ self.assertTrue(list(validator.iter_errors(drifted)))
+
+ missing = json.loads(json.dumps(result))
+ missing.pop("projection_policy")
+ self.assertTrue(list(validator.iter_errors(missing)))
+ extra = json.loads(json.dumps(result))
+ extra["projection_policy"]["project_path"] = "/private/project"
+ self.assertTrue(list(validator.iter_errors(extra)))
+
+ mismatched = json.loads(json.dumps(result))
+ mismatched["projection_policy"]["manual"] = "explicit"
+ canonical = json.dumps(
+ mismatched["projection_policy"],
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ mismatched["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
+ with self.assertRaises(AssertionError):
+ _validate_configuration_result(mismatched)
+
+ bad_hash = json.loads(json.dumps(result))
+ bad_hash["projection_policy_hash"] = "0" * 64
+ with self.assertRaises(AssertionError):
+ _validate_configuration_result(bad_hash)
+
+ defaulted = generate_client_configuration(project, "codex")
+ self.assertNotIn(
+ "--manual-render-policy",
+ defaulted["binding"]["args"],
+ )
+ defaulted["projection_policy"]["manual"] = "disabled"
+ canonical = json.dumps(
+ defaulted["projection_policy"],
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ defaulted["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
+ with self.assertRaises(AssertionError):
+ _validate_configuration_result(defaulted)
+
+ unavailable = json.loads(json.dumps(result))
+ unavailable["projection_availability"]["manual_configured"] = False
+ with self.assertRaises(AssertionError):
+ _validate_configuration_result(unavailable)
+
+ descriptor = project.descriptor.descriptor_path
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
+ encoding="utf-8",
+ )
+ graph_project = Project.open(project.descriptor.root)
+ graph_defaulted = generate_client_configuration(graph_project, "codex")
+ self.assertNotIn(
+ "--portable-graph-policy",
+ graph_defaulted["binding"]["args"],
+ )
+ coordinated_graph_drift = json.loads(json.dumps(graph_defaulted))
+ coordinated_graph_drift["projection_policy"]["portable_graph"] = "disabled"
+ coordinated_graph_drift["projection_availability"]["portable_graph_configured"] = False
+ canonical = json.dumps(
+ coordinated_graph_drift["projection_policy"],
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ coordinated_graph_drift["projection_policy_hash"] = hashlib.sha256(
+ canonical
+ ).hexdigest()
+ coordinated_graph_drift["configuration_hash"] = document_hash(
+ {
+ "schema_version": 1,
+ "client": coordinated_graph_drift["client"],
+ "server_name": coordinated_graph_drift["server_name"],
+ "project": coordinated_graph_drift["project"],
+ "binding": coordinated_graph_drift["binding"],
+ "effective_policy": coordinated_graph_drift["effective_policy"],
+ "projection_policy": coordinated_graph_drift["projection_policy"],
+ "projection_policy_hash": coordinated_graph_drift["projection_policy_hash"],
+ "projection_availability": coordinated_graph_drift["projection_availability"],
+ "artifact_format": coordinated_graph_drift["artifact"]["format"],
+ "artifact_content_sha256": coordinated_graph_drift["artifact"][
+ "content_sha256"
+ ],
+ }
+ )
+ with self.assertRaises(AssertionError):
+ _validate_configuration_result(coordinated_graph_drift)
+
+ invalid_selections = (
+ {"manual_render_policy": "sometimes"},
+ {"portable_graph_policy": "auto"},
+ {"live_viewer_policy": "always"},
+ )
+ for selection in invalid_selections:
+ with self.subTest(selection=selection):
+ with self.assertRaises(DocForgeError) as raised:
+ generate_client_configuration(graph_project, "codex", **selection)
+ self.assertEqual("invalid_projection_policy", raised.exception.code)
+
+ def test_doctor_round_trips_projection_selectors_and_rejects_invalid_modes(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ parent = Path(directory)
+ project = Project.open(self.copy_fixture(parent))
+ ProjectIndex(project).build()
+ config = parent / "openclaw.json"
+ generated = generate_client_configuration(
+ project,
+ "openclaw",
+ manual_render_policy="disabled",
+ live_viewer_policy="disabled",
+ output=config,
+ )
+ healthy = run_doctor(
+ project,
+ "openclaw",
+ config_path=config,
+ server_name=generated["server_name"],
+ )
+ Draft202012Validator(DOCTOR_SCHEMA).validate(healthy)
+ self.assertEqual("healthy", healthy["doctor_state"])
+ self.assertIn(
+ "effective_policy_valid",
+ [check["code"] for check in healthy["checks"]],
+ )
+
+ document = json.loads(config.read_text(encoding="utf-8"))
+ entry = document["mcp"]["servers"][generated["server_name"]]
+ arguments = entry["args"]
+ manual_position = arguments.index("--manual-render-policy") + 1
+ arguments[manual_position] = "sometimes"
+ config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
+ invalid = run_doctor(
+ project,
+ "openclaw",
+ config_path=config,
+ server_name=generated["server_name"],
+ )
+ Draft202012Validator(DOCTOR_SCHEMA).validate(invalid)
+ self.assertEqual("unhealthy", invalid["doctor_state"])
+ policy_check = next(
+ check for check in invalid["checks"] if check["check_id"] == "policy.effective"
+ )
+ self.assertEqual("invalid_projection_policy", policy_check["code"])
+
+ arguments[manual_position] = "auto"
+ config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
+ unavailable = run_doctor(
+ project,
+ "openclaw",
+ config_path=config,
+ server_name=generated["server_name"],
+ )
+ Draft202012Validator(DOCTOR_SCHEMA).validate(unavailable)
+ self.assertEqual("unhealthy", unavailable["doctor_state"])
+ policy_check = next(
+ check for check in unavailable["checks"] if check["check_id"] == "policy.effective"
+ )
+ self.assertEqual("projection_policy_unavailable", policy_check["code"])
+
def test_explicit_fragment_write_is_atomic_conflict_aware_and_private(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
diff --git a/tests/test_graph_projection.py b/tests/test_graph_projection.py
new file mode 100644
index 0000000..d0b516a
--- /dev/null
+++ b/tests/test_graph_projection.py
@@ -0,0 +1,404 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from dataclasses import replace
+from pathlib import Path
+from typing import Any, cast
+
+from docforge.errors import DocForgeError
+from docforge.graph_projection import (
+ GraphViewRequestV1,
+ build_graph_view_plan,
+)
+from docforge.models import Edge, Limits, Node, ProjectDescriptor, ProjectSnapshot
+from docforge.projection_contract import GraphViewPlanV1
+
+
+def _document(plan: GraphViewPlanV1) -> dict[str, Any]:
+ return cast(dict[str, Any], plan.as_dict())
+
+
+def _node(
+ node_id: str,
+ *,
+ title: str | None = None,
+ family: str = "code",
+ authority: str = "derived",
+ status: str = "active",
+ tags: tuple[str, ...] = (),
+) -> Node:
+ return Node(
+ node_id=node_id,
+ title=title or node_id,
+ family=family,
+ authority=authority,
+ status=status,
+ tags=tags,
+ summary=f"Summary for {node_id}",
+ content=f"SECRET SOURCE BODY {node_id}",
+ source_path=f"/private/source/{node_id}.py",
+ source_anchor=f"line-{len(node_id)}",
+ content_hash=(node_id.encode("utf-8").hex() + "0" * 64)[:64],
+ )
+
+
+def _snapshot(
+ root: Path,
+ nodes: tuple[Node, ...],
+ edges: tuple[Edge, ...],
+) -> ProjectSnapshot:
+ descriptor = ProjectDescriptor(
+ schema_version=1,
+ project_id="graph-project",
+ title="Graph project",
+ adapter="generic",
+ root=root,
+ descriptor_path=root / ".docforge" / "project.toml",
+ descriptor_hash="d" * 64,
+ content_roots=(root / "docs",),
+ authority_files=(),
+ cache_root=root / ".docforge" / "cache",
+ index_path=root / ".docforge" / "cache" / "index.sqlite3",
+ changeset_root=root / ".docforge" / "changesets",
+ proposal_writers=(),
+ render=None,
+ allowed_relations=tuple(sorted({edge.relation for edge in edges})),
+ profiles=(),
+ limits=Limits(),
+ )
+ return ProjectSnapshot(
+ descriptor=descriptor,
+ nodes=nodes,
+ edges=edges,
+ revision="revision-1",
+ source_hash="a" * 64,
+ )
+
+
+class GraphProjectionTests(unittest.TestCase):
+ def test_exact_root_plan_is_deterministic_sorted_and_path_free(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ nodes = (
+ _node("c", family="docs", tags=("python",)),
+ _node("a", tags=("python", "callable")),
+ _node("b", tags=("python",)),
+ _node("unrelated"),
+ )
+ edges = (
+ Edge("b", "calls", "c"),
+ Edge("a", "calls", "b"),
+ Edge("unrelated", "calls", "c"),
+ )
+ request = GraphViewRequestV1(
+ view_id="architecture",
+ title="Architecture",
+ root_node_id="a",
+ depth=2,
+ max_nodes=10,
+ max_edges=10,
+ max_work=100,
+ )
+ first = build_graph_view_plan(_snapshot(root, nodes, edges), request, True)
+ second = build_graph_view_plan(
+ _snapshot(root, tuple(reversed(nodes)), tuple(reversed(edges))),
+ request,
+ True,
+ )
+ self.assertEqual(first.as_dict(), second.as_dict())
+ GraphViewPlanV1.from_dict(first.as_dict())
+ document = _document(first)
+ self.assertEqual(
+ ["a", "b", "c"], [node["node_id"] for node in document["graph"]["nodes"]]
+ )
+ self.assertEqual(
+ [
+ {"source_id": "a", "relation": "calls", "target_id": "b"},
+ {"source_id": "b", "relation": "calls", "target_id": "c"},
+ ],
+ document["graph"]["edges"],
+ )
+ encoded = json.dumps(document, sort_keys=True)
+ self.assertNotIn(str(root), encoded)
+ self.assertNotIn("SECRET SOURCE BODY", encoded)
+ self.assertNotIn("/private/source/", encoded)
+ self.assertNotIn("line-1", encoded)
+ self.assertEqual("excluded", document["policy"]["source_paths"])
+ self.assertEqual("excluded", document["policy"]["source_bodies"])
+ self.assertEqual("allowed", document["policy"]["logic"])
+ self.assertEqual("exact_root", document["view"]["scope"]["kind"])
+
+ def test_lexical_scope_uses_metadata_only_and_closed_filters(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ nodes = (
+ _node(
+ "api.handler",
+ title="Request handler",
+ family="code",
+ authority="derived",
+ tags=("python", "route"),
+ ),
+ _node(
+ "api.test",
+ title="Handler proof",
+ family="test",
+ authority="approved_plan",
+ tags=("python", "test"),
+ ),
+ replace(
+ _node("hidden.body", family="code", tags=("python",)),
+ content="request handler appears only in the forbidden source body",
+ ),
+ )
+ edges = (
+ Edge("api.handler", "tested_by", "api.test"),
+ Edge("hidden.body", "relates_to", "api.handler"),
+ )
+ request = GraphViewRequestV1(
+ view_id="routes",
+ title="Routes",
+ query="request handler",
+ families=("code",),
+ authorities=("derived",),
+ tags=("python", "route"),
+ relations=("tested_by",),
+ max_nodes=10,
+ max_edges=10,
+ max_work=100,
+ )
+ plan = build_graph_view_plan(_snapshot(root, nodes, edges), request, False)
+ document = _document(plan)
+ self.assertEqual(
+ ["api.handler"],
+ [node["node_id"] for node in document["graph"]["nodes"]],
+ )
+ self.assertEqual([], document["graph"]["edges"])
+ self.assertEqual(
+ {
+ "families": ["code"],
+ "relations": ["tested_by"],
+ "authorities": ["derived"],
+ "statuses": [],
+ "tags": ["python", "route"],
+ },
+ document["view"]["filters"],
+ )
+ self.assertEqual("lexical", document["view"]["scope"]["kind"])
+
+ def test_result_and_work_limits_emit_explicit_omissions(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ nodes = tuple(_node(value) for value in ("a", "b", "c", "d"))
+ edges = (
+ Edge("a", "calls", "b"),
+ Edge("a", "calls", "c"),
+ Edge("a", "calls", "d"),
+ Edge("b", "calls", "c"),
+ )
+ result_limited = build_graph_view_plan(
+ _snapshot(root, nodes, edges),
+ GraphViewRequestV1(
+ view_id="limited",
+ title="Limited",
+ root_node_id="a",
+ max_nodes=2,
+ max_edges=0,
+ max_work=100,
+ ),
+ False,
+ )
+ result_limited = _document(result_limited)
+ self.assertEqual(
+ ["a", "b"], [node["node_id"] for node in result_limited["graph"]["nodes"]]
+ )
+ self.assertEqual([], result_limited["graph"]["edges"])
+ self.assertEqual(
+ ["edge_result_limit", "node_result_limit"],
+ [item["code"] for item in result_limited["omissions"]],
+ )
+
+ work_limited = build_graph_view_plan(
+ _snapshot(root, nodes, edges),
+ GraphViewRequestV1(
+ view_id="work",
+ title="Work",
+ root_node_id="a",
+ max_nodes=10,
+ max_edges=10,
+ max_work=1,
+ ),
+ False,
+ )
+ work_limited = _document(work_limited)
+ self.assertIn(
+ "work_limit",
+ [item["code"] for item in work_limited["omissions"]],
+ )
+ self.assertEqual(
+ 1,
+ work_limited["diagnostics"]["examined_work_units"],
+ )
+ self.assertTrue(work_limited["diagnostics"]["truncated"])
+
+ def test_no_ast_policy_excludes_requested_logic(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ snapshot = _snapshot(root, (_node("a"),), ())
+ request = GraphViewRequestV1(
+ view_id="logic",
+ title="Logic",
+ root_node_id="a",
+ initial_mode="logic",
+ include_logic=True,
+ )
+ blocked = _document(build_graph_view_plan(snapshot, request, False))
+ self.assertEqual("forbidden", blocked["policy"]["logic"])
+ self.assertEqual([], blocked["graph"]["logic_projections"])
+ self.assertIn(
+ "logic_forbidden",
+ [item["code"] for item in blocked["omissions"]],
+ )
+ allowed = _document(build_graph_view_plan(snapshot, request, True))
+ self.assertEqual("allowed", allowed["policy"]["logic"])
+ self.assertNotIn(
+ "logic_forbidden",
+ [item["code"] for item in allowed["omissions"]],
+ )
+
+ def test_edge_and_node_filters_constrain_exact_root_bfs(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ nodes = (
+ _node("root", family="code", status="active"),
+ _node("code-child", family="code", status="active"),
+ _node("doc-child", family="docs", status="active"),
+ _node("old-child", family="code", status="historical"),
+ )
+ edges = (
+ Edge("root", "calls", "code-child"),
+ Edge("root", "documents", "doc-child"),
+ Edge("root", "calls", "old-child"),
+ )
+ plan = build_graph_view_plan(
+ _snapshot(root, nodes, edges),
+ GraphViewRequestV1(
+ view_id="filtered",
+ title="Filtered",
+ root_node_id="root",
+ families=("code",),
+ statuses=("active",),
+ relations=("calls",),
+ max_work=100,
+ ),
+ False,
+ )
+ plan = _document(plan)
+ self.assertEqual(
+ ["code-child", "root"], [node["node_id"] for node in plan["graph"]["nodes"]]
+ )
+ self.assertEqual(
+ [{"source_id": "root", "relation": "calls", "target_id": "code-child"}],
+ plan["graph"]["edges"],
+ )
+
+ def test_invalid_requests_and_graphs_fail_closed(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ snapshot = _snapshot(root, (_node("a"),), ())
+ invalid_requests = (
+ GraphViewRequestV1(view_id="v", title="V"),
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="a",
+ query="a",
+ ),
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="a",
+ max_nodes=0,
+ ),
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ query="***",
+ ),
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="a",
+ families=("code", "code"),
+ ),
+ )
+ for request in invalid_requests:
+ with self.subTest(request=request), self.assertRaises(DocForgeError) as error:
+ build_graph_view_plan(snapshot, request, False)
+ self.assertEqual("invalid_graph_view_request", error.exception.code)
+
+ with self.assertRaises(DocForgeError) as missing:
+ build_graph_view_plan(
+ snapshot,
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="missing",
+ ),
+ False,
+ )
+ self.assertEqual("missing_node", missing.exception.code)
+
+ duplicate = _snapshot(root, (_node("a"), _node("a")), ())
+ with self.assertRaises(DocForgeError) as invalid:
+ build_graph_view_plan(
+ duplicate,
+ GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="a",
+ ),
+ False,
+ )
+ self.assertEqual("invalid_projection", invalid.exception.code)
+
+ def test_plan_identity_changes_with_generation_request_and_policy(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ snapshot = _snapshot(root, (_node("a"),), ())
+ request = GraphViewRequestV1(
+ view_id="v",
+ title="V",
+ root_node_id="a",
+ )
+ base = build_graph_view_plan(snapshot, request, False)
+ self.assertEqual(
+ base.plan_id,
+ build_graph_view_plan(snapshot, request, False).plan_id,
+ )
+ self.assertNotEqual(
+ base.plan_id,
+ build_graph_view_plan(
+ replace(snapshot, source_hash="b" * 64),
+ request,
+ False,
+ ).plan_id,
+ )
+ self.assertNotEqual(
+ base.plan_id,
+ build_graph_view_plan(
+ snapshot,
+ replace(request, title="Other"),
+ False,
+ ).plan_id,
+ )
+ self.assertNotEqual(
+ base.plan_id,
+ build_graph_view_plan(snapshot, request, True).plan_id,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_graph_publication.py b/tests/test_graph_publication.py
new file mode 100644
index 0000000..1d6d110
--- /dev/null
+++ b/tests/test_graph_publication.py
@@ -0,0 +1,344 @@
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+import tomllib
+import unittest
+from pathlib import Path
+from unittest import mock
+
+from jsonschema import Draft202012Validator
+
+from docforge.errors import DocForgeError
+from docforge.graph_rendering import GraphRenderService
+from docforge.models import ProjectState
+from docforge.project import Project
+from docforge.projection_contract import projection_hash
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURES = ROOT / "tests" / "fixtures"
+PROJECT_SCHEMA = json.loads((ROOT / "schemas/project.schema.json").read_text(encoding="utf-8"))
+
+GRAPH_CONFIG = """
+
+[graph_render]
+output_root = ".docforge/portable-graph"
+
+[[graph_render.views]]
+id = "architecture"
+renderer = "portable_graph_html"
+output = "architecture.html"
+title = "Alpha architecture"
+root = "guide.workflow"
+initial_mode = "nodes"
+depth = 2
+max_nodes = 20
+max_edges = 40
+max_work = 1000
+families = ["guide", "proof"]
+relations = ["depends_on", "proves"]
+authorities = []
+statuses = []
+tags = []
+include_logic = false
+"""
+
+
+class GraphPublicationTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name) / "alpha"
+ shutil.copytree(FIXTURES / "alpha", self.root)
+ descriptor = self.root / ".docforge/project.toml"
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
+ encoding="utf-8",
+ )
+ self.project = Project.open(self.root)
+ self.service = GraphRenderService(self.project)
+
+ def test_descriptor_schema_and_runtime_accept_the_separate_graph_view(self) -> None:
+ document = tomllib.loads((self.root / ".docforge/project.toml").read_text(encoding="utf-8"))
+ Draft202012Validator(PROJECT_SCHEMA).validate(document)
+ config = self.project.descriptor.graph_render
+ assert config is not None
+ self.assertEqual(self.root / ".docforge/portable-graph", config.output_root)
+ self.assertEqual("architecture", config.views[0].view_id)
+ self.assertEqual("guide.workflow", config.views[0].root_node_id)
+ self.assertIsNone(config.views[0].query)
+
+ def test_render_publication_is_deterministic_durable_and_unchanged_on_reuse(self) -> None:
+ missing = self.service.status("architecture")
+ self.assertEqual("stale", missing["state"])
+ self.assertEqual("missing", missing["outputs"][0]["state"])
+
+ first = self.service.render("architecture")
+ output = self.root / ".docforge/portable-graph/architecture.html"
+ manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json"
+ artifact_root = self.root / ".docforge/cache/projection-artifacts"
+ self.assertEqual("current", first["state"])
+ self.assertEqual("published", first["publication"])
+ self.assertTrue(output.is_file())
+ self.assertTrue(manifest.is_file())
+ self.assertEqual(1, len(tuple(artifact_root.glob("*.html"))))
+ before = output.stat()
+ before_bytes = output.read_bytes()
+
+ current = self.service.status("architecture")
+ self.assertEqual("current", current["state"])
+ self.assertEqual("manifest", current["outputs"][0]["verification"])
+ second = self.service.render("architecture")
+ after = output.stat()
+ self.assertEqual("unchanged", second["publication"])
+ self.assertEqual(before_bytes, output.read_bytes())
+ self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino))
+
+ def test_status_is_manifest_only_and_detects_source_output_and_manifest_changes(self) -> None:
+ self.service.render("architecture")
+ output = self.root / ".docforge/portable-graph/architecture.html"
+ manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json"
+ with mock.patch.object(
+ self.project,
+ "load",
+ side_effect=AssertionError("status must not load or plan"),
+ ):
+ self.assertEqual("current", self.service.status("architecture")["state"])
+
+ output.write_bytes(output.read_bytes() + b"\n")
+ changed_output = self.service.status("architecture")
+ self.assertEqual("stale", changed_output["state"])
+ self.assertEqual("output_changed", changed_output["outputs"][0]["reason"])
+
+ self.service.render("architecture")
+ source = self.root / "docs/content/workflow.md"
+ source.write_text(
+ source.read_text(encoding="utf-8") + "\nChanged after publication.\n",
+ encoding="utf-8",
+ )
+ changed_source = self.service.status("architecture")
+ self.assertEqual("stale", changed_source["state"])
+ self.assertEqual("stale", changed_source["outputs"][0]["state"])
+ self.assertEqual(
+ "source_generation_changed",
+ changed_source["outputs"][0]["reason"],
+ )
+
+ self.service.render("architecture")
+ manifest.write_text("{bad-json", encoding="utf-8")
+ corrupt = self.service.status("architecture")
+ self.assertEqual("missing", corrupt["outputs"][0]["state"])
+
+ def test_status_validates_nested_manifest_and_artifact_store_evidence(self) -> None:
+ self.service.render("architecture")
+ manifest_path = (
+ self.root / ".docforge/cache/projection-publications/graph/architecture.json"
+ )
+ original = json.loads(manifest_path.read_text(encoding="utf-8"))
+ mutations = {
+ "bad_project": lambda value: value.__setitem__("project", "bad"),
+ "forged_artifact": lambda value: value.__setitem__(
+ "artifact",
+ {
+ "artifact_id": "portable-graph.html",
+ "media_type": "text/html; charset=utf-8",
+ "sha256": "0" * 64,
+ "bytes": 1,
+ },
+ ),
+ "bad_store": lambda value: value["store"].__setitem__("size", -1),
+ }
+ for name, mutate in mutations.items():
+ with self.subTest(name=name):
+ value = json.loads(json.dumps(original))
+ mutate(value)
+ value.pop("publication_id")
+ value["publication_id"] = projection_hash(value)
+ manifest_path.write_text(
+ json.dumps(value, sort_keys=True, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ status = self.service.status("architecture")
+ self.assertEqual("unverified", status["outputs"][0]["state"])
+ self.assertEqual("manifest_invalid", status["outputs"][0]["reason"])
+ manifest_path.write_text(
+ json.dumps(original, sort_keys=True, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ artifact = next((self.root / ".docforge/cache/projection-artifacts").glob("*.html"))
+ artifact.unlink()
+ missing = self.service.status("architecture")
+ self.assertEqual("stale", missing["outputs"][0]["state"])
+ self.assertEqual("artifact_store_missing", missing["outputs"][0]["reason"])
+ repaired = self.service.render("architecture")
+ self.assertEqual("current", repaired["state"])
+ self.assertTrue(artifact.is_file())
+
+ def test_status_detects_source_and_publication_races(self) -> None:
+ self.service.render("architecture")
+ current = self.project.incremental_state()
+ assert current is not None
+ changed = ProjectState(source_hash="0" * 64, revision="changed")
+ with mock.patch.object(
+ self.project,
+ "incremental_state",
+ side_effect=(current, changed),
+ ):
+ raced_source = self.service.status("architecture")
+ self.assertEqual("stale", raced_source["state"])
+ self.assertEqual(
+ "source_changed_during_status",
+ raced_source["outputs"][0]["reason"],
+ )
+
+ baseline = self.service._manifest_status(
+ self.project.descriptor.graph_render.views[0], # type: ignore[union-attr]
+ current,
+ )
+ replaced = dict(baseline)
+ replaced["publication_id"] = "f" * 64
+ with mock.patch.object(
+ self.service,
+ "_manifest_status",
+ side_effect=(baseline, replaced),
+ ):
+ raced_publication = self.service.status("architecture")
+ self.assertEqual("stale", raced_publication["state"])
+ self.assertEqual(
+ "publication_changed_during_status",
+ raced_publication["outputs"][0]["reason"],
+ )
+
+ def test_manifest_failure_after_output_is_degraded_success(self) -> None:
+ with mock.patch.object(
+ self.service,
+ "_publish_manifest",
+ side_effect=DocForgeError(
+ "publication_failure",
+ "Synthetic manifest failure",
+ ),
+ ):
+ result = self.service.render("architecture")
+ self.assertEqual("degraded", result["state"])
+ self.assertEqual("published", result["publication"])
+ self.assertEqual("manifest", result["committed_stage"])
+ self.assertTrue((self.root / ".docforge/portable-graph/architecture.html").is_file())
+ self.assertEqual("failed", result["manifest"]["state"])
+
+ def test_post_commit_stage_failures_are_degraded_and_precommit_failures_raise(self) -> None:
+ committed = DocForgeError(
+ "publication_failure",
+ "Synthetic committed failure",
+ mutation_committed=True,
+ )
+ cases = (
+ ("_publish_artifact", "artifact_store", "partial", False),
+ ("_publish_output", "output", "published", None),
+ )
+ for method, stage, publication, output_exists in cases:
+ with self.subTest(method=method):
+ root = Path(self.temporary.name) / method
+ shutil.copytree(FIXTURES / "alpha", root)
+ descriptor = root / ".docforge/project.toml"
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
+ encoding="utf-8",
+ )
+ service = GraphRenderService(Project.open(root))
+ with mock.patch.object(service, method, side_effect=committed):
+ result = service.render("architecture")
+ self.assertEqual("degraded", result["state"])
+ self.assertEqual(stage, result["committed_stage"])
+ self.assertEqual(publication, result["publication"])
+ if output_exists is not None:
+ self.assertEqual(
+ output_exists,
+ (root / ".docforge/portable-graph/architecture.html").exists(),
+ )
+
+ uncommitted = DocForgeError(
+ "publication_failure",
+ "Synthetic precommit failure",
+ mutation_committed=False,
+ )
+ with (
+ mock.patch.object(
+ self.service,
+ "_publish_output",
+ side_effect=uncommitted,
+ ),
+ self.assertRaises(DocForgeError),
+ ):
+ self.service.render("architecture")
+
+ def test_configuration_rejects_unsafe_ambiguous_and_overlapping_views(self) -> None:
+ cases = {
+ "both_scope": GRAPH_CONFIG.replace(
+ 'root = "guide.workflow"',
+ 'root = "guide.workflow"\nquery = "workflow"',
+ ),
+ "output_overlap": GRAPH_CONFIG.replace(
+ 'output_root = ".docforge/portable-graph"',
+ 'output_root = "docs/content"',
+ ),
+ "active_renderer": GRAPH_CONFIG.replace(
+ 'renderer = "portable_graph_html"',
+ 'renderer = "shell"',
+ ),
+ "oversized": GRAPH_CONFIG.replace("max_nodes = 20", "max_nodes = 100000"),
+ "logic_mode": GRAPH_CONFIG.replace('initial_mode = "nodes"', 'initial_mode = "logic"'),
+ "logic_projection": GRAPH_CONFIG.replace(
+ "include_logic = false",
+ "include_logic = true",
+ ),
+ "long_title": GRAPH_CONFIG.replace(
+ 'title = "Alpha architecture"',
+ f'title = "{"x" * 1025}"',
+ ),
+ "long_query": GRAPH_CONFIG.replace(
+ 'root = "guide.workflow"',
+ f'query = "{"x" * 10001}"',
+ ),
+ "too_many_filters": GRAPH_CONFIG.replace(
+ 'families = ["guide", "proof"]',
+ "families = [" + ", ".join(f'"family-{index}"' for index in range(65)) + "]",
+ ),
+ }
+ for name, graph_config in cases.items():
+ with self.subTest(name=name):
+ root = Path(self.temporary.name) / name
+ shutil.copytree(FIXTURES / "alpha", root)
+ descriptor = root / ".docforge/project.toml"
+ descriptor.write_text(
+ descriptor.read_text(encoding="utf-8") + graph_config,
+ encoding="utf-8",
+ )
+ parsed = tomllib.loads(descriptor.read_text(encoding="utf-8"))
+ if name != "output_overlap":
+ self.assertFalse(Draft202012Validator(PROJECT_SCHEMA).is_valid(parsed))
+ with self.assertRaises(DocForgeError):
+ Project.open(root)
+
+ relocated = Path(self.temporary.name) / "descriptor-overlap"
+ shutil.copytree(FIXTURES / "alpha", relocated)
+ descriptor = relocated / ".docforge/project.toml"
+ base = descriptor.read_text(encoding="utf-8").replace(
+ 'cache_root = ".docforge/cache"\nindex = ".docforge/cache/index.sqlite3"',
+ 'cache_root = "var/cache"\nindex = "var/cache/index.sqlite3"',
+ )
+ descriptor.write_text(
+ base
+ + GRAPH_CONFIG.replace(
+ 'output_root = ".docforge/portable-graph"',
+ 'output_root = ".docforge"',
+ ),
+ encoding="utf-8",
+ )
+ with self.assertRaises(DocForgeError):
+ Project.open(relocated)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_graph_rendering.py b/tests/test_graph_rendering.py
new file mode 100644
index 0000000..bdb4831
--- /dev/null
+++ b/tests/test_graph_rendering.py
@@ -0,0 +1,284 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import os
+import shutil
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+from typing import cast
+from unittest import mock
+
+from docforge.errors import DocForgeError
+from docforge.graph_projection import (
+ GraphViewRequestV1,
+ build_graph_projection_package,
+ build_graph_view_plan,
+)
+from docforge.project import Project
+from docforge.projection_contract import ProjectionPackageV1
+from docforge_renderers.graph import PortableGraphHtmlRenderer
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURES = ROOT / "tests" / "fixtures"
+
+
+class PortableGraphRenderingTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name) / "alpha"
+ shutil.copytree(FIXTURES / "alpha", self.root)
+ self.project = Project.open(self.root)
+ self.snapshot = self.project.load()
+ self.request = GraphViewRequestV1(
+ view_id="architecture",
+ title="Alpha architecture",
+ root_node_id="guide.workflow",
+ depth=2,
+ max_nodes=20,
+ max_edges=40,
+ max_work=1_000,
+ )
+ self.plan = build_graph_view_plan(self.snapshot, self.request, False)
+ self.package = build_graph_projection_package(
+ self.plan,
+ renderer_id=PortableGraphHtmlRenderer.renderer_id,
+ renderer_version=PortableGraphHtmlRenderer.renderer_version,
+ max_output_bytes=1_000_000,
+ )
+
+ def test_portable_artifact_is_deterministic_self_contained_and_generation_bound(self) -> None:
+ renderer = PortableGraphHtmlRenderer()
+ first = renderer.render(self.package)
+ second = renderer.render(self.package)
+ self.assertEqual(first.artifacts, second.artifacts)
+ self.assertEqual(1, len(first.artifacts))
+ artifact = first.artifacts[0]
+ self.assertEqual("portable-graph.html", artifact.artifact_id)
+ self.assertEqual("text/html; charset=utf-8", artifact.media_type)
+ receipt = first.receipt.as_dict()
+ evidence = cast(list[dict[str, object]], receipt["artifacts"])
+ self.assertEqual(
+ hashlib.sha256(artifact.content).hexdigest(),
+ evidence[0]["sha256"],
+ )
+ rendered = artifact.content.decode("utf-8")
+ self.assertIn("Content-Security-Policy", rendered)
+ self.assertIn("default-src 'none'", rendered)
+ self.assertIn('type="application/json"', rendered)
+ self.assertNotIn(str(self.root), rendered)
+ self.assertNotIn("docs/content/", rendered)
+ self.assertNotIn("Editors change canonical nodes", rendered)
+ self.assertNotIn("fetch(", rendered)
+ self.assertNotIn("XMLHttpRequest", rendered)
+ self.assertNotIn("WebSocket", rendered)
+ self.assertIn(self.snapshot.source_hash, rendered)
+
+ def test_embedded_plan_is_exact_and_script_breakout_is_inert(self) -> None:
+ plan = copy.deepcopy(self.plan.as_dict())
+ view = plan["view"]
+ assert isinstance(view, dict)
+ view["title"] = ''
+ plan.pop("plan_id")
+ from docforge.projection_contract import GraphViewPlanV1
+
+ malicious = GraphViewPlanV1.create(plan)
+ package = build_graph_projection_package(
+ malicious,
+ renderer_id=PortableGraphHtmlRenderer.renderer_id,
+ renderer_version=PortableGraphHtmlRenderer.renderer_version,
+ max_output_bytes=1_000_000,
+ )
+ rendered = PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
+ self.assertNotIn('", 1)[0]
+ self.assertEqual(malicious.as_dict(), json.loads(embedded))
+
+ def test_renderer_has_no_project_database_or_filesystem_write_capability(self) -> None:
+ forbidden = AssertionError("portable graph renderer crossed its capability boundary")
+ with (
+ mock.patch.object(Project, "open", side_effect=forbidden),
+ mock.patch.object(Project, "load", side_effect=forbidden),
+ mock.patch.object(sqlite3, "connect", side_effect=forbidden),
+ mock.patch.object(Path, "write_bytes", side_effect=forbidden),
+ mock.patch.object(Path, "write_text", side_effect=forbidden),
+ mock.patch.object(Path, "mkdir", side_effect=forbidden),
+ mock.patch.object(os, "replace", side_effect=forbidden),
+ mock.patch.object(os, "rename", side_effect=forbidden),
+ mock.patch.object(os, "unlink", side_effect=forbidden),
+ ):
+ result = PortableGraphHtmlRenderer().render(self.package)
+ self.assertEqual(1, len(result.artifacts))
+
+ def test_renderer_rejects_wrong_kind_identity_assets_and_size(self) -> None:
+ wrong = ProjectionPackageV1.create(
+ kind="graph",
+ plan=self.plan,
+ renderer={
+ "renderer_id": PortableGraphHtmlRenderer.renderer_id,
+ "renderer_version": "other",
+ },
+ components=[],
+ assets=[],
+ output_policy={
+ "artifact_ids": ["portable-graph.html"],
+ "max_total_bytes": 1_000_000,
+ },
+ )
+ with self.assertRaises(DocForgeError) as unsupported:
+ PortableGraphHtmlRenderer().render(wrong)
+ self.assertEqual("unsupported_renderer", unsupported.exception.code)
+
+ with_asset = ProjectionPackageV1.create(
+ kind="graph",
+ plan=self.plan,
+ renderer={
+ "renderer_id": PortableGraphHtmlRenderer.renderer_id,
+ "renderer_version": PortableGraphHtmlRenderer.renderer_version,
+ },
+ components=[],
+ assets=[
+ {
+ "asset_id": "project-script",
+ "media_type": "text/javascript",
+ "sha256": "0" * 64,
+ "text": "alert(1)",
+ }
+ ],
+ output_policy={"artifact_ids": ["portable-graph.html"], "max_total_bytes": 1_000_000},
+ )
+ with self.assertRaises(DocForgeError) as assets:
+ PortableGraphHtmlRenderer().render(with_asset)
+ self.assertEqual("invalid_projection", assets.exception.code)
+
+ wrong_components = ProjectionPackageV1.create(
+ kind="graph",
+ plan=self.plan,
+ renderer={
+ "renderer_id": PortableGraphHtmlRenderer.renderer_id,
+ "renderer_version": PortableGraphHtmlRenderer.renderer_version,
+ },
+ components=[],
+ assets=[],
+ output_policy={
+ "artifact_ids": ["portable-graph.html"],
+ "max_total_bytes": 1_000_000,
+ },
+ )
+ with self.assertRaises(DocForgeError) as components:
+ PortableGraphHtmlRenderer().render(wrong_components)
+ self.assertEqual("invalid_projection", components.exception.code)
+
+ wrong_artifact = ProjectionPackageV1.create(
+ kind="graph",
+ plan=self.plan,
+ renderer={
+ "renderer_id": PortableGraphHtmlRenderer.renderer_id,
+ "renderer_version": PortableGraphHtmlRenderer.renderer_version,
+ },
+ components=self.package.document["components"], # type: ignore[arg-type]
+ assets=[],
+ output_policy={
+ "artifact_ids": ["unexpected.html"],
+ "max_total_bytes": 1_000_000,
+ },
+ )
+ with self.assertRaises(DocForgeError) as artifact:
+ PortableGraphHtmlRenderer().render(wrong_artifact)
+ self.assertEqual("invalid_projection", artifact.exception.code)
+
+ tiny = build_graph_projection_package(
+ self.plan,
+ renderer_id=PortableGraphHtmlRenderer.renderer_id,
+ renderer_version=PortableGraphHtmlRenderer.renderer_version,
+ max_output_bytes=1,
+ )
+ with self.assertRaises(DocForgeError) as too_large:
+ PortableGraphHtmlRenderer().render(tiny)
+ self.assertEqual("render_too_large", too_large.exception.code)
+
+ def test_initial_mode_is_honored_and_logic_is_rejected(self) -> None:
+ for mode in ("nodes", "flow", "web"):
+ with self.subTest(mode=mode):
+ plan = build_graph_view_plan(
+ self.snapshot,
+ GraphViewRequestV1(
+ view_id=f"{mode}-view",
+ title=f"{mode.title()} view",
+ root_node_id="guide.workflow",
+ initial_mode=mode, # type: ignore[arg-type]
+ depth=2,
+ max_nodes=20,
+ max_edges=40,
+ max_work=1_000,
+ ),
+ False,
+ )
+ package = build_graph_projection_package(
+ plan,
+ renderer_id=PortableGraphHtmlRenderer.renderer_id,
+ renderer_version=PortableGraphHtmlRenderer.renderer_version,
+ max_output_bytes=1_000_000,
+ )
+ rendered = (
+ PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
+ )
+ self.assertIn(f'data-mode="{mode}"', rendered)
+ self.assertIn(f'