diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md index a19a4e1..2305a00 100644 --- a/DEVELOPMENT_NOTES.md +++ b/DEVELOPMENT_NOTES.md @@ -160,6 +160,31 @@ committed success. A separate 700-character preflight test proves the callback a are never created. Changeset lifecycle receipts now obey the configured changeset byte limit on both write and read. +#### Receipt-based render status + +Successful declared renders now publish a bounded, atomic version-1 receipt below the disposable +cache. It binds project/root/adapter/source identity, the normalized view configuration, renderer +identity, template and output hashes, byte size, and safe regular-file identities. Generic renders +also publish the verified source generation used by cheap status. + +Normal status compares only source-generation, descriptor/view, template-file, output-file, and +receipt identities. It does not call `Project.load()`, prepare the renderer, construct HTML, read +the full output, rebuild the index, or repair missing state. Missing and corrupt receipts are +`unverified`; source, template, or output changes are `stale`. An explicit `deep` option on the +Python, CLI, and MCP status surfaces preserves the old side-effect-free full-render equivalence +oracle. + +Receipt failure after atomic output replacement is reported as degraded publication success, not a +false render failure. Canonical application converts the same condition into a degraded +derived-refresh report while retaining canonical success. Focused tests forbid source loading and +renderer preparation during warm status and cover output, template, missing-receipt, corrupt- +receipt, and post-publication receipt-failure behavior. + +After race hardening, a 50-sample three-node receipt-status check measured a 3.202 ms median and +3.509 ms p95, compared with the 1.941 ms Milestone 0 three-node full-render status. The small +fixture does not show the scaling benefit; the 1,000-node Milestone 0 status baseline was +150.591 ms and will be rerun in the final Milestone 1 evidence pass. + #### Bounded indexed retrieval Search, metadata filtering, backlinks, dependency traversal, and impact traversal now query one diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index cf3ba59..d2d7fc4 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -127,10 +127,14 @@ index, render, and error payloads remain available through the corresponding rea ## Render boundary -`docforge_render_status` recomputes expected hashes without writing. `docforge_preview_changeset` -runs only a project-declared view through DocForge's fixed built-in renderer registry and writes one -atomic HTML file below the configured preview root. Rendering declared project output is available -only through the explicit local CLI integration command. +`docforge_render_status` reads bounded publication receipts and cheap file/source identities by +default. It does not parse canonical nodes, prepare Markdown, construct HTML, hash the complete +output, rebuild the index, or write state. Missing or corrupt receipts are conservative +`unverified` results. Callers may pass `deep = true` to explicitly request the side-effect-free +full-render equivalence oracle. `docforge_preview_changeset` runs only a project-declared view +through DocForge's fixed built-in renderer registry and writes one atomic HTML file below the +configured preview root. Rendering declared project output is available only through the explicit +local CLI integration command. ## Visualization boundary diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index d7189a6..b3f76d6 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -472,7 +472,7 @@ context PROFILE [--budget N] ### Render and proposal commands ```text -render-status [VIEW_ID] +render-status [VIEW_ID] [--deep] render VIEW_ID preview CHANGESET_ID VIEW_ID apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID @@ -629,6 +629,13 @@ Canonical application records its terminal receipt immediately after the project verifies the new canonical state. A later index or render refresh failure is reported as degraded derived state with remediation, not as permission to apply the same canonical change again. +Every successful declared render publishes a bounded version-1 receipt below the disposable cache. +Normal `render-status` compares cheap source-generation, view-configuration, template-file, and +output-file identities. It does not parse canonical nodes, prepare Markdown, construct HTML, or +hash the complete output. Missing or corrupt receipts are `unverified`; changed sources, templates, +or outputs are `stale`. Use `render-status --deep` only when explicitly requesting the +side-effect-free full-render equivalence oracle. + Use `docforge_propose_relationship_update` when the intended change is only an edge addition or removal. It uses the same underlying validated update contract, but rejects empty relationship lists and makes it explicit that node content will remain unchanged. diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 6f40649..86624ac 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -10,7 +10,10 @@ "status": { "const": "ok" }, "project_id": { "type": "string" }, "revision": { "type": "string" }, - "source_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "source_hash": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{64}$" + }, "adapter": { "type": "string" } }, "additionalProperties": true diff --git a/src/docforge/application.py b/src/docforge/application.py index 632ad46..bb52c0a 100644 --- a/src/docforge/application.py +++ b/src/docforge/application.py @@ -405,7 +405,28 @@ class CanonicalApplicationService: if config is not None: for view in config.views: try: - renders.append(self.rendering.render(view.view_id)) + rendered = self.rendering.render(view.view_id) + renders.append(rendered) + if rendered.get("state") == "degraded": + receipt = rendered.get("receipt") + refresh_errors.append( + { + "component": "render_receipt", + "view_id": view.view_id, + "error": ( + cast(Mapping[str, object], receipt).get("error") + if isinstance(receipt, dict) + else { + "code": "render_receipt_failure", + "message": ( + "Rendered output was published without a " + "verification receipt" + ), + "details": {}, + } + ), + } + ) except DocForgeError as error: refresh_errors.append( { diff --git a/src/docforge/cli.py b/src/docforge/cli.py index e3c2023..85a6313 100644 --- a/src/docforge/cli.py +++ b/src/docforge/cli.py @@ -61,6 +61,7 @@ def _parser() -> argparse.ArgumentParser: render.add_argument("view_id") render_status = commands.add_parser("render-status") render_status.add_argument("view_id", nargs="?") + render_status.add_argument("--deep", action="store_true") preview = commands.add_parser("preview") preview.add_argument("changeset_id") preview.add_argument("view_id") @@ -172,7 +173,12 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]: if arguments.command == "render": return RenderService(project).render(arguments.view_id) if arguments.command == "render-status": - return RenderService(project).status(arguments.view_id) + rendering = RenderService(project) + return ( + rendering.deep_status(arguments.view_id) + if arguments.deep + else rendering.status(arguments.view_id) + ) if arguments.command == "preview": return RenderService(project).preview(arguments.changeset_id, arguments.view_id) if arguments.command == "apply": diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index 1185481..2d47c6b 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -176,6 +176,7 @@ class DocForgeService: *, synchronize: bool = True, mutation: _MutationPolicy | None = None, + load_error_identity: bool = True, ) -> dict[str, Any]: synchronization: dict[str, object] | None = None maximum = self.project.descriptor.limits.max_tool_output_chars @@ -211,16 +212,20 @@ class DocForgeService: "server_version": SERVER_VERSION, "error": error.as_dict(), } - try: - snapshot = self.project.load() - result.update( - { - "revision": snapshot.revision, - "source_hash": snapshot.source_hash, - } - ) - except DocForgeError: + if load_error_identity: + try: + snapshot = self.project.load() + result.update( + { + "revision": snapshot.revision, + "source_hash": snapshot.source_hash, + } + ) + except DocForgeError: + result.update({"revision": "unknown", "source_hash": None}) + else: result.update({"revision": "unknown", "source_hash": None}) + result["staleness"] = "unknown" remediation = self._remediation(error) if remediation is not None: cast(dict[str, object], result["error"])["remediation"] = remediation @@ -497,7 +502,11 @@ class DocForgeService: "recommended_workflow": recommended_workflow, } - return self.invoke(operation, synchronize=False) + return self.invoke( + operation, + synchronize=False, + load_error_identity=False, + ) def project_info(self) -> dict[str, object]: def operation() -> dict[str, object]: @@ -628,8 +637,22 @@ class DocForgeService: return self.invoke(operation) - def render_status(self, view_id: str | None = None) -> dict[str, object]: - return self.invoke(lambda: self.rendering.status(view_id)) + def render_status( + self, + view_id: str | None = None, + *, + deep: bool = False, + ) -> dict[str, object]: + operation = ( + (lambda: self.rendering.deep_status(view_id)) + if deep + else (lambda: self.rendering.status(view_id)) + ) + return self.invoke( + operation, + synchronize=False, + load_error_identity=False, + ) def context(self, profile: str, budget: int | None = None) -> dict[str, Any]: return self.invoke(lambda: self.context_provider(self.index, profile, budget)) @@ -808,10 +831,13 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC return service.validate_project() @server.tool(name="docforge_render_status") - def render_status(view_id: str | None = None) -> dict[str, Any]: - """Report render configuration state without generating or changing output.""" + def render_status( + view_id: str | None = None, + deep: bool = False, + ) -> dict[str, Any]: + """Report receipt state, or explicitly recompute the side-effect-free render oracle.""" - return service.render_status(view_id) + return service.render_status(view_id, deep=deep) @server.tool(name="docforge_visualize") def visualize( diff --git a/src/docforge/rendering.py b/src/docforge/rendering.py index 4524d10..1a09879 100644 --- a/src/docforge/rendering.py +++ b/src/docforge/rendering.py @@ -4,18 +4,33 @@ from __future__ import annotations import fcntl import hashlib +import json import os +import stat import tempfile from collections.abc import Callable, Generator from contextlib import contextmanager from pathlib import Path +from typing import cast from .changesets import ChangesetStore from .errors import DocForgeError -from .models import ProjectService, ProjectSnapshot, RenderConfig, RenderView +from .models import ( + GenerationRecordingProject, + IncrementalStateProject, + ProjectDescriptor, + ProjectService, + ProjectSnapshot, + ProjectState, + RenderConfig, + RenderView, +) from .project import project_root_fingerprint from .render_contract import PreparedRender, relative_output, renderer_for +RENDER_RECEIPT_SCHEMA_VERSION = 1 +MAX_RENDER_RECEIPT_BYTES = 64_000 + class RenderService: """Render only declared views through fixed built-in renderer implementations.""" @@ -25,6 +40,47 @@ class RenderService: self.changesets = changesets or ChangesetStore(project) def status(self, view_id: str | None = None) -> dict[str, object]: + """Report publication state from bounded receipts without rendering canonical content.""" + + descriptor = self.project.descriptor + config = descriptor.render + current_state = self._current_state() + if config is None: + return self._status_result( + descriptor, + current_state, + configured=False, + state="not_configured", + verification="receipt", + outputs=[], + ) + views = self._views(config, view_id) + first_outputs = [self._receipt_status(descriptor, view, current_state) for view in views] + outputs = [self._receipt_status(descriptor, view, current_state) 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_state = self._current_state() + if final_state != current_state: + for output in outputs: + if output["state"] == "current": + output["state"] = "stale" + output["reason"] = "source_changed_during_status" + identity = final_state if final_state is not None else current_state + return self._status_result( + descriptor, + identity, + configured=True, + state="current" if all(item["state"] == "current" for item in outputs) else "stale", + verification="receipt", + outputs=outputs, + ) + + def deep_status(self, view_id: str | None = None) -> dict[str, object]: + """Recompute render output as the explicit side-effect-free equivalence oracle.""" + snapshot = self.project.load() config = snapshot.descriptor.render if config is None: @@ -32,11 +88,20 @@ class RenderService: snapshot, configured=False, state="not_configured", + verification="deep", outputs=[], ) views = self._views(config, view_id) outputs: list[dict[str, object]] = [] for view in views: + template_before = self._safe_file_identity( + snapshot.descriptor.root, + view.template_path, + ) + output_before = self._safe_file_identity( + snapshot.descriptor.root, + view.output_path, + ) prepared, _ = self._prepare(snapshot, view, changeset_hash=None) state = "missing" actual_hash: str | None = None @@ -50,13 +115,31 @@ class RenderService: raw = output.read_bytes() actual_hash = hashlib.sha256(raw).hexdigest() state = "current" if actual_hash == prepared.output_hash else "stale" - outputs.append( - self._view_result(snapshot, view, prepared, state=state, actual_hash=actual_hash) + result = self._view_result( + snapshot, + view, + prepared, + state=state, + actual_hash=actual_hash, ) + if template_before != self._safe_file_identity( + snapshot.descriptor.root, view.template_path + ) or output_before != self._safe_file_identity( + snapshot.descriptor.root, view.output_path + ): + result["state"] = "stale" + result["reason"] = "publication_changed_during_deep_status" + outputs.append(result) + current = self.project.load() + if current.source_hash != snapshot.source_hash or current.revision != snapshot.revision: + for output in outputs: + output["state"] = "stale" + output["reason"] = "source_changed_during_deep_status" return self._result( snapshot, configured=True, state="current" if all(item["state"] == "current" for item in outputs) else "stale", + verification="deep", outputs=outputs, ) @@ -71,10 +154,32 @@ class RenderService: prepared.output, verify=lambda: self._verify_canonical(snapshot, view, template_bytes), ) + receipt: dict[str, object] + state = "current" + try: + if isinstance(self.project, GenerationRecordingProject): + self.project.record_generation(snapshot) + receipt = self._publish_receipt(snapshot, view, prepared) + except (DocForgeError, OSError) as error: + state = "degraded" + receipt = { + "state": "failed", + "error": ( + error.as_dict() + if isinstance(error, DocForgeError) + else { + "code": "render_receipt_failure", + "message": "Rendered output was published but its receipt failed", + "details": {}, + } + ), + } return self._result( snapshot, configured=True, - state="current", + state=state, + publication="published", + receipt=receipt, output=self._view_result( snapshot, view, @@ -84,6 +189,496 @@ class RenderService: ), ) + def _receipt_status( + self, + descriptor: ProjectDescriptor, + view: RenderView, + current_state: ProjectState | None, + ) -> dict[str, object]: + receipt, receipt_state = self._read_receipt(view) + output_state = self._safe_file_identity(descriptor.root, view.output_path) + if output_state is None: + state = "unsafe" if view.output_path.is_symlink() else "missing" + return self._receipt_view_result( + descriptor, + view, + receipt, + state=state, + reason="output_not_safe" if state == "unsafe" else "output_missing", + ) + if receipt is None: + return self._receipt_view_result( + descriptor, + view, + receipt, + state="unverified", + reason=receipt_state, + ) + if not self._receipt_matches_binding(descriptor, view, receipt): + return self._receipt_view_result( + descriptor, + view, + receipt, + state="unverified", + reason="foreign_or_incompatible_receipt", + ) + template_state = self._safe_file_identity(descriptor.root, view.template_path) + if template_state is None: + return self._receipt_view_result( + descriptor, + view, + receipt, + state="unsafe", + reason="template_not_safe", + ) + if receipt.get("template_file") != template_state: + return self._receipt_view_result( + descriptor, + view, + receipt, + state="stale", + reason="template_changed", + ) + if receipt.get("output_file") != output_state: + return self._receipt_view_result( + descriptor, + view, + receipt, + state="stale", + reason="output_changed", + ) + if current_state is None: + reason = ( + "source_generation_unavailable" + if isinstance(self.project, GenerationRecordingProject) + else "source_generation_unsupported" + ) + return self._receipt_view_result( + descriptor, + view, + receipt, + state=( + "stale" + if isinstance(self.project, GenerationRecordingProject) + else "unverified" + ), + reason=reason, + ) + if ( + receipt.get("source_hash") != current_state.source_hash + or receipt.get("revision") != current_state.revision + ): + return self._receipt_view_result( + descriptor, + view, + receipt, + state="stale", + reason="source_generation_changed", + ) + return self._receipt_view_result( + descriptor, + view, + receipt, + state="current", + reason=None, + ) + + def _publish_receipt( + self, + snapshot: ProjectSnapshot, + view: RenderView, + prepared: PreparedRender, + ) -> dict[str, object]: + source_before = self._current_state() + if isinstance(self.project, GenerationRecordingProject) and ( + source_before is None + or source_before.source_hash != snapshot.source_hash + or source_before.revision != snapshot.revision + ): + raise DocForgeError( + "render_receipt_failure", + "Canonical source changed before render receipt publication", + ) + if source_before is not None and ( + source_before.source_hash != snapshot.source_hash + or source_before.revision != snapshot.revision + ): + raise DocForgeError( + "render_receipt_failure", + "Canonical source changed before render receipt publication", + ) + template_file, template_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.template_path, + snapshot.descriptor.limits.max_template_bytes, + ) + output_file, output_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.output_path, + snapshot.descriptor.limits.max_render_bytes, + ) + if ( + template_hash != prepared.template_hash + or output_hash != prepared.output_hash + or output_file["size"] != len(prepared.output) + ): + raise DocForgeError( + "render_receipt_failure", + "Published render files do not match the verified render", + ) + final_template_file, final_template_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.template_path, + snapshot.descriptor.limits.max_template_bytes, + ) + final_output_file, final_output_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.output_path, + snapshot.descriptor.limits.max_render_bytes, + ) + source_after = self._current_state() + if ( + template_file != final_template_file + or output_file != final_output_file + or template_hash != final_template_hash + or output_hash != final_output_hash + or source_before != source_after + ): + raise DocForgeError( + "render_receipt_failure", + "Render publication changed while its receipt was being prepared", + ) + payload: dict[str, object] = { + "schema_version": RENDER_RECEIPT_SCHEMA_VERSION, + "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_id": view.view_id, + "view_config_hash": self._view_config_hash(snapshot.descriptor, view), + "renderer": prepared.renderer, + "renderer_version": prepared.renderer_version, + "render_identity": prepared.render_identity, + "template_hash": prepared.template_hash, + "output_hash": prepared.output_hash, + "output_bytes": len(prepared.output), + "template_file": final_template_file, + "output_file": final_output_file, + } + raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n" + if len(raw) > MAX_RENDER_RECEIPT_BYTES: + raise DocForgeError( + "render_receipt_failure", + "Render publication receipt exceeds its fixed size limit", + ) + root = self._receipt_root(create=True) + path = root / f"{view.view_id}.json" + if path.is_symlink(): + raise DocForgeError( + "path_escape", + "Render publication receipt path is not safe", + ) + descriptor, temporary_name = tempfile.mkstemp(prefix=".render-receipt-", dir=root) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(raw) + handle.flush() + os.fsync(handle.fileno()) + last_template_file, last_template_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.template_path, + snapshot.descriptor.limits.max_template_bytes, + ) + last_output_file, last_output_hash = self._verified_file_digest( + snapshot.descriptor.root, + view.output_path, + snapshot.descriptor.limits.max_render_bytes, + ) + if ( + last_template_file != final_template_file + or last_output_file != final_output_file + or last_template_hash != final_template_hash + or last_output_hash != final_output_hash + or self._current_state() != source_after + ): + raise DocForgeError( + "render_receipt_failure", + "Render publication changed before receipt publication", + ) + os.replace(temporary, path) + directory_descriptor = os.open(root, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + except Exception: + temporary.unlink(missing_ok=True) + raise + return { + "state": "current", + "schema_version": RENDER_RECEIPT_SCHEMA_VERSION, + "path": path.relative_to(snapshot.descriptor.root).as_posix(), + } + + def _read_receipt( + self, + view: RenderView, + ) -> tuple[dict[str, object] | None, str]: + try: + root = self._receipt_root(create=False) + except DocForgeError: + return None, "receipt_root_unsafe" + path = root / f"{view.view_id}.json" + if path.is_symlink(): + return None, "receipt_unsafe" + if not path.is_file(): + return None, "receipt_missing" + try: + if path.stat().st_size > MAX_RENDER_RECEIPT_BYTES: + return None, "receipt_oversized" + raw = path.read_bytes() + if len(raw) > MAX_RENDER_RECEIPT_BYTES: + return None, "receipt_oversized" + parsed: object = json.loads(raw) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None, "receipt_corrupt" + if not isinstance(parsed, dict): + return None, "receipt_corrupt" + return cast(dict[str, object], parsed), "receipt" + + def _receipt_root(self, *, create: bool) -> Path: + cache_root = self.project.descriptor.cache_root + root = cache_root / "render-receipts" + if ( + cache_root.resolve(strict=False) != cache_root + or root.is_symlink() + or root.resolve(strict=False) != root + or not root.is_relative_to(cache_root) + ): + raise DocForgeError("path_escape", "Render receipt root is not safe") + if create: + cache_root.mkdir(parents=True, exist_ok=True) + root.mkdir(parents=True, exist_ok=True) + if root.exists() and not root.is_dir(): + raise DocForgeError("path_escape", "Render receipt root is not safe") + return root + + @staticmethod + def _safe_file_identity(root: Path, path: Path) -> dict[str, object] | None: + if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root): + return None + try: + current = path.lstat() + except OSError: + return None + if not stat.S_ISREG(current.st_mode): + return None + return { + "path": path.relative_to(root).as_posix(), + "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, + } + + @staticmethod + def _verified_file_digest( + root: Path, + path: Path, + maximum: int, + ) -> tuple[dict[str, object], str]: + if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root): + raise DocForgeError( + "render_receipt_failure", + "Render publication file is not safe for verification", + ) + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + except OSError as error: + raise DocForgeError( + "render_receipt_failure", + "Render publication file is not readable for verification", + ) 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: + raise DocForgeError( + "render_receipt_failure", + "Render publication file failed receipt validation", + ) + digest = hashlib.file_digest(handle, "sha256").hexdigest() + return ( + { + "path": path.relative_to(root).as_posix(), + "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, + }, + digest, + ) + + @staticmethod + def _view_config_hash(descriptor: ProjectDescriptor, view: RenderView) -> str: + payload = { + "view_id": view.view_id, + "renderer": view.renderer, + "template": view.template_path.relative_to(descriptor.root).as_posix(), + "output": view.output_path.relative_to(descriptor.root).as_posix(), + "title": view.title, + "families": list(view.families), + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + def _receipt_matches_binding( + self, + descriptor: ProjectDescriptor, + view: RenderView, + receipt: dict[str, object], + ) -> bool: + required = { + "schema_version", + "project_id", + "project_root_fingerprint", + "adapter", + "revision", + "source_hash", + "view_id", + "view_config_hash", + "renderer", + "renderer_version", + "render_identity", + "template_hash", + "output_hash", + "output_bytes", + "template_file", + "output_file", + } + renderer = renderer_for(view) + template_file = receipt.get("template_file") + output_file = receipt.get("output_file") + return ( + set(receipt) == required + 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) + and receipt.get("adapter") == descriptor.adapter + and receipt.get("view_id") == view.view_id + and receipt.get("view_config_hash") == self._view_config_hash(descriptor, view) + and receipt.get("renderer") == renderer.renderer_id + and receipt.get("renderer_version") == renderer.renderer_version + and self._is_hash(receipt.get("source_hash")) + and isinstance(receipt.get("revision"), str) + and bool(receipt.get("revision")) + and self._is_hash(receipt.get("view_config_hash")) + and self._is_hash(receipt.get("render_identity")) + and self._is_hash(receipt.get("template_hash")) + and self._is_hash(receipt.get("output_hash")) + and type(receipt.get("output_bytes")) is int + and 0 <= cast(int, receipt["output_bytes"]) <= descriptor.limits.max_render_bytes + and self._valid_receipt_file( + template_file, + view.template_path.relative_to(descriptor.root).as_posix(), + descriptor.limits.max_template_bytes, + ) + and self._valid_receipt_file( + output_file, + view.output_path.relative_to(descriptor.root).as_posix(), + descriptor.limits.max_render_bytes, + ) + and cast(dict[str, object], output_file)["size"] == receipt.get("output_bytes") + ) + + @staticmethod + def _is_hash(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + @staticmethod + def _valid_receipt_file( + value: object, + expected_path: str, + maximum: int, + ) -> bool: + if not isinstance(value, dict): + return False + payload = cast(dict[str, object], value) + return ( + set(payload) + == { + "path", + "device", + "inode", + "mode", + "size", + "mtime_ns", + "ctime_ns", + } + and payload.get("path") == expected_path + and all( + type(payload.get(key)) is int and cast(int, payload[key]) >= 0 + for key in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns") + ) + and cast(int, payload["size"]) <= maximum + ) + + @staticmethod + def _receipt_view_result( + descriptor: ProjectDescriptor, + view: RenderView, + receipt: dict[str, object] | None, + *, + state: str, + reason: str | None, + ) -> dict[str, object]: + payload = receipt or {} + return { + "view_id": view.view_id, + "renderer": payload.get("renderer", view.renderer), + "renderer_version": payload.get("renderer_version"), + "render_identity": payload.get("render_identity"), + "expected_output_hash": payload.get("output_hash"), + "actual_output_hash": (payload.get("output_hash") if state == "current" else None), + "template_hash": payload.get("template_hash"), + "path": view.output_path.relative_to(descriptor.root).as_posix(), + "state": state, + "reason": reason, + "verification": "receipt", + "receipt_schema_version": payload.get("schema_version"), + } + + def _current_state(self) -> ProjectState | None: + if isinstance(self.project, IncrementalStateProject): + return self.project.incremental_state() + return None + + @staticmethod + def _status_result( + descriptor: ProjectDescriptor, + identity: ProjectState | None, + **payload: object, + ) -> dict[str, object]: + return { + "status": "ok", + "project_id": descriptor.project_id, + "project_root_fingerprint": project_root_fingerprint(descriptor.root), + "adapter": descriptor.adapter, + "revision": identity.revision if identity is not None else "unknown", + "source_hash": identity.source_hash if identity is not None else None, + **payload, + } + def preview(self, changeset_id: str, view_id: str) -> dict[str, object]: with self._lock(): snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4dd7e36..632f76e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -10,6 +10,7 @@ import time import unittest from contextlib import contextmanager from pathlib import Path +from unittest import mock from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -79,6 +80,10 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): ): self.assertIn("limit", tools[name].inputSchema["properties"]) self.assertNotIn("limit", tools[name].inputSchema.get("required", [])) + self.assertIn( + "deep", + tools["docforge_render_status"].inputSchema["properties"], + ) self.assertFalse( any( token in name @@ -188,6 +193,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(contract["proposal_access"]["enabled"]) self.assertFalse(results[3].structuredContent["available"]) self.assertTrue(results[11].structuredContent["configured"]) + self.assertEqual("receipt", results[11].structuredContent["verification"]) self.assertEqual("stale", results[11].structuredContent["state"]) visualization = results[12].structuredContent["visualization"] self.assertTrue(visualization["read_only"]) @@ -220,6 +226,31 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): result.structuredContent["error"]["code"], ) + async def test_render_status_error_never_loads_or_synchronizes_project(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + project = Project.open(root) + service = DocForgeService(project) + with ( + mock.patch.object( + project, + "load", + side_effect=AssertionError("status error decoration must remain cheap"), + ), + mock.patch.object( + service.index, + "synchronize", + side_effect=AssertionError("status must not synchronize"), + ), + ): + result = service.render_status("not-a-view") + + self.assertEqual("error", result["status"]) + self.assertEqual("unknown_render_view", result["error"]["code"]) + self.assertEqual("unknown", result["revision"]) + self.assertIsNone(result["source_hash"]) + self.assertEqual("unknown", result["staleness"]) + async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow( self, ) -> None: diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 3b576be..03d66ca 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -77,6 +77,171 @@ class DocForgeRenderingTests(unittest.TestCase): self.assertEqual("stale", stale["outputs"][0]["state"]) self.assertEqual(first_bytes, output.read_bytes()) + def test_warm_render_status_uses_only_publication_receipts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + rendered = RenderService(Project.open(root)).render("manual") + self.assertEqual("current", rendered["receipt"]["state"]) + + project = Project.open(root) + service = RenderService(project) + with ( + mock.patch.object( + project, + "load", + side_effect=AssertionError("receipt status must not load canonical source"), + ), + mock.patch.object( + service, + "_prepare", + side_effect=AssertionError("receipt status must not render"), + ), + ): + current = service.status("manual") + self.assertEqual("current", current["state"]) + self.assertEqual("receipt", current["verification"]) + self.assertEqual("current", current["outputs"][0]["state"]) + + output = root / ".docforge/rendered/manual.html" + output.write_bytes(output.read_bytes() + b"\n") + changed_output = service.status("manual") + self.assertEqual("stale", changed_output["state"]) + self.assertEqual("output_changed", changed_output["outputs"][0]["reason"]) + + RenderService(Project.open(root)).render("manual") + template = root / "docs/templates/manual.html" + template.write_text( + template.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + changed_template = service.status("manual") + self.assertEqual("template_changed", changed_template["outputs"][0]["reason"]) + + def test_render_receipt_failures_are_degraded_after_output_publication(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + service = RenderService(Project.open(root)) + with mock.patch.object( + service, + "_publish_receipt", + side_effect=DocForgeError( + "render_receipt_failure", + "Synthetic receipt failure", + ), + ): + result = service.render("manual") + + self.assertEqual("degraded", result["state"]) + self.assertEqual("published", result["publication"]) + self.assertEqual("failed", result["receipt"]["state"]) + self.assertTrue((root / ".docforge/rendered/manual.html").is_file()) + + def test_render_receipt_refuses_post_render_input_and_output_changes(self) -> None: + for changed in ("template", "output", "source"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + service = RenderService(Project.open(root)) + publish = service._publish_receipt + + def mutate_then_publish( + snapshot, + view, + prepared, + *, + changed_kind=changed, + project_root=root, + publish_receipt=publish, + ): + if changed_kind == "template": + target = project_root / "docs/templates/manual.html" + elif changed_kind == "output": + target = project_root / ".docforge/rendered/manual.html" + else: + target = project_root / "docs/content/workflow.md" + target.write_bytes(target.read_bytes() + b"\nChanged before receipt.\n") + return publish_receipt(snapshot, view, prepared) + + with mock.patch.object( + service, + "_publish_receipt", + side_effect=mutate_then_publish, + ): + result = service.render("manual") + + self.assertEqual("degraded", result["state"]) + self.assertEqual("published", result["publication"]) + self.assertNotEqual("current", service.status("manual")["state"]) + self.assertEqual("stale", service.deep_status("manual")["state"]) + + def test_missing_and_corrupt_render_receipts_are_conservative(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + service = RenderService(Project.open(root)) + service.render("manual") + receipt = root / ".docforge/cache/render-receipts/manual.json" + + receipt.unlink() + missing = service.status("manual") + self.assertEqual("unverified", missing["outputs"][0]["state"]) + self.assertEqual("receipt_missing", missing["outputs"][0]["reason"]) + + receipt.write_text("{not-json", encoding="utf-8") + corrupt = service.status("manual") + self.assertEqual("unverified", corrupt["outputs"][0]["state"]) + self.assertEqual("receipt_corrupt", corrupt["outputs"][0]["reason"]) + + def test_render_receipt_schema_and_renderer_version_fail_closed(self) -> None: + for mutation in ("missing_hash", "renderer_version", "file_identity"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + service = RenderService(Project.open(root)) + service.render("manual") + receipt_path = root / ".docforge/cache/render-receipts/manual.json" + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if mutation == "missing_hash": + receipt.pop("output_hash") + elif mutation == "renderer_version": + receipt["renderer_version"] = "obsolete" + else: + receipt["output_file"].pop("ctime_ns") + receipt_path.write_text( + json.dumps(receipt, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + status = service.status("manual") + self.assertEqual("unverified", status["outputs"][0]["state"]) + self.assertEqual( + "foreign_or_incompatible_receipt", + status["outputs"][0]["reason"], + ) + + def test_render_status_detects_change_between_bounded_captures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + service = RenderService(Project.open(root)) + service.render("manual") + receipt_status = service._receipt_status + calls = 0 + + def mutate_between_captures(descriptor, view, current_state): + nonlocal calls + calls += 1 + if calls == 2: + output = root / ".docforge/rendered/manual.html" + output.write_bytes(output.read_bytes() + b"\n") + return receipt_status(descriptor, view, current_state) + + with mock.patch.object( + service, + "_receipt_status", + side_effect=mutate_between_captures, + ): + result = service.status("manual") + + self.assertEqual("stale", result["state"]) + self.assertNotEqual("current", result["outputs"][0]["state"]) + def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) @@ -312,6 +477,7 @@ class DocForgeRenderingTests(unittest.TestCase): ("render", "manual"), ("render-status", "manual"), ("preview", "cli-preview", "manual"), + ("render-status", "manual", "--deep"), ) results: list[dict] = [] for command in commands: @@ -322,6 +488,8 @@ class DocForgeRenderingTests(unittest.TestCase): self.assertEqual("current", results[0]["state"]) self.assertEqual("current", results[1]["state"]) self.assertEqual("current", results[2]["state"]) + self.assertEqual("receipt", results[1]["verification"]) + self.assertEqual("deep", results[3]["verification"]) self.assertTrue((root / ".docforge/rendered/manual.html").is_file()) self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())