1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Make render status receipt based

This commit is contained in:
Andraxion 2026-07-29 04:42:55 -04:00
parent 21c4992f9c
commit 0fe968c475
10 changed files with 913 additions and 27 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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(
{

View file

@ -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":

View file

@ -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,6 +212,7 @@ class DocForgeService:
"server_version": SERVER_VERSION,
"error": error.as_dict(),
}
if load_error_identity:
try:
snapshot = self.project.load()
result.update(
@ -221,6 +223,9 @@ class DocForgeService:
)
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(

View file

@ -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)

View file

@ -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:

View file

@ -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())