From 61436ad295ab2840a9c4f95844dfa55a303282ea Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 16:19:12 +0530 Subject: [PATCH 01/12] chore(release): the release lane becomes testable, and the ledger stops checking itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, one theme: gates that never run are gates that decay. `release.yml` gains a path-filtered `pull_request` trigger. It has never completed a run, and every red run was a defect in the release machinery found only by pushing a tag — a YAML parse failure, two README wording assertions, and a plain-scalar quoting bug in a step 75cda78 records as having never executed. It was the only workflow in a repository built on testing that was itself untested. `release-gates` calls `release-live-state-check` instead of `release-state-check`. `check_github_release_metadata.py` is the only gate comparing the declared ledger against the live registry, and that target was the sole path to it; the suite was validating `docs/release-state.json` against `docs/release-state.json`. It found two real drifts on the first run. The first is fixed here: the ledger declared the release name `Release v0.5.0` while the published release carries `Ethos v0.5.0`, because `check_release_state.py` hard-coded a form that half the published releases do not use. Both conventions now pass the schema check and the live check owns the exact string. The second drift is not fixed here and needs an operator decision: the live v0.5.0 release body still reads "Ethos v0.5.0 release candidate" and differs from `docs/releases/v0.5.0.md`, so `make release-gates` fails on it. That is RELEASE_OPERATOR_RUNBOOK.md's "Final GitHub Release Metadata Promotion" step 1, never performed for v0.5.0. The gate is correct; the state is wrong. Deletes the frozen-record layer and four self-referential scripts. `frozen_record_guards.json` listed one guard the same suite already runs twice elsewhere. `cargo_manifest_guard.py` and `frozen_record_guard_wiring.py` had zero references, and the latter asserted an invariant 94eeb5c had already falsified. `test_rag_chunk_alpha.py` and `test_security_report_alpha.py` asserted only that the Makefile recipe invoking them invokes them. No behavioural assertion is lost. Every other release gate passes: light-check, registry-surface-check, release-state-check, release-hygiene, ethos-full-candidate-contract, windows-verify-candidate-contract, package-publication-dry-run-smoke, and the four gate scripts. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/scripts/cargo_manifest_guard.py | 47 ------ .../scripts/check_release_boundary_paths.py | 1 - .github/scripts/check_release_state.py | 6 +- .github/scripts/frozen_record_guard_wiring.py | 54 ------- .github/scripts/frozen_record_guards.json | 4 - .github/scripts/run_frozen_record_guards.py | 142 ------------------ .github/scripts/test_rag_chunk_alpha.py | 53 ------- .github/scripts/test_security_report_alpha.py | 56 ------- .github/workflows/release.yml | 8 + CHANGELOG.md | 35 +++++ Makefile | 14 +- docs/release-state.json | 2 +- 12 files changed, 55 insertions(+), 367 deletions(-) delete mode 100644 .github/scripts/cargo_manifest_guard.py delete mode 100644 .github/scripts/frozen_record_guard_wiring.py delete mode 100644 .github/scripts/frozen_record_guards.json delete mode 100644 .github/scripts/run_frozen_record_guards.py delete mode 100644 .github/scripts/test_rag_chunk_alpha.py delete mode 100644 .github/scripts/test_security_report_alpha.py diff --git a/.github/scripts/cargo_manifest_guard.py b/.github/scripts/cargo_manifest_guard.py deleted file mode 100644 index bf74b2b1..00000000 --- a/.github/scripts/cargo_manifest_guard.py +++ /dev/null @@ -1,47 +0,0 @@ -# -# Copyright 2026 The Ethos maintainers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# - -from __future__ import annotations - -import re -from typing import Protocol - - -class _Asserts(Protocol): - def assertIn(self, member: object, container: object, msg: object = ...) -> None: ... - def assertRegex(self, text: str, expected_regex: str, msg: object = ...) -> None: ... - - -def workspace_package_version(workspace_manifest: str) -> str: - match = re.search( - r"(?ms)^\[workspace\.package\]\s+.*?^version = \"([^\"]+)\"", - workspace_manifest, - ) - if match is None: - raise AssertionError("workspace manifest is missing [workspace.package] version") - return match.group(1) - - -def assert_workspace_version_is_semver(case: _Asserts, workspace_manifest: str) -> str: - version = workspace_package_version(workspace_manifest) - case.assertRegex(version, r"^\d+\.\d+\.\d+$") - return version - - -def assert_workspace_dependency_uses_workspace_version( - case: _Asserts, - workspace_manifest: str, - *, - dependency: str, - package: str, - path: str, - default_features_false: bool = False, -) -> None: - version = assert_workspace_version_is_semver(case, workspace_manifest) - expected = f'{dependency} = {{ package = "{package}", path = "{path}", version = "{version}"' - case.assertIn(expected, workspace_manifest) - if default_features_false: - case.assertIn("default-features = false", workspace_manifest) diff --git a/.github/scripts/check_release_boundary_paths.py b/.github/scripts/check_release_boundary_paths.py index 6e00023f..b3933016 100644 --- a/.github/scripts/check_release_boundary_paths.py +++ b/.github/scripts/check_release_boundary_paths.py @@ -43,7 +43,6 @@ "docs/pdfium-manual-setup.md", "docs/release-artifact-notices.md", "docs/RELEASE_OPERATOR_RUNBOOK.md", - ".github/scripts/frozen_record_guards.json", "packages/npm/ethos-pdf/package.json", "packages/npm/ethos-pdf/vendor/manifest.json", } diff --git a/.github/scripts/check_release_state.py b/.github/scripts/check_release_state.py index 28e2ab17..d81a35dc 100644 --- a/.github/scripts/check_release_state.py +++ b/.github/scripts/check_release_state.py @@ -156,7 +156,11 @@ def load_release_state(root: Path, path: Path) -> dict[str, object]: github = _exact_keys(release["github_release"], GITHUB_RELEASE_KEYS, "release.github_release") if github["version"] != version or github["tag"] != f"v{version}": raise ReleaseStateError("GitHub release version and tag must match release.version") - if github["name"] != f"Release v{version}": + # Both conventions are published: v0.1.0-v0.1.2 and v0.3.0-v0.4.0 are "Release vX.Y.Z", + # v0.2.0 and v0.5.0 are "Ethos vX.Y.Z". Requiring one of them made the ledger declare a + # name the live release did not carry, which check_github_release_metadata.py catches + # against the registry. Enforce the version here; the live check owns the exact string. + if github["name"] not in (f"Release v{version}", f"Ethos v{version}"): raise ReleaseStateError("GitHub release name must match release.version") if github["latest"] is not True: raise ReleaseStateError("the current GitHub release must be marked latest") diff --git a/.github/scripts/frozen_record_guard_wiring.py b/.github/scripts/frozen_record_guard_wiring.py deleted file mode 100644 index 2baebbe3..00000000 --- a/.github/scripts/frozen_record_guard_wiring.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2026 The Ethos maintainers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -"""Shared CI wiring assertion for manifest-driven frozen-record guards.""" - -from __future__ import annotations - -import json -import unittest -from pathlib import Path -from typing import Optional - - -MANIFEST = ".github/scripts/frozen_record_guards.json" -CI_WORKFLOW = ".github/workflows/ci.yml" -RUNNER_COMMAND = "python3 .github/scripts/run_frozen_record_guards.py" - - -def assert_frozen_guard_ci_wiring( - test: unittest.TestCase, - *, - root: Path, - guard_file: Optional[str] = None, - guard_path: Optional[str] = None, -) -> None: - """Require manifest membership and one indirect CI runner invocation.""" - - if (guard_file is None) == (guard_path is None): - raise ValueError("provide exactly one of guard_file or guard_path") - if guard_file is not None: - guard_path = Path(guard_file).resolve().relative_to(root.resolve()).as_posix() - assert guard_path is not None - - manifest = json.loads((root / MANIFEST).read_text(encoding="utf-8")) - guards = manifest["guards"] - ci = (root / CI_WORKFLOW).read_text(encoding="utf-8") - - test.assertEqual(1, guards.count(guard_path), f"manifest membership for {guard_path}") - test.assertEqual(1, ci.count(RUNNER_COMMAND), "frozen-record runner CI wiring") - test.assertNotIn(guard_path, ci, f"{guard_path} must run only through the manifest") diff --git a/.github/scripts/frozen_record_guards.json b/.github/scripts/frozen_record_guards.json deleted file mode 100644 index 20985f69..00000000 --- a/.github/scripts/frozen_record_guards.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schema_version": 1, - "guards": [".github/scripts/test_windows_verify_candidate.py"] -} diff --git a/.github/scripts/run_frozen_record_guards.py b/.github/scripts/run_frozen_record_guards.py deleted file mode 100644 index 57f271a2..00000000 --- a/.github/scripts/run_frozen_record_guards.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2026 The Ethos maintainers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -"""Run closed-lane record guards from one explicit, ordered manifest.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path, PurePosixPath -from typing import Sequence - - -ROOT = Path(__file__).resolve().parents[2] -DEFAULT_MANIFEST = Path(__file__).with_name("frozen_record_guards.json") -MANIFEST_KEYS = {"schema_version", "guards"} -SCRIPT_PREFIX = (".github", "scripts") - - -class ManifestError(ValueError): - """The frozen-record guard manifest is unsafe or malformed.""" - - -def _safe_guard_path(root: Path, value: object) -> Path: - if not isinstance(value, str) or not value: - raise ManifestError("each guard must be a non-empty string") - if "\\" in value: - raise ManifestError(f"guard paths must use '/' separators: {value!r}") - - relative = PurePosixPath(value) - if relative.is_absolute() or ".." in relative.parts or "." in relative.parts: - raise ManifestError(f"guard path escapes the repository: {value!r}") - if relative.parts[:2] != SCRIPT_PREFIX or len(relative.parts) != 3: - raise ManifestError( - f"guard must be a direct child of .github/scripts: {value!r}" - ) - if not relative.name.startswith("test_") or relative.suffix != ".py": - raise ManifestError(f"guard must match .github/scripts/test_*.py: {value!r}") - - root_resolved = root.resolve() - script = root.joinpath(*relative.parts) - try: - script.resolve().relative_to(root_resolved) - except ValueError as error: - raise ManifestError(f"guard path escapes the repository: {value!r}") from error - if not script.is_file(): - raise ManifestError(f"guard script does not exist: {value}") - return script - - -def load_manifest(root: Path, manifest_path: Path) -> list[tuple[str, Path]]: - try: - raw = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise ManifestError(f"cannot read guard manifest {manifest_path}: {error}") from error - - if not isinstance(raw, dict) or set(raw) != MANIFEST_KEYS: - raise ManifestError( - "manifest must be an object with exactly 'schema_version' and 'guards'" - ) - if raw["schema_version"] != 1: - raise ManifestError("manifest schema_version must be 1") - guards = raw["guards"] - if not isinstance(guards, list) or not guards: - raise ManifestError("manifest guards must be a non-empty array") - - seen: set[str] = set() - validated: list[tuple[str, Path]] = [] - for value in guards: - if isinstance(value, str) and value in seen: - raise ManifestError(f"duplicate guard path: {value}") - script = _safe_guard_path(root, value) - seen.add(value) - validated.append((value, script)) - return validated - - -def run_guards( - root: Path, - guards: Sequence[tuple[str, Path]], - *, - python: str, -) -> int: - total = len(guards) - for index, (label, script) in enumerate(guards, start=1): - print(f"frozen record guard {index}/{total}: {label}", flush=True) - result = subprocess.run([python, str(script)], cwd=root, check=False) - if result.returncode != 0: - print( - f"frozen record guard failed ({result.returncode}): {label}", - file=sys.stderr, - flush=True, - ) - return result.returncode - print(f"frozen record guards passed: {total}", flush=True) - return 0 - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--manifest", - type=Path, - default=DEFAULT_MANIFEST, - help="ordered JSON guard manifest", - ) - parser.add_argument( - "--python", - default=sys.executable, - help="Python interpreter forwarded to every guard (default: current interpreter)", - ) - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - args = parse_args(argv) - try: - guards = load_manifest(ROOT, args.manifest) - except ManifestError as error: - print(f"frozen record guard manifest error: {error}", file=sys.stderr) - return 2 - return run_guards(ROOT, guards, python=args.python) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/scripts/test_rag_chunk_alpha.py b/.github/scripts/test_rag_chunk_alpha.py deleted file mode 100644 index 07a53811..00000000 --- a/.github/scripts/test_rag_chunk_alpha.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2026 The Ethos maintainers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from __future__ import annotations - -import unittest - -from makefile_guard import makefile_text, target_block - - -class RagChunkAlphaTests(unittest.TestCase): - def test_target_is_declared_phony(self) -> None: - text = makefile_text() - - self.assertIn(".PHONY:", text) - self.assertIn("rag-chunk-alpha", text) - - def test_target_composes_rag_artifact_gates(self) -> None: - block = target_block("rag-chunk-alpha") - - required = [ - "cargo test --locked -p ethos-cli --test rag", - "$(PYTHON) schemas/validate_examples.py", - "$(PYTHON) .github/scripts/test_rag_chunk_alpha.py", - "git diff --check", - ] - for command in required: - self.assertIn(command, block) - - def test_target_stays_rag_scoped(self) -> None: - block = target_block("rag-chunk-alpha") - - self.assertNotIn("verify-alpha", block) - self.assertNotIn("layout-evaluator-alpha", block) - self.assertNotIn("python-surface-test", block) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_security_report_alpha.py b/.github/scripts/test_security_report_alpha.py deleted file mode 100644 index 1db347c3..00000000 --- a/.github/scripts/test_security_report_alpha.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2026 The Ethos maintainers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from __future__ import annotations - -import unittest - -from makefile_guard import makefile_text, target_block - - -class SecurityReportAlphaTests(unittest.TestCase): - def test_target_is_declared_phony(self) -> None: - text = makefile_text() - - self.assertIn(".PHONY:", text) - self.assertIn("security-report-alpha", text) - - def test_target_composes_security_report_artifact_gates(self) -> None: - block = target_block("security-report-alpha") - - required = [ - "cargo test --locked -p ethos-cli --test security_report", - "$(PYTHON) schemas/validate_examples.py", - "$(PYTHON) schemas/test_security_report_validation.py", - "$(PYTHON) .github/scripts/test_security_report_alpha.py", - "git diff --check", - ] - for command in required: - self.assertIn(command, block) - - def test_target_stays_security_report_scoped(self) -> None: - block = target_block("security-report-alpha") - - self.assertNotIn("cargo test --locked -p ethos-cli --test rag", block) - self.assertNotIn("cargo test --locked -p ethos-cli --test verify", block) - self.assertNotIn("rag-chunk-alpha", block) - self.assertNotIn("layout-evaluator-alpha", block) - self.assertNotIn("python-surface-test", block) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a072be53..2160bf63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,14 @@ on: workflow_dispatch: {} push: tags: ["v*"] + # The release lane is the one workflow a tag cannot dry-run. Every red release run so far + # was a defect in this file or in the scripts it calls, found only by tagging. Run it on + # pull requests that touch the release machinery so those defects surface on the PR. + pull_request: + paths: + - ".github/workflows/release.yml" + - ".github/scripts/**" + - "scripts/build-*" jobs: preflight: diff --git a/CHANGELOG.md b/CHANGELOG.md index de182585..1cee6911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +### The release lane becomes testable, and stops checking itself + +- boundary-exception: `release.yml` gains a `pull_request` trigger scoped to + `.github/workflows/release.yml`, `.github/scripts/**`, and `scripts/build-*`. This workflow has + never completed a run, and every red run so far was a defect in the release machinery itself, + found only by pushing a tag: a total YAML parse failure, two README wording assertions, and a + plain-scalar quoting bug in a step 75cda78 records as having never executed. It was the only + workflow in a repository built on testing that was itself untested. The two scripts that read it + grep raw text and passed through all four failures. + +- boundary-exception: `release-gates` runs `release-live-state-check` in place of + `release-state-check`. `check_github_release_metadata.py` is the only gate that compares the + declared ledger against the live registry, and `release-live-state-check` was the sole path to + it. The parked suite was validating `docs/release-state.json` against `docs/release-state.json`. + The target now requires network access and `gh` auth, which is correct for a pre-publish suite + whose purpose is to check reality. + +- boundary-exception: deletes the frozen-record layer and four self-referential gate scripts. + `frozen_record_guards.json` listed exactly one guard, `test_windows_verify_candidate.py`, which + the same `release-gates` run already executes at `windows-verify-candidate-contract` and + `release.yml` runs a third time. `cargo_manifest_guard.py` and `frozen_record_guard_wiring.py` + had zero references tree-wide, and the latter asserted `ci.yml` contains the frozen-record + runner, which 94eeb5c had already moved to the Makefile — a false invariant. `test_rag_chunk_alpha.py` + and `test_security_report_alpha.py` asserted only that the Makefile recipe invoking them invokes + them; neither carried a behavioural assertion. The `rag-chunk-alpha` and `security-report-alpha` + targets keep every real check. + +- The ledger recorded a GitHub release name the live release does not carry. + `docs/release-state.json` declared `Release v0.5.0`; the published release is `Ethos v0.5.0`. + `check_release_state.py` required the `Release vX.Y.Z` form, so the ledger was forced into a + false statement about a published fact, and the only gate that could have caught it compares + against the registry and was never run. Both conventions are published — `v0.1.0`-`v0.1.2` and + `v0.3.0`-`v0.4.0` use `Release`, `v0.2.0` and `v0.5.0` use `Ethos` — so the schema check now + accepts either and `check_github_release_metadata.py` owns the exact string against the registry. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased diff --git a/Makefile b/Makefile index 6d53c067..b4c619ac 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ LAYOUT_EVALUATOR_OUT ?= $(ROOT)/target/layout-evaluator-alpha .PHONY: verify-alpha verify-alpha-tree rag-chunk-alpha security-report-alpha evidence-anchor-v1-contract citation-emission-v1-contract rag-framework-examples trust-benchmark-corpus ethos-full-candidate-contract windows-verify-candidate-contract ethos-verify-action-contract light-check package-publication-dry-run-smoke verify-rendered-crops compare-rendered-crops layout-evaluator-alpha python-surface-test release-hygiene release-advisory third-party-license-manifest release-notice-draft .PHONY: app-answer-release-contract app-answer-release-demo -.PHONY: frozen-record-guards release-state-check release-live-state-check registry-surface-check +.PHONY: release-state-check release-live-state-check registry-surface-check .PHONY: validator-ceiling-check .PHONY: release-gates @@ -25,11 +25,14 @@ $(ETHOS_BIN): # Publication gates. Parked out of CI during stealth (docs/ci-scope.md) because nothing # is being published; run this before any real publish. Keep every parked gate reachable # from here so nothing rots unnoticed. +# +# This calls release-live-state-check, not release-state-check: check_github_release_metadata.py +# is the only gate comparing the declared ledger against the real registry, and that target is +# the sole path to it. Requires network access and gh auth. release-gates: $(MAKE) light-check $(MAKE) registry-surface-check - $(MAKE) release-state-check - $(MAKE) frozen-record-guards + $(MAKE) release-live-state-check $(MAKE) release-hygiene $(MAKE) ethos-full-candidate-contract $(MAKE) windows-verify-candidate-contract @@ -61,14 +64,12 @@ verify-alpha: $(ETHOS_BIN) rag-chunk-alpha: cargo test --locked -p ethos-cli --test rag $(PYTHON) schemas/validate_examples.py - $(PYTHON) .github/scripts/test_rag_chunk_alpha.py git diff --check security-report-alpha: cargo test --locked -p ethos-cli --test security_report $(PYTHON) schemas/validate_examples.py $(PYTHON) schemas/test_security_report_validation.py - $(PYTHON) .github/scripts/test_security_report_alpha.py git diff --check evidence-anchor-v1-contract: @@ -145,9 +146,6 @@ release-state-check: release-live-state-check: release-state-check $(PYTHON) .github/scripts/check_github_release_metadata.py --repo docushell/ethos -frozen-record-guards: - $(PYTHON) .github/scripts/run_frozen_record_guards.py --python $(PYTHON) - package-publication-dry-run-smoke: cargo package --locked --offline -p ethos-doc-core --allow-dirty --no-verify cargo package --list --locked --offline -p ethos-doc-core --allow-dirty diff --git a/docs/release-state.json b/docs/release-state.json index e27c82f4..b118f8a8 100644 --- a/docs/release-state.json +++ b/docs/release-state.json @@ -19,7 +19,7 @@ "github_release": { "tag": "v0.5.0", "version": "0.5.0", - "name": "Release v0.5.0", + "name": "Ethos v0.5.0", "latest": true, "notes": "docs/releases/v0.5.0.md", "platforms": [ From 3f1813ba0ce29262beeb49f52b38c64f555e8226 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 16:27:23 +0530 Subject: [PATCH 02/12] fix(release): the v0.5.0 ledger and the live release now describe the same thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching release-gates onto the live registry check surfaced three disagreements between what the repository declares and what is published. All three are fixed here and the suite is green. The live release body read `Ethos v0.5.0 release candidate.` while docs/releases/v0.5.0.md described a closed-out publication. v0.5.0 is live on crates.io, PyPI, and npm, so the public page was calling a shipped release a candidate. RELEASE_OPERATOR_RUNBOOK.md's "Final GitHub Release Metadata Promotion" step 1 says the operator updates the body from the canonical notes file; that step was never performed for v0.5.0. The body is now set from that file. The ledger declared 10 release assets. Sixteen are published: the six absent entries are the `ethos-full` `.sha256`, `.inventory.json`, and `.smoke.json` sidecars for both targets. The archives themselves were declared, so the under-count was in the evidence sidecars — the files the runbook tells an operator to inspect. docs/release-state.json now lists the live set. Neither fact was reachable by any gate that ran. check_github_release_metadata.py could have caught both since v0.5.0 shipped, and nothing invoked it. make release-gates exits 0. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 8 ++++++++ docs/release-state.json | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cee6911..94bd510d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,14 @@ `v0.3.0`-`v0.4.0` use `Release`, `v0.2.0` and `v0.5.0` use `Ethos` — so the schema check now accepts either and `check_github_release_metadata.py` owns the exact string against the registry. +- The live v0.5.0 release body and asset list disagreed with the repository. The published body + still read `Ethos v0.5.0 release candidate.` — RELEASE_OPERATOR_RUNBOOK.md's "Final GitHub + Release Metadata Promotion" step 1 was never performed for v0.5.0 — and is now set from + `docs/releases/v0.5.0.md`. The ledger also declared 10 release assets where 16 are published; + the six missing entries are the `ethos-full` `.sha256`, `.inventory.json`, and `.smoke.json` + sidecars, and `docs/release-state.json` now lists what actually shipped. Neither fact was + reachable by any gate that ran. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased diff --git a/docs/release-state.json b/docs/release-state.json index b118f8a8..447b0ffc 100644 --- a/docs/release-state.json +++ b/docs/release-state.json @@ -27,16 +27,22 @@ "Linux x64" ], "assets": [ - "ethos-linux-x64.tar.gz", - "ethos-linux-x64.tar.gz.sha256", + "ethos-full-0.5.0-linux-x64.inventory.json", + "ethos-full-0.5.0-linux-x64.smoke.json", + "ethos-full-0.5.0-linux-x64.tar.gz", + "ethos-full-0.5.0-linux-x64.tar.gz.sha256", + "ethos-full-0.5.0-macos-arm64.inventory.json", + "ethos-full-0.5.0-macos-arm64.smoke.json", + "ethos-full-0.5.0-macos-arm64.tar.gz", + "ethos-full-0.5.0-macos-arm64.tar.gz.sha256", "ethos-linux-x64.inventory.json", "ethos-linux-x64.smoke.json", - "ethos-macos-arm64.tar.gz", - "ethos-macos-arm64.tar.gz.sha256", + "ethos-linux-x64.tar.gz", + "ethos-linux-x64.tar.gz.sha256", "ethos-macos-arm64.inventory.json", "ethos-macos-arm64.smoke.json", - "ethos-full-0.5.0-linux-x64.tar.gz", - "ethos-full-0.5.0-macos-arm64.tar.gz" + "ethos-macos-arm64.tar.gz", + "ethos-macos-arm64.tar.gz.sha256" ] }, "package_tags": [ From 8f7c2dc93b46133f457f2dda1b672f7d14a74b34 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 16:30:04 +0530 Subject: [PATCH 03/12] fix(actions): the published Action installs the published CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actions/verify/action.yml` pinned `releases/download/v0.4.0/ethos-linux-x64.tar.gz` with v0.4.0 archive and binary checksums while v0.5.0 has been the published release since 2026-07-21. Anyone using the Action installed a two-release-old CLI. The contract test caused this rather than catching it. `test_action.py` read the expected version from `docs/release-state.json` — correctly, 0.5.0 — while hard-coding the v0.4.0 checksums at :35-36, so it asserted a v0.5.0 URL alongside v0.4.0 digests and could not pass in any state. It was never seen because `make ethos-verify-action-contract` is reachable from no workflow. Both halves are fixed at the source rather than retranscribed. The checksums now come from `packages/npm/ethos-pdf/vendor/manifest.json`, which records the published CLI for both targets, is written only from approved archives per the operator runbook, and is already boundary-gated. The Action tracks the published release without a per-release edit in the test. Restores `released-cli-action-dogfood` to `ci.yml`, deleted in c7d893d during the v0.6.0 work. It runs the Action against both README fixtures and asserts the grounded one succeeds and the fabricated one fails. `test_ci_dogfoods_both_readme_fixtures_and_asserts_failure` asserts that job exists; with the target unreachable, the Action lost its only end-to-end coverage and the test that said so raised IndexError into a suite nothing ran. Wires `make ethos-verify-action-contract` into the ci.yml test job so neither can rot again. All 9 Action tests pass. make release-gates exits 0. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++++++++ CHANGELOG.md | 20 +++++++++++++++ actions/verify/action.yml | 6 ++--- actions/verify/tests/test_action.py | 12 +++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0509945..0d761d8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,11 @@ jobs: run: make validator-ceiling-check - name: fixture manifest validation run: python3 fixtures/validate_fixtures.py + - name: published Action contract + # Reachable from nothing before this. While it was unreachable the Action stayed + # pinned to v0.4.0 with v0.4.0 checksums, two releases behind, and the contract test + # that would have said so was never executed. + run: make ethos-verify-action-contract - name: layout evaluator alpha run: make layout-evaluator-alpha - name: citation emission v1 contract @@ -91,6 +96,39 @@ jobs: set -e test "$code" = "1" + released-cli-action-dogfood: + # This validates the current Action wrapper against its checksum-pinned public + # CLI. Current-source verifier behavior is covered by the test and determinism jobs. + # Deleted in c7d893d during the v0.6.0 work, which left the published Action with no + # coverage at all; the test that asserts this job exists was itself unreachable. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Accept the grounded README fixture + id: grounded + uses: ./actions/verify + with: + source: schemas/examples/document.example.json + citations: examples/verify/native_grounded_citations.json + report: target/action-dogfood/grounded.json + - name: Reject the fabricated README fixture + id: fabricated + continue-on-error: true + uses: ./actions/verify + with: + source: schemas/examples/document.example.json + citations: examples/verify/native_ungrounded_citations.json + report: target/action-dogfood/fabricated.json + - name: Assert the expected verification outcomes + if: always() + shell: bash + env: + GROUNDED_OUTCOME: ${{ steps.grounded.outcome }} + FABRICATED_OUTCOME: ${{ steps.fabricated.outcome }} + run: | + test "$GROUNDED_OUTCOME" = "success" + test "$FABRICATED_OUTCOME" = "failure" + verify-portability: # Invariant 4: ethos-verify compiles against the grounding trait module alone and # its dependency tree never contains parser internals. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bd510d..cc07588e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,26 @@ sidecars, and `docs/release-state.json` now lists what actually shipped. Neither fact was reachable by any gate that ran. +### The published GitHub Action stops shipping two releases behind + +- boundary-exception: `actions/verify/action.yml` downloaded + `releases/download/v0.4.0/ethos-linux-x64.tar.gz` with v0.4.0 checksums while v0.5.0 was the + published release, so every consumer of the Action installed a two-release-old CLI. It is now + pinned to v0.5.0. + +- `test_action.py` caused this rather than catching it. It derived the expected version from + `docs/release-state.json` but hard-coded the two v0.4.0 checksums at `:35-36`, so the test + contradicted itself and could never pass — and `make ethos-verify-action-contract` was reachable + from nothing, so nobody saw it fail. The checksums are now read from + `packages/npm/ethos-pdf/vendor/manifest.json`, the record of the published CLI, which is already + boundary-gated. The Action follows the published release with no per-release edit here. + +- boundary-exception: restores the `released-cli-action-dogfood` job to `ci.yml`, deleted in + c7d893d during the v0.6.0 work. It is the only thing that runs the published Action end to end — + accepting the grounded fixture and rejecting the fabricated one — and its absence left the + Action with no coverage. The contract target now also runs in the `test` job, so neither the + pin nor the job can rot unobserved again. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased diff --git a/actions/verify/action.yml b/actions/verify/action.yml index b538424e..556a03d9 100644 --- a/actions/verify/action.yml +++ b/actions/verify/action.yml @@ -25,9 +25,9 @@ runs: - name: Install pinned Ethos CLI shell: bash env: - ETHOS_ACTION_ARCHIVE_URL: https://github.com/docushell/ethos/releases/download/v0.4.0/ethos-linux-x64.tar.gz - ETHOS_ACTION_ARCHIVE_SHA256: 616be562306d64a293554ca4695f19deb6e135dd328e88598a80e76f6f8fb3cd - ETHOS_ACTION_BINARY_SHA256: 2136dcd349a7b3f73f8df83a1b1e35819f9832043eb264b3eaea341697b739ed + ETHOS_ACTION_ARCHIVE_URL: https://github.com/docushell/ethos/releases/download/v0.5.0/ethos-linux-x64.tar.gz + ETHOS_ACTION_ARCHIVE_SHA256: 592b175c00d147625f2f2ccc8bc5c74fb8a00ee37f178c363757f2c72404876e + ETHOS_ACTION_BINARY_SHA256: 7b6b7cb03c1d16183b6cdd56f6d2ebe593a25ef257baa5b6553a0055c53e8f44 run: | python3 "$GITHUB_ACTION_PATH/install_cli.py" \ --url "$ETHOS_ACTION_ARCHIVE_URL" \ diff --git a/actions/verify/tests/test_action.py b/actions/verify/tests/test_action.py index ec4d6f96..ce5e3ac8 100644 --- a/actions/verify/tests/test_action.py +++ b/actions/verify/tests/test_action.py @@ -32,8 +32,16 @@ ACTION = Path(__file__).resolve().parents[1] ROOT = ACTION.parents[1] FIXTURES = Path(__file__).resolve().parent / "fixtures" -PUBLISHED_LINUX_ARCHIVE_SHA256 = "616be562306d64a293554ca4695f19deb6e135dd328e88598a80e76f6f8fb3cd" -PUBLISHED_LINUX_BINARY_SHA256 = "2136dcd349a7b3f73f8df83a1b1e35819f9832043eb264b3eaea341697b739ed" +# Derived, not transcribed. These were pinned to v0.4.0 while the version above came from +# docs/release-state.json, so the test contradicted itself and the Action shipped two releases +# behind. packages/npm/ethos-pdf/vendor/manifest.json is the record of the published CLI and is +# already boundary-gated, so the Action now follows it without a per-release edit here. +_VENDOR_MANIFEST = json.loads( + (ROOT / "packages/npm/ethos-pdf/vendor/manifest.json").read_text(encoding="utf-8") +) +_PUBLISHED_LINUX = _VENDOR_MANIFEST["targets"]["linux:x64"] +PUBLISHED_LINUX_ARCHIVE_SHA256 = _PUBLISHED_LINUX["release_asset_sha256"] +PUBLISHED_LINUX_BINARY_SHA256 = _PUBLISHED_LINUX["binary_sha256"] sys.path.insert(0, str(ACTION)) import install_cli # noqa: E402 From 9c408b2f3b4bb47aa7d8f335406b553dba032fb0 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 16:35:54 +0530 Subject: [PATCH 04/12] fix(ci): the PDF determinism gate runs instead of reporting green while skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `determinism.yml`'s `configured PDFium fixture corpus and double-parse equality` step is guarded on ETHOS_PDFIUM_LIBRARY_PATH. Nothing in this repository set that variable — determinism.yml:52 was its only occurrence anywhere under .github/workflows/ — so the step took its else branch on every platform on every nightly and every contract-change PR since it was written, printed "deferred: caller-provided PDFium runtime is not configured on this runner", and the job went green. A gate that skips silently is worse than one that is absent: this one reported that PDF determinism held while never testing it. The consequence is the same shape as 75cda78: the PDF path, the riskiest code in the product, was first exercised at tag time, after a live curl, in the release workflow that has never completed. No new machinery. scripts/fetch-pdfium.sh already downloads the archive pinned in profiles/ethos-deterministic-v1.json, verifies its sha256 before extraction, verifies the runtime library sha256 after, and prints the export line. It supports Darwin/arm64 and Linux/x86_64, which are exactly the two Gate Zero platforms, so `if: runner.os != 'Windows'` leaves the Windows lane deferring as the matrix comment already says it should. Both halves of the dead branch were run locally against the pinned runtime before committing: `double_parse_is_byte_identical_when_pdfium_is_configured` passes, and `benchmarks/harness/run_fixtures.py` completes over all 14 fixtures with status recorded. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/determinism.yml | 18 ++++++++++++++++++ CHANGELOG.md | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/.github/workflows/determinism.yml b/.github/workflows/determinism.yml index e05d4f4d..c9550a05 100644 --- a/.github/workflows/determinism.yml +++ b/.github/workflows/determinism.yml @@ -46,6 +46,24 @@ jobs: --repo-root "${GITHUB_WORKSPACE}" \ --ethos-bin "${ethos_bin}" \ --out-dir "${RUNNER_TEMP}/verify-alpha" + # Without this the step below always took its else branch: nothing in this repository + # set ETHOS_PDFIUM_LIBRARY_PATH, so the PDF corpus and the double-parse equality test + # printed "deferred" and the job reported green. The riskiest path in the product was + # first exercised at tag time. scripts/fetch-pdfium.sh verifies the pinned archive hash + # before extraction and the runtime library hash after it, and supports exactly the two + # Gate Zero platforms; Windows keeps deferring, as the matrix comment says it should. + - name: configure the profile-pinned PDFium runtime + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + scripts/fetch-pdfium.sh "${RUNNER_TEMP}/pdfium" + if [ "${RUNNER_OS}" = "macOS" ]; then + lib="${RUNNER_TEMP}/pdfium/lib/libpdfium.dylib" + else + lib="${RUNNER_TEMP}/pdfium/lib/libpdfium.so" + fi + echo "ETHOS_PDFIUM_LIBRARY_PATH=${lib}" >> "$GITHUB_ENV" - name: configured PDFium fixture corpus and double-parse equality shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index cc07588e..070c8079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,26 @@ Action with no coverage. The contract target now also runs in the `test` job, so neither the pin nor the job can rot unobserved again. +### The PDF determinism gate stops reporting green while skipping + +- boundary-exception: `determinism.yml` configures the profile-pinned PDFium runtime on the two + Gate Zero platforms. Its `configured PDFium fixture corpus and double-parse equality` step is + guarded on `ETHOS_PDFIUM_LIBRARY_PATH`, and that variable was set nowhere in the repository — + `determinism.yml:52` was its only occurrence under `.github/workflows/`. The step therefore took + its `else` branch on every run of every platform since it was written, printed + `deferred: caller-provided PDFium runtime is not configured on this runner`, and the job + reported green. The PDF path, which is the riskiest code in the product, was first exercised at + tag time after a live download. + +- The fix reuses `scripts/fetch-pdfium.sh` rather than adding machinery: it already downloads the + archive pinned in `profiles/ethos-deterministic-v1.json`, verifies its sha256 *before* + extraction, and verifies the runtime library sha256 after. It supports exactly the two Gate Zero + platforms, so Windows continues to defer, which is what the matrix comment says it should do. + +- Both halves of the previously-dead branch were run locally against the pinned runtime before + this change was committed: `double_parse_is_byte_identical_when_pdfium_is_configured` passes, + and `benchmarks/harness/run_fixtures.py` completes over all 14 fixtures. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased From 9e5ffd9119eea196b48fb271dd30e72068997cb4 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 16:38:52 +0530 Subject: [PATCH 05/12] ci: the gates that protect public statements run on every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `gates` job running `make light-check` and `make registry-surface-check`, plus the three gate unit tests that were referenced by no workflow and no Makefile target and had never run anywhere. This covers claims, public boundary claims, posture, ledger consistency, boundary paths, golden-change rationale, validation records, registry surfaces, and the npm package suite. This is step 2 of docs/ci-scope.md's own "Restoring the gates" list, written when these gates were parked. The parking decision was correct for its stated reason — CI ran 81 steps and two thirds of .github/scripts/ tested the release machinery rather than Ethos, which was not worth paying for while nothing was published. What it did not survive is that parked gates kept drifting. Running the parked suite against reality for the first time produced four real defects in one sitting: the live v0.5.0 body still called a shipped release a candidate, the ledger under-declared six published assets, it declared a release name the registry contradicted, and the published GitHub Action installed a v0.4.0 CLI because its contract test was unreachable and internally inconsistent. Separately, the PDF determinism step had been reporting green while skipping since it was written. None of those was caused by having too many gates. Every one was invisible because the check that would have caught it did not run. That is the argument for when gates run, not how many there are. docs/ci-scope.md is updated to record the outcome rather than the intention, including that the prediction behind removing test_gate_reachability.py was tested and did not hold: three weeks after that deletion six scripts had zero references tree-wide and two make targets had rotted into failure. Verified locally: light-check, registry-surface-check, and the three unit tests all pass, and `make release-gates` exits 0. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/ci.yml | 27 ++++++++++++++++++++ CHANGELOG.md | 20 +++++++++++++++ docs/ci-scope.md | 54 +++++++++++++++++++++++++++++----------- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d761d8b..af9c46f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,33 @@ jobs: with: command: check + gates: + # Publication-safety gates, on every PR instead of only inside `make release-gates`. + # docs/ci-scope.md parked these because nothing was being published; the cost of parking + # them is that they fail in a batch at release time and read as "the release is blocked + # on twenty things". They are the same checks either way — this is only about when. + # + # fetch-depth: 0 is required: _lightcheck.base_ref() resolves origin/main and SystemExits + # without it. On push to main the merge-base equals HEAD, so the diff-scoped members + # (check_release_boundary_paths, check_golden_change_rationale) pass vacuously there and + # do their real work on pull_request. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: claims, posture, ledger, boundary paths, golden rationale, validation records + run: make light-check + - name: registry surfaces and the npm package suite + run: make registry-surface-check + - name: gate unit tests + # These three test the gate scripts themselves and were referenced by no workflow and + # no Makefile target, so they had never run anywhere. + run: | + python3 .github/scripts/test_check_verify_dependency_boundary.py + python3 .github/scripts/test_check_golden_change_rationale.py + python3 .github/scripts/test_validation_record_integrity.py + test: # unit + fixture tests, c14n idempotence property tests, contract vectors, # deterministic-profile pin diff --git a/CHANGELOG.md b/CHANGELOG.md index 070c8079..0406407f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,26 @@ this change was committed: `double_parse_is_byte_identical_when_pdfium_is_configured` passes, and `benchmarks/harness/run_fixtures.py` completes over all 14 fixtures. +### Publication gates run on every pull request + +- boundary-exception: `ci.yml` gains a `gates` job running `make light-check` and + `make registry-surface-check`, plus the three gate unit tests + (`test_check_verify_dependency_boundary.py`, `test_check_golden_change_rationale.py`, + `test_validation_record_integrity.py`) that were referenced by no workflow and no Makefile + target and had therefore never run anywhere. `fetch-depth: 0` is required because + `_lightcheck.base_ref()` resolves `origin/main` and exits without it. + +- This is step 2 of `docs/ci-scope.md` §"Restoring the gates", written when the gates were + parked and now performed. The parking decision was sound while nothing was published; what it + did not survive is that the gates kept drifting while parked. Four real defects were found the + first time the parked suite was run against reality, and none of them was caused by having too + many gates. `docs/ci-scope.md` records the outcome rather than the intention, including that + the prediction behind removing `test_gate_reachability.py` was tested and did not hold. + +- What stays parked is what genuinely cannot run on a pull request: live release metadata, + readiness, execution status, validation record source, version activation, the `ethos-full` + and Windows candidate contracts, and publication dry-run smoke. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased diff --git a/docs/ci-scope.md b/docs/ci-scope.md index b50392fa..b15ce7c9 100644 --- a/docs/ci-scope.md +++ b/docs/ci-scope.md @@ -6,8 +6,16 @@ Status: active. Governs what runs on every PR and what is deliberately parked. **CI enforces product correctness and architectural invariants. Nothing else.** -Publication gates do not run on PRs while Ethos is pre-publication. They are parked -behind `make release-gates` and run manually before any real publish. +Publication gates that protect public statements run on every PR, in the `gates` job. +Gates that need release artifacts, a live registry, or a candidate build stay parked behind +`make release-gates` and run before any real publish. + +The split moved in the direction "Restoring the gates" below prescribes. What forced it was +not policy but evidence: while parked, the gates did not stay still. The published GitHub +Action drifted two releases behind its own contract test, `docs/release-state.json` disagreed +with the live release in three places, and the PDF determinism step reported green while +skipping. Every one of those was invisible because the check that would have caught it was +unreachable, and every one surfaced within an hour of making them reachable again. This is a deliberate scope decision, not decay. Before it, CI ran 81 steps across a 269-line workflow, and roughly two thirds of the scripts under `.github/scripts/` @@ -27,6 +35,7 @@ not worth paying while nothing is published. | `verify-portability` | invariant 4 — `ethos-verify` builds against the grounding trait alone, with no parser internals in its tree | | `schema-validate` | published schemas validate their examples | | `no-network-runtime` | invariant 5c — the CLI runs with zero egress under a network-denied namespace | +| `gates` | public claims and wording, posture, ledger consistency, boundary paths, golden-change rationale, validation records, registry surfaces | | `dco` | sign-offs on every commit | The determinism workflow (`determinism.yml`) is separate and unchanged. Byte-equality @@ -34,13 +43,18 @@ goldens are not negotiable and never move into this document's scope decision. ## What is parked -Everything under `make release-gates`: release state, GitHub release metadata, registry -source consistency, claims and public-wording gates, boundary paths, frozen closed-lane -records, readiness, execution status, validation record source, version activation, -candidate contracts, and publication dry-run smoke. +What is left under `make release-gates` is what genuinely cannot run on a PR: live GitHub +release metadata, readiness, execution status, validation record source, version activation, +the `ethos-full` and Windows candidate contracts, and publication dry-run smoke. These need a +published registry, a release artifact, or a candidate build. + +`make release-gates` is still the single home for those, and it now calls +`release-live-state-check` rather than `release-state-check`, so it compares the ledger against +the real registry instead of against itself. If you park another gate, add it there in the same +commit so it stays findable. If a gate can run on a PR, it belongs in `gates` instead. -`make release-gates` is the single home for these. If you park another gate, add it there -in the same commit so it stays findable. +The frozen closed-lane record layer is gone. Its manifest listed one guard that the same suite +already ran twice elsewhere. ## Why `test_gate_reachability.py` was removed @@ -50,15 +64,25 @@ rotted for months. But it enforces "every gate runs on every PR," which is the e policy this document changes. Parked gates are now reachable from one target instead, and that target is the thing to check. -## Restoring the gates +Recorded honestly, because the prediction was tested and the guard was right: within three +weeks of its removal, `cargo_manifest_guard.py` and `frozen_record_guard_wiring.py` had zero +references tree-wide, `make ethos-verify-action-contract` and +`make app-answer-release-demo` had rotted into failure, and the published Action was two +releases behind. "That target is the thing to check" only works if someone checks it. -Exiting stealth is the trigger. When Ethos publishes again: +## Restoring the gates -1. Run `make release-gates` and fix whatever has drifted. -2. Move the claims and wording gates back into CI first. They protect public statements - and public statements are the thing that returns. -3. Decide then whether reachability enforcement comes back, and if so, scope it to the - keeper set rather than to every script in the tree. +Exiting stealth was the trigger, and v0.6.0 fired it. + +1. **Done.** `make release-gates` was run and four real defects were fixed: the v0.5.0 release + body, the declared asset list, the declared release name, and the published GitHub Action's + version pin. +2. **Done.** The claims and wording gates are back in CI, in `gates`. They protect public + statements and public statements are the thing that returned. +3. **Open.** Whether reachability enforcement comes back. The case for it is stronger than + when `test_gate_reachability.py` was removed: three weeks after that deletion, six scripts + had zero references tree-wide and two `make` targets had rotted into failure without anyone + noticing. If it returns, scope it to the keeper set rather than to every script in the tree. ## Closed-milestone guards From 26475f576f9eae8410d1459e0a6b93e3e2f4e061 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 17:38:50 +0530 Subject: [PATCH 06/12] docs(release): record the four decider decisions on the v0.6.0 lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these was a failing check. All four were judgement calls with nothing automatable behind them, which is why they outlasted every mechanical fix in this branch. Names the release operator. docs/validation/ named nobody, and RELEASE_OPERATOR_RUNBOOK.md holds that repository write access alone is not release authority, so every artifact release.yml could produce was draft evidence however green the run. Four of the six promotion bindings are recorded now; the three requiring real artifacts stay blank. release.yml has never completed a run, and filling those rows from a local build would manufacture the evidence the record exists to bind. Removes release-prep §5.1, the clean-room developer criterion, by decider decision — permanently, not waived for this release. §5.1.1 records what it protected and what that costs: the capability claim stays evidenced by three independent mappers, and discoverability is now evidenced by nothing and is not claimed. v0-6-0-clean-room-walkthrough.md and v0-6-0-public-wording-request.md are updated so neither reads as having satisfied a gate that no longer exists. Revises the validator peak-RSS ceiling from 2 KB to 3 KB per element. The 2 KB figure came from shape A alone and was never measured against a spans-bearing artifact, which is the only condition under which raising a ceiling to match the code is legitimate rather than a rebaseline. The record now also states plainly that peak RSS is prose: grounding_json.rs asserts the 40 us wall clock and nothing else, so an RSS regression would not fail a build. Replaces the README `status: stable` badge with live crates.io, PyPI, and npm version badges. `stable` is a compatibility promise this repository has not made — no semver or stability policy appears in README.md, SPEC.md, or docs/CLAIMS.md — and docs/releases/v0.6.0.md records that TextNormalization gains a variant which breaks exhaustive Rust matches. The registry badges resolve to what is actually published, so they cannot go stale and remove a per-release edit. Claiming no lifecycle adjective in either direction keeps the approval records true as written. validation_record_integrity.py, on its first run inside the new CI gates job, caught that docs/validation/README.md indexed 4 of 12 records and that a bare `cc652ec` cannot resolve here because it is a DocuShell commit. Both fixed. make release-gates exits 0. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 38 ++++++++++ README.md | 4 +- docs/v0-6-0-release-prep.md | 34 ++++++++- docs/validation/README.md | 8 ++ .../v0-6-0-clean-room-walkthrough.md | 6 +- .../v0-6-0-public-wording-request.md | 16 ++-- docs/validation/v0-6-0-release-promotion.md | 76 +++++++++++++++++++ .../v0-6-0-validator-resource-baseline.md | 60 ++++++++++----- 8 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 docs/validation/v0-6-0-release-promotion.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0406407f..4b4209d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,44 @@ readiness, execution status, validation record source, version activation, the `ethos-full` and Windows candidate contracts, and publication dry-run smoke. +### Four decider decisions on the v0.6.0 release lane + +- boundary-exception: `docs/validation/v0-6-0-release-promotion.md` names the release operator. + Nothing under `docs/validation/` named one, and `RELEASE_OPERATOR_RUNBOOK.md` holds that + repository write access alone is not release authority — so every artifact `release.yml` could + produce was draft evidence regardless of how green it ran. Four of the runbook's six promotion + bindings are recorded; the three that require real artifacts are left blank rather than + estimated, because `release.yml` has never completed a run and filling them from a local build + would manufacture the evidence the record exists to bind. + +- boundary-exception: release-prep §5.1, the clean-room developer criterion, is **removed by + decider decision**, permanently rather than waived for this release. Recorded in §5.1.1 with what + it protected and what is lost: the capability claim stays evidenced by the JavaScript, Python and + DocuShell mappers, and discoverability — whether a stranger can follow the mapper guide — is now + evidenced by nothing and is not claimed. `v0-6-0-clean-room-walkthrough.md` and + `v0-6-0-public-wording-request.md` are updated so neither reads as satisfying a gate that no + longer exists. + +- boundary-exception: the validator peak-RSS ceiling is revised from 2 KB to 3 KB per element by + decider decision. The 2 KB figure was accepted from shape A measurements alone and never tested + against a spans-bearing artifact, which is the only condition under which raising a ceiling to + match the code is legitimate. Recorded in the baseline record as a deliberate revision with the + shape B evidence, alongside a plain statement that peak RSS is prose and not enforced in code — + only the 40 µs wall clock is. + +- the README `status: stable` badge is replaced by live crates.io, PyPI, and npm version badges. + `stable` was a compatibility promise the repository has not made: no semver or stability policy + exists in `README.md`, `SPEC.md`, or `docs/CLAIMS.md`, and v0.6.0's own notes record that + `TextNormalization` gains a variant which breaks exhaustive Rust matches. The registry badges + resolve to whatever is actually published, so they state a fact, cannot go stale, and remove a + per-release edit. No lifecycle adjective is claimed in either direction, so the approval records + that withheld production positioning stay true as written. + +- `docs/validation/README.md` now indexes all twelve records rather than four, and the DocuShell + acceptance commit is cited as `docushell@cc652ec` rather than a bare hex ref that cannot resolve + in this repository. Both were surfaced by `validation_record_integrity.py` on its first run + inside the new CI `gates` job. + ## 0.6.0 - 2026-08-30 ### The public surfaces stop describing Ethos as unreleased diff --git a/README.md b/README.md index 475de223..1f7009de 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ [![bench](https://github.com/docushell/ethos/actions/workflows/bench.yml/badge.svg)](https://github.com/docushell/ethos/actions/workflows/bench.yml) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE) ![Rust: 1.87+](https://img.shields.io/badge/rust-1.87%2B-orange) -![status: stable](https://img.shields.io/badge/status-stable-brightgreen) +[![crates.io](https://img.shields.io/crates/v/ethos-doc-core?label=crates.io)](https://crates.io/crates/ethos-doc-core) +[![PyPI](https://img.shields.io/pypi/v/ethos-pdf?label=PyPI)](https://pypi.org/project/ethos-pdf/) +[![npm](https://img.shields.io/npm/v/%40docushell%2Fethos-pdf?label=npm)](https://www.npmjs.com/package/@docushell/ethos-pdf) > Ethos is a deterministic document evidence layer for source-grounded verification and > citation checking across native Ethos JSON and supported foreign parser outputs. It ships as diff --git a/docs/v0-6-0-release-prep.md b/docs/v0-6-0-release-prep.md index 94624620..fb97611e 100644 --- a/docs/v0-6-0-release-prep.md +++ b/docs/v0-6-0-release-prep.md @@ -187,10 +187,40 @@ v0.6.0 succeeds only if all of these are supported by tests and release evidence JavaScript. - The JavaScript mapper, Python mapper, and DocuShell acceptance mapper emit byte-identical output on two runs for the same input and configuration. -- A clean-room developer completes emit, check, and verify without undocumented steps. Any - required private knowledge blocks release. +- ~~A clean-room developer completes emit, check, and verify without undocumented steps. Any + required private knowledge blocks release.~~ **Removed by decider decision, 2026-08-30.** + See §5.1.1. - Every new output artifact is byte-identical on repeated runs under identical inputs. +#### 5.1.1 The clean-room criterion, and its removal + +The criterion above required a developer who did not implement the feature to complete emit, +check, and verify using only the published documentation. It was removed on 2026-08-30 by decider +decision, permanently rather than waived for this release. + +Recorded plainly, because removing a success criterion is not the same as meeting it. What the +criterion protected was discoverability: whether a stranger can follow +[`writing-a-mapper.md`](writing-a-mapper.md) without knowledge that exists only in the author's +head. Nothing else in the release evidence covers that. + +What remains evidenced is narrower and should not be read as a substitute: + +- [`v0-6-0-clean-room-walkthrough.md`](validation/v0-6-0-clean-room-walkthrough.md) establishes + that the documented procedure is complete and executable end to end against a parser shape it + was not written for. Its author wrote the guide, so it cannot detect knowledge that is on neither + the page nor the reader's side. +- The JavaScript, Python, and DocuShell mappers establish the *capability* claim — that any parser + can reach the verifier through one mapper, with no Rust and no PDFium. + +The distinction matters and the wording approval already drew it: capability is evidenced, +documentation quality is not. v0.6.0 therefore ships the Grounding JSON path with its capability +proven and its discoverability unproven. If integrators report that the guide is unclear, that is +the untested surface, and the revision clause in +[`v0-6-0-public-wording-request.md`](validation/v0-6-0-public-wording-request.md) is the route to +fix it. + +Removing the criterion does not narrow what the release claims. It removes a check, not a promise. + ### 5.2 Included scope - One `ethos.grounding.v1` JSON Schema and built-in CLI adapter. diff --git a/docs/validation/README.md b/docs/validation/README.md index f44dc5ba..335e2008 100644 --- a/docs/validation/README.md +++ b/docs/validation/README.md @@ -7,3 +7,11 @@ summary. Completed per-lane historical validation records were retired. - [v0.4.0 release closeout summary](v0-4-0-release-closeout-summary.md) - [v0.5.0 release closeout summary](v0-5-0-release-closeout-summary.md) - [NIP-5.2 ethos-full build evidence](nip-5-2-ethos-full-build-evidence-2026-07-20.md) +- [v0.6.0 clean-room mapper walkthrough](v0-6-0-clean-room-walkthrough.md) +- [v0.6.0 DocuShell consumer acceptance](v0-6-0-docushell-acceptance.md) +- [v0.6.0 double-run determinism evidence](v0-6-0-double-run-determinism.md) +- [v0.6.0 public wording request](v0-6-0-public-wording-request.md) +- [v0.6.0 release promotion record](v0-6-0-release-promotion.md) +- [v0.6.0 validator resource baseline](v0-6-0-validator-resource-baseline.md) +- [v0.6.0 WP-0 mapping feasibility](v0-6-0-wp-0-mapping-feasibility.md) +- [v0.6.0 WP-0 public-posture request](v0-6-0-wp-0-public-posture-request.md) diff --git a/docs/validation/v0-6-0-clean-room-walkthrough.md b/docs/validation/v0-6-0-clean-room-walkthrough.md index 6e0460b6..d157e84f 100644 --- a/docs/validation/v0-6-0-clean-room-walkthrough.md +++ b/docs/validation/v0-6-0-clean-room-walkthrough.md @@ -1,6 +1,10 @@ # v0.6.0 Clean-Room Mapper Walkthrough -Status: **procedure validated; independent-developer gate still outstanding** (2026-07-30). +Status: **procedure validated** (2026-07-30). The independent-developer gate this record was +written against was **removed by decider decision on 2026-08-30**, so it is no longer outstanding +— it no longer exists. See release-prep §5.1.1. The honest limitation below is unchanged and is +the reason that removal is worth reading: this record does not establish discoverability, and +after the removal nothing else does either. ## What this records diff --git a/docs/validation/v0-6-0-public-wording-request.md b/docs/validation/v0-6-0-public-wording-request.md index cdc9a10e..695d7462 100644 --- a/docs/validation/v0-6-0-public-wording-request.md +++ b/docs/validation/v0-6-0-public-wording-request.md @@ -28,8 +28,11 @@ quality**: whether a stranger can follow the guide. A poor walkthrough result means the guide needs work. It does not make the capability claim false. -The walkthrough still gates **publication**, because release-prep §5.1 makes an undocumented step a -release blocker. It does not gate this approval. +The walkthrough gated **publication** when this was written, because release-prep §5.1 made an +undocumented step a release blocker. That criterion was removed by decider decision on 2026-08-30 +(release-prep §5.1.1), so the walkthrough now gates nothing. This approval was never contingent on +it, so the approval below stands exactly as written; what changed is that the publication gate +behind it is gone. Release-prep §12 requires that exact public wording be separately approved. This document is the request. It proposes the minimum wording change v0.6.0 needs, and states plainly what it does not @@ -121,11 +124,12 @@ what makes the claim credible: | --- | --- | | Any language can reach the verifier | JavaScript and Python mappers, byte-identical output | | No PDFium needed | Full path exercised on a host with no usable PDFium | -| A stranger can write a mapper | **Pending** — outsider walkthrough | -| A real consumer uses public surfaces only | DocuShell `cc652ec` | +| A stranger can write a mapper | **Not evidenced.** The gate requiring it was removed 2026-08-30; see release-prep §5.1.1 | +| A real consumer uses public surfaces only | DocuShell `docushell@cc652ec` | -The third row is documentation quality, not capability. It gates publication under §5.1 and feeds -the revision clause above; it does not gate this approval. +The third row is documentation quality, not capability. It gated publication under §5.1 until that +criterion was removed on 2026-08-30; it still feeds the revision clause above, and it never gated +this approval. ## Decision diff --git a/docs/validation/v0-6-0-release-promotion.md b/docs/validation/v0-6-0-release-promotion.md new file mode 100644 index 00000000..6940fc71 --- /dev/null +++ b/docs/validation/v0-6-0-release-promotion.md @@ -0,0 +1,76 @@ +# v0.6.0 Release Promotion Record + +Status: **operator named; artifact bindings outstanding** (2026-08-30). + +Discharges the promotion gate in +[`RELEASE_OPERATOR_RUNBOOK.md`](../RELEASE_OPERATOR_RUNBOOK.md) §"Promotion Gate". Until this +record exists and its bindings are filled, the runbook holds that everything `release.yml` +produces is draft evidence, because repository write access alone is not release authority. + +## Operator + +Release operator for v0.6.0: **docushell-dev **. + +Authority: decider of record on every v0.6.0 approval in `docs/validation/`, and sole committer +on the release lane. Named on 2026-08-30. + +This names the operator only. It does not widen any approved boundary, and it does not authorize +hosted surfaces, Windows packaged artifacts, bundled project-maintained PDFium builds, public +benchmark reports, `ethos-doc`, or `ethos-rag`, all of which remain in `blocked_lanes`. + +## Promotion bindings + +The runbook requires six bindings. Four can be recorded now. Two cannot exist until a green +release run produces real artifacts, and are deliberately left blank rather than estimated. + +| Binding | Value | +| --- | --- | +| Exact source commit | pending — the commit of the green `release.yml` run | +| Artifact names and platform targets | pending — `ethos-macos-arm64.tar.gz`, `ethos-linux-x64.tar.gz` and their sidecars, on a run that has not yet succeeded | +| SHA256 checksums | pending — from that run's `.sha256` sidecars, never from a local build | +| License/NOTICE bundle | `LICENSE` and `NOTICE`, copied into each archive by `release.yml` | +| PDFium posture | caller-provided via `ETHOS_PDFIUM_LIBRARY_PATH`, per ADR-0013. No bundled PDFium in the base archives | +| Exact public wording | approved in [`v0-6-0-public-wording-request.md`](v0-6-0-public-wording-request.md), applied at publication only | + +**The three pending rows are the gate.** `release.yml` has never completed a run. Filling them +from anything other than that run — a local build, a previous release, an estimate — would +manufacture the evidence this record exists to bind, so they stay blank until the run is green. + +## Consumer acceptance + +Release-prep §9.4 is discharged by +[`v0-6-0-docushell-acceptance.md`](v0-6-0-docushell-acceptance.md), bound to DocuShell +`docushell@cc652ec`, reviewed and fast-forward merged so the reviewed SHA and the SHA on +`main` are identical. + +## Clean-room criterion + +Release-prep §5.1 required a developer who did not implement the feature to complete emit, check, +and verify using only the published documentation. **That criterion was removed by decider +decision on 2026-08-30** (release-prep §5.1.1). It is recorded here so this record does not read +as having satisfied it. + +The capability claim — that any parser can reach the verifier through one mapper, with no Rust and +no PDFium — is evidenced by the JavaScript, Python, and DocuShell mappers. Discoverability, which +is what §5.1 protected, is not evidenced by anything and is not claimed. + +## Resource ceiling + +Release-prep §12's resource evidence is met by +[`v0-6-0-validator-resource-baseline.md`](v0-6-0-validator-resource-baseline.md), whose +peak-RSS ceiling was revised from 2 KB to 3 KB per element on 2026-08-30 by decider decision, +recorded there as a deliberate revision with the shape B measurements behind it. + +## What this record does not do + +- It does not mark v0.6.0 published. `docs/release-state.json` continues to report 0.5.0 as the + published baseline, and every install surface continues to name 0.5.0, until the packages are + actually live. +- It does not approve production positioning. The README states what Ethos is and where it ships, + with no lifecycle claim in either direction. +- It does not authorize publication on its own. The three pending bindings above must be filled + from a green release run first. + +## Source binding + +Recorded against Ethos `9e5ffd9119eea196b48fb271dd30e72068997cb4`. diff --git a/docs/validation/v0-6-0-validator-resource-baseline.md b/docs/validation/v0-6-0-validator-resource-baseline.md index d193a1d0..eb057cac 100644 --- a/docs/validation/v0-6-0-validator-resource-baseline.md +++ b/docs/validation/v0-6-0-validator-resource-baseline.md @@ -1,7 +1,8 @@ # v0.6.0 Grounding JSON Validator Resource Baseline -Status: **accepted** (2026-07-31). Ceiling set at 40 µs and 2 KB per element. Frozen structural -limits unchanged; the working set is documented rather than reduced. +Status: **accepted** (2026-07-31); **peak-RSS ceiling revised to 3 KB per element (2026-08-30)**. +Ceiling is 40 µs and 3 KB per element. Frozen structural limits unchanged; the working set is +documented rather than reduced. Release-prep §12 asks for resource and performance evidence showing no unacceptable regression against the frozen v0.5.0 verification baseline, with a numeric ceiling set before implementation @@ -105,16 +106,16 @@ Replaces the §12 v0.5.0 regression comparison with a bounded per-element resour validator. Measured values with roughly 1.5× headroom: - **40 µs per element wall clock**, release profile -- **2 KB per element peak RSS** +- **3 KB per element peak RSS** (revised 2026-08-30; originally 2 KB — see Revisions) -At the frozen 1,000,000-element limit that permits 40 s and 2 GB. +At the frozen 1,000,000-element limit that permits 40 s and 3 GB. **Wall clock: holds on both shapes.** 26.5 µs/element (shape A) and 13.0 µs/element (shape B), against the 40 µs ceiling. -**Peak RSS: holds on shape A, exceeded on shape B.** 1.29 KB/element against the 2 KB ceiling for -shape A; **2.66 KB/element for shape B, which is 33% over.** The ceiling was set from shape A -measurements and no spans-bearing artifact was measured before it was accepted. See Outstanding. +**Peak RSS: holds on both shapes against the revised ceiling.** 1.29 KB/element (shape A) and +2.66 KB/element (shape B), against the 3 KB ceiling. Shape B exceeded the original 2 KB figure by +33%, which is what forced the revision recorded below. Wall clock is enforced by two tests in `crates/ethos-core/src/grounding_json.rs` — `validator_stays_within_the_accepted_resource_ceiling` (shape A) and @@ -135,20 +136,39 @@ representative while staying fast enough to run on every PR. Peak RSS is not asserted in-process; measuring it portably would cost more than it proves. It is recorded here and re-measured on any change to the strict parser. -## Outstanding +## Revisions -**The 2 KB/element peak-RSS ceiling is exceeded by shape B (2.66 KB/element, 33% over).** The -ceiling was accepted on 2026-07-31 from shape A measurements only; no spans-bearing artifact was -measured before acceptance. The wall-clock half of the ceiling holds on both shapes. +**2026-08-30 — peak-RSS ceiling raised from 2 KB to 3 KB per element. Decided by the decider; +recorded as a deliberate revision, not absorbed.** -This needs a decision, not a silent re-baseline. The options are to raise the RSS ceiling to -roughly 3 KB/element with the shape B evidence above, or to treat 2 KB as binding and reduce the -working set, which means the streaming or two-pass validation already recorded as a v0.7.0 input. -Raising a ceiling to match what the code does is only legitimate when the number was never -measured against the relevant shape — which is the case here, but it should be recorded as a -deliberate revision rather than absorbed. +The 2 KB figure was accepted on 2026-07-31 from shape A measurements alone. No spans-bearing +artifact was measured before acceptance, so the number was never tested against the shape it now +governs. Shape B measures 2.66 KB/element — 33% over — and the wall-clock half held on both shapes +throughout. -Until that decision lands, `docs/writing-a-mapper.md` §9 publishes the shape B numbers so -integrators size workers against the larger figure. +The alternative was to treat 2 KB as binding and reduce the working set through streaming or +two-pass validation. That work stays recorded as a v0.7.0 input; it was not done here and this +revision does not claim it was. -§12's resource and performance evidence requirement is otherwise met. +Raising a ceiling to match what the code does is legitimate only when the number was never +measured against the relevant shape. That condition holds here and is the whole basis of the +decision. It does not license a future revision on any other grounds: a ceiling that was measured +against the shape it governs and is then exceeded is a regression, and the answer to a regression +is the code, not the number. + +3 KB carries roughly 1.13× headroom over shape B, tighter than the ~1.5× the original figures +used. That is deliberate. The measured value is now known rather than assumed, so the headroom +covers measurement noise rather than an unmeasured shape. + +`docs/writing-a-mapper.md` §9 already publishes the shape B numbers, so integrators have been +sizing workers against the larger figure since before this revision. + +§12's resource and performance evidence requirement is met. + +## Enforcement gap, recorded + +Wall clock is enforced in code. Peak RSS is not, and this revision does not change that: the +figure above is prose, re-measured by hand when the strict parser changes. `grounding_json.rs` +asserts `CEILING_MICROS_PER_ELEMENT` and nothing else, so a peak-RSS regression would not fail a +build. Measuring RSS portably in-process was judged to cost more than it proves; that judgement is +unchanged, but the consequence should be stated plainly rather than left to be discovered. From 7182ab5bffea18ea1e83e6b4e36948b8e1581bfa Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 20:01:08 +0530 Subject: [PATCH 07/12] ci(gates): drop the duplicated registry-surface target from the gates job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make registry-surface-check` failed in the new gates job with `Cannot find module 'json-schema-to-typescript'`: its `npm test` needs `npm ci --ignore-scripts` first, which the `test` job already runs at ci.yml:97-100 before running the same suite. Rather than install npm twice, run the one member covered nowhere else. registry-surface-check has five members and four are already in `make light-check` in the step above — claims_gate.py, public_boundary_claims_gate.py, test_package_registry_source_consistency.py — or in the `test` job — the npm package suite. Only test_claims_gate_registry_surfaces.py was unique to it. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af9c46f9..012818e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,8 +56,11 @@ jobs: fetch-depth: 0 - name: claims, posture, ledger, boundary paths, golden rationale, validation records run: make light-check - - name: registry surfaces and the npm package suite - run: make registry-surface-check + - name: claims-gate registry surface coverage + # Not `make registry-surface-check`: four of its five members are already in + # light-check above, and its `npm test` needs `npm ci` and is already run by the + # `test` job. This is the one member covered nowhere else. + run: python3 .github/scripts/test_claims_gate_registry_surfaces.py - name: gate unit tests # These three test the gate scripts themselves and were referenced by no workflow and # no Makefile target, so they had never run anywhere. From 6c277e19e9653eb25e8dd2a6132a1c8981791f85 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 22:17:55 +0530 Subject: [PATCH 08/12] fix(fixtures): the two failures the skipping determinism gate was hiding Enabling the PDF determinism step surfaced two fixture failures immediately. Neither is caused by this branch, which touches no Rust. Both had been failing since long before it. Regenerates the rotation-90 goldens. They were written 2026-06-16 and hold text coordinates with page rotation not applied. c7d893d applied it and never regenerated them, so every box in the goldens is the pre-fix transpose. Checked as a stale golden rather than a regression before touching it. The fixture's MediaBox is [0 0 144 300] with /Rotate 90, so the display box is 300x144 and `pages` reports exactly that, which is what PageSpace's own comment says Ethos artifacts share. The transform reproduces from the content stream: `36 72 Td (Rotate Ninety) Tj` at 18pt gives y_disp = x_user starting 37.42 for a baseline at x=36, and x_disp = y_user spanning 71.80-84.89 for a baseline at y=72. Recorded but deliberately not fixed here: that text is ~109pt wide from x=36, so it reaches x~144.88 and overflows its own 144pt MediaBox. Legal PDF. Pre-rotation the overflow sat on the x axis against a 300pt width and was invisible; it now sits on y against a 144pt height. So Ethos can emit, from a legal PDF, geometry grounding_json.rs rejects as invalid_bbox, "submit a positive bounding box within its page". That predates this release and is not narrowed by it, but it is a real seam between the parser and the validator and should not stay undocumented. Teaches the harness a per-fixture `env` block. failure-memory-limit-simulated expects memory_limit_exceeded, reachable only through the debug-build hook at worker.rs:846 behind ETHOS_INTERNAL_TEST_PDFIUM_WORKER_MEMORY_LIMIT. run_fixtures.py never set it, so the fixture declared an expected_error the harness could not induce and failed permanently while saying nothing about the product. Declared in fixture.json rather than special-cased in the harness. Corpus is 14/14, run_fixtures.py exits 0, validate_fixtures.py green, and all 25 pdf_parse tests pass against the pinned PDFium. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 32 +++++++++++++++++++ benchmarks/harness/run_fixtures.py | 16 +++++++++- .../memory-limit-simulated/fixture.json | 3 ++ .../synthetic/rotation-90/extraction.json | 2 +- fixtures/synthetic/rotation-90/layout.json | 2 +- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4209d5..e96292f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,38 @@ per-release edit. No lifecycle adjective is claimed in either direction, so the approval records that withheld production positioning stay true as written. +### The two fixtures the PDF determinism gate was hiding + +- golden-change: `fixtures/synthetic/rotation-90/extraction.json` and `layout.json` are + regenerated. The goldens were written 2026-06-16 and record text coordinates with page rotation + **not** applied. `c7d893d` applied it — "apply page rotation when mapping PDFium text + coordinates" — and the goldens were never regenerated, so every box in them is the pre-fix + transpose. Nothing noticed, because the only check that compares them is the step that had been + skipping. + + Verified as a stale golden rather than a regression before regenerating. The fixture's MediaBox + is `[0 0 144 300]` with `/Rotate 90`, so the display box is 300×144 and `pages` reports exactly + that, rotation applied, as `PageSpace`'s contract requires. The transform checks out against the + content stream: `36 72 Td (Rotate Ninety) Tj` at 18pt gives `y_disp = x_user` starting at 37.42 + for a baseline at x=36, and `x_disp = y_user` spanning 71.80-84.89 for a baseline at y=72. + + One observation worth recording rather than fixing here: that text is about 109pt wide from + x=36, so it runs to x≈144.88 and overflows its own 144pt-wide MediaBox. Legal PDF. Before the + rotation fix the overflow sat on the x axis against a 300pt width and was invisible; it now sits + on the y axis against a 144pt height. So Ethos can emit, from a legal PDF, geometry that + `grounding_json.rs` would reject as `invalid_bbox` — "submit a positive bounding box within its + page". That tension predates this release and is not narrowed by it. + +- `benchmarks/harness/run_fixtures.py` honours a per-fixture `env` block, and + `fixtures/failure/memory-limit-simulated/fixture.json` declares the one it needs. That fixture + expects `memory_limit_exceeded`, which is reachable only through the debug-build hook at + `worker.rs:846` behind `ETHOS_INTERNAL_TEST_PDFIUM_WORKER_MEMORY_LIMIT`. The harness never set + it, so the fixture declared an `expected_error` the harness could not induce and failed + permanently while saying nothing about the product. Data-driven rather than special-cased, so + the next such fixture declares its own hook. + + With both fixed the corpus is 14/14 and `run_fixtures.py` exits 0. + - `docs/validation/README.md` now indexes all twelve records rather than four, and the DocuShell acceptance commit is cited as `docushell@cc652ec` rather than a bare hex ref that cannot resolve in this repository. Both were surfaced by `validation_record_integrity.py` on its first run diff --git a/benchmarks/harness/run_fixtures.py b/benchmarks/harness/run_fixtures.py index 8d680642..3f1353b7 100644 --- a/benchmarks/harness/run_fixtures.py +++ b/benchmarks/harness/run_fixtures.py @@ -105,7 +105,19 @@ def check_equal(name: str, actual: Any, expected: Any, failures: list[str]) -> N failures.append(f"{name} does not match golden") -def run_command(args: list[str], timeout_sec: float) -> tuple[subprocess.CompletedProcess[bytes] | None, float, str | None]: +def run_command( + args: list[str], + timeout_sec: float, + env_overlay: dict[str, str] | None = None, +) -> tuple[subprocess.CompletedProcess[bytes] | None, float, str | None]: + # Some failure fixtures describe an error only reachable through a debug-build hook — + # a simulated worker memory limit, for one. Without a way to set that variable the + # fixture declares an `expected_error` the harness can never induce, so it fails + # permanently and says nothing about the product. The fixture declares its own env. + env = None + if env_overlay: + env = dict(os.environ) + env.update(env_overlay) start = time.perf_counter() try: completed = subprocess.run( @@ -114,6 +126,7 @@ def run_command(args: list[str], timeout_sec: float) -> tuple[subprocess.Complet stderr=subprocess.PIPE, timeout=timeout_sec, check=False, + env=env, ) return completed, (time.perf_counter() - start) * 1000.0, None except subprocess.TimeoutExpired: @@ -205,6 +218,7 @@ def failure_fixture_result( completed, duration_ms, timeout = run_command( [str(ethos_bin), "doc", "parse", str(fixture_file), "--format", "json"], timeout_sec, + metadata.get("env"), ) envelope: dict[str, Any] | None = None if timeout is not None: diff --git a/fixtures/failure/memory-limit-simulated/fixture.json b/fixtures/failure/memory-limit-simulated/fixture.json index e3cc064e..603f3a9b 100644 --- a/fixtures/failure/memory-limit-simulated/fixture.json +++ b/fixtures/failure/memory-limit-simulated/fixture.json @@ -9,6 +9,9 @@ "worker memory-limit stable error-envelope relay", "memory_limit_exceeded exit code" ], + "env": { + "ETHOS_INTERNAL_TEST_PDFIUM_WORKER_MEMORY_LIMIT": "1" + }, "expected_error": { "code": "memory_limit_exceeded", "message": "parse exceeded memory limit" diff --git a/fixtures/synthetic/rotation-90/extraction.json b/fixtures/synthetic/rotation-90/extraction.json index 516f2251..5ff33fa8 100644 --- a/fixtures/synthetic/rotation-90/extraction.json +++ b/fixtures/synthetic/rotation-90/extraction.json @@ -1 +1 @@ -{"pages":[{"height":14400,"id":"p0001","index":1,"rotation":90,"width":30000}],"regions":[],"spans":[{"bbox":[3742,5911,8829,7220],"font_id":"subst:liberation-sans-regular","font_size_q":1800,"id":"s000001","origin_locator":{"first_origin":[3600,7200],"last_origin":[7902,7200],"policy":"origin-run-locator-v1"},"page":"p0001","text":"Rotate"},{"bbox":[9540,5911,14488,7576],"font_id":"subst:liberation-sans-regular","font_size_q":1800,"id":"s000002","origin_locator":{"first_origin":[9403,7200],"last_origin":[13604,7200],"policy":"origin-run-locator-v1"},"page":"p0001","text":"Ninety"}],"warnings":[]} +{"pages":[{"height":14400,"id":"p0001","index":1,"rotation":90,"width":30000}],"regions":[],"spans":[{"bbox":[7180,3742,8489,8829],"font_id":"subst:liberation-sans-regular","font_size_q":1800,"id":"s000001","origin_locator":{"first_origin":[7200,3600],"last_origin":[7200,7902],"policy":"origin-run-locator-v1"},"page":"p0001","text":"Rotate"},{"bbox":[6824,9540,8489,14488],"font_id":"subst:liberation-sans-regular","font_size_q":1800,"id":"s000002","origin_locator":{"first_origin":[7200,9403],"last_origin":[7200,13604],"policy":"origin-run-locator-v1"},"page":"p0001","text":"Ninety"}],"warnings":[]} diff --git a/fixtures/synthetic/rotation-90/layout.json b/fixtures/synthetic/rotation-90/layout.json index 1385c4f4..048c2b88 100644 --- a/fixtures/synthetic/rotation-90/layout.json +++ b/fixtures/synthetic/rotation-90/layout.json @@ -1 +1 @@ -{"elements":[{"bbox":[3742,5911,14488,7576],"id":"e000001","page":"p0001","span_refs":["s000001","s000002"],"text":"Rotate Ninety","type":"text_block"}],"warnings":[]} +{"elements":[{"bbox":[6824,3742,8489,14488],"id":"e000001","page":"p0001","span_refs":["s000001","s000002"],"text":"Rotate Ninety","type":"text_block"}],"warnings":[]} From af29aabff90cdb029ec7fa63c07d89497506e1ed Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 22:23:04 +0530 Subject: [PATCH 09/12] ci(determinism): report which fixture failed instead of exiting silently run_fixtures.py records failures in its JSON and prints nothing to stdout or stderr, so the determinism step failed on ubuntu-latest with no indication of which of the fourteen fixtures broke or why. A gate that fails without saying what failed is only marginally better than the skipping one it replaced. Prints the status and every failing fixture with its reasons before propagating the exit code. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/determinism.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/determinism.yml b/.github/workflows/determinism.yml index c9550a05..d26288ce 100644 --- a/.github/workflows/determinism.yml +++ b/.github/workflows/determinism.yml @@ -70,10 +70,28 @@ jobs: if [ -n "${ETHOS_PDFIUM_LIBRARY_PATH:-}" ] && [ -f "${ETHOS_PDFIUM_LIBRARY_PATH:-}" ]; then ethos_bin="${GITHUB_WORKSPACE}/target/debug/ethos" if [ "${RUNNER_OS}" = "Windows" ]; then ethos_bin="${ethos_bin}.exe"; fi + # run_fixtures.py records failures in its JSON and prints nothing, so a red + # step gave no clue which fixture broke. Report them before exiting. + set +e python benchmarks/harness/run_fixtures.py \ --repo-root "${GITHUB_WORKSPACE}" \ --ethos-bin "${ethos_bin}" \ --out "${RUNNER_TEMP}/fixture-baseline.json" + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + python - "${RUNNER_TEMP}/fixture-baseline.json" <<'PY' + import json, sys + d = json.load(open(sys.argv[1])) + print("status:", d.get("status")) + for f in d.get("fixtures", []): + if f.get("status") == "fail": + print(f"FAIL {f.get('id')}") + for reason in f.get("failures", []): + print(f" {reason}") + PY + exit "$rc" + fi cargo test --locked -p ethos-cli --test pdf_parse \ double_parse_is_byte_identical_when_pdfium_is_configured -- --exact --nocapture else From 567bf8b6f68baf48466458f230f62576dfd033ee Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 22:30:21 +0530 Subject: [PATCH 10/12] ci(determinism): emit and upload the actual projections on failure "extraction does not match golden" says nothing about what differs, which is exactly the information needed when the goldens match one platform and not another. The harness gains --emit-projections, and the workflow uploads them when the step fails. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/determinism.yml | 9 ++++++++- benchmarks/harness/run_fixtures.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/determinism.yml b/.github/workflows/determinism.yml index d26288ce..06db2bc1 100644 --- a/.github/workflows/determinism.yml +++ b/.github/workflows/determinism.yml @@ -76,7 +76,8 @@ jobs: python benchmarks/harness/run_fixtures.py \ --repo-root "${GITHUB_WORKSPACE}" \ --ethos-bin "${ethos_bin}" \ - --out "${RUNNER_TEMP}/fixture-baseline.json" + --out "${RUNNER_TEMP}/fixture-baseline.json" \ + --emit-projections "${RUNNER_TEMP}/projections" rc=$? set -e if [ "$rc" -ne 0 ]; then @@ -97,3 +98,9 @@ jobs: else echo "deferred: caller-provided PDFium runtime is not configured on this runner" fi + - name: upload actual projections + if: failure() && runner.os != 'Windows' + uses: actions/upload-artifact@v4 + with: + name: projections-${{ matrix.os }} + path: ${{ runner.temp }}/projections diff --git a/benchmarks/harness/run_fixtures.py b/benchmarks/harness/run_fixtures.py index 3f1353b7..3a515ca6 100644 --- a/benchmarks/harness/run_fixtures.py +++ b/benchmarks/harness/run_fixtures.py @@ -139,6 +139,7 @@ def success_fixture_result( entry: dict[str, Any], iterations: int, timeout_sec: float, + emit_projections: Path | None = None, ) -> dict[str, Any]: fixture_file = fixtures_root / entry["file"] fixture_dir = fixture_file.parent @@ -177,6 +178,16 @@ def success_fixture_result( if first_doc is not None: extraction, layout = c14n_projection_from_document(first_doc) + # "does not match golden" says nothing about what differs, which is useless when the + # goldens match one platform and not another. Write what this run actually produced so + # it can be diffed against the checked-in goldens off the runner. + if emit_projections is not None: + emit_projections.mkdir(parents=True, exist_ok=True) + for stage, value in (("extraction", extraction), ("layout", layout)): + text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + (emit_projections / f"{entry['id']}.{stage}.json").write_text( + text + "\n", encoding="utf-8" + ) check_equal("extraction", extraction, extraction_golden, failures) check_equal("layout", layout, layout_golden, failures) @@ -278,6 +289,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: entry, args.iterations, args.timeout_sec, + args.emit_projections, ) ) @@ -335,6 +347,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--ethos-bin", type=Path, default=ROOT / "target" / "release" / "ethos") parser.add_argument("--out", type=Path, default=DEFAULT_RESULTS) parser.add_argument("--stdout", action="store_true", help="write report to stdout instead of --out") + parser.add_argument( + "--emit-projections", + type=Path, + default=None, + help="write each fixture's actual c14n projection here, for diffing against the goldens", + ) parser.add_argument("--iterations", type=int, default=3) parser.add_argument("--timeout-sec", type=float, default=30.0) args = parser.parse_args(argv) From 89b3672ec7f08ec9f5b79be899679b534710aebe Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 22:39:49 +0530 Subject: [PATCH 11/12] fix(layout): reading order follows the baseline origin, not the bbox centre A release-blocking cross-platform divergence, found by the determinism gate the previous commits stopped from skipping. `span_line_order` sorted primarily on `center_y`, derived from `bbox`. docs/determinism-contract.md excludes `bbox` and `bboxes` from the stable payload projection precisely because "PDFium reports platform-sensitive rectangle dimensions for otherwise identical text", and designates `origin_locator` fingerprint-critical and stable. Ordering on the excluded field fed platform-sensitive geometry back into span_refs, element text, and element type, all of which are in the stable projection. The contract calls a stable-projection difference across supported platforms release-blocking, so this was one. Measured on the pinned chromium/7881 PDFium. synthetic-list-items produced "- Verify cited evidence" typed list_item on macOS arm64 and "Verify cited evidence -" typed text_block on Linux x64: the bullet's bbox centre sits 1 quantum below the word's on macOS and 23 above it on Linux. The baseline origins are byte-identical on both, [7200,7200] and [7933,7200]. Of eighteen projections, sixteen differed across platforms. Fifteen differed only in bbox/bboxes, which the contract permits, and origin_locator matched on all forty spans. This was the only real divergence, and it changed a document's meaning rather than its geometry. No goldens are rewritten: macOS projections are byte-identical before and after, because ordering by baseline agrees with what the bbox centre happened to produce there. Spans with no locator keep the bbox-centre fallback, which is every non-PDF source. Covered by reading_order_uses_the_origin_locator_not_the_bbox_centre, built from the real coordinates above so it fails if the ordering key regresses. Workspace tests pass except crop_element_cli_writes_rendered_artifacts_when_pdfium_is_configured, which fails identically with this change stashed. It is a separate PDFium-gated test that CI has never exercised, surfaced by the same act of configuring the runtime. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 25 ++++++++++++ crates/ethos-layout/src/lib.rs | 74 ++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e96292f5..b2a02954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,31 @@ With both fixed the corpus is 14/14 and `run_fixtures.py` exits 0. +### Reading order stops depending on platform-sensitive geometry + +- Layout ordering keys off the span's `origin_locator` baseline instead of its `bbox` centre. + `span_line_order` sorted primarily on `center_y`, derived from `bbox`. The determinism + contract excludes `bbox` and `bboxes` from the stable payload projection precisely because + "PDFium reports platform-sensitive rectangle dimensions for otherwise identical text", while + `origin_locator` is fingerprint-critical and stable. Ordering on the excluded field fed + platform-sensitive geometry straight back into `span_refs`, element `text`, and element + `type` — all of which are *in* the stable projection. + + Measured, not assumed. On the pinned chromium/7881 PDFium, `synthetic-list-items` produced + `"- Verify cited evidence"` typed `list_item` on macOS arm64 and `"Verify cited evidence -"` + typed `text_block` on Linux x64, because the bullet's bbox centre lands 1 quantum below the + word's on macOS and 23 above it on Linux. The baseline origins are byte-identical on both + platforms: `[7200, 7200]` and `[7933, 7200]`. Per the contract's own words a stable-projection + difference across supported platforms is a release-blocking bug, so this was one. + + Sixteen of eighteen projections differed across the two platforms; fifteen differed only in + `bbox`/`bboxes`, which the contract permits, and `origin_locator` was identical across all + forty spans. This was the one real divergence. + + The change rewrites no goldens: macOS projections are byte-identical before and after, because + ordering by baseline agrees with what the bbox centre happened to produce there. Spans without + a locator — every non-PDF source — keep the bbox-centre fallback. + - `docs/validation/README.md` now indexes all twelve records rather than four, and the DocuShell acceptance commit is cited as `docushell@cc652ec` rather than a bare hex ref that cannot resolve in this repository. Both were surfaced by `validation_record_integrity.py` on its first run diff --git a/crates/ethos-layout/src/lib.rs b/crates/ethos-layout/src/lib.rs index 40c1d2a2..a4007bb5 100644 --- a/crates/ethos-layout/src/lib.rs +++ b/crates/ethos-layout/src/lib.rs @@ -59,6 +59,7 @@ impl LayoutEngine for BasicLayoutEngine { index, span, center_y: center_y(span.bbox), + order_key: span_order_key(span), }) }) .collect(); @@ -89,6 +90,17 @@ struct SpanRef<'a> { index: usize, span: &'a Span, center_y: i64, + /// Platform-stable ordering key: `(baseline y, baseline x)`. + /// + /// Reading order must not be decided by `bbox`. The determinism contract excludes `bbox` + /// and `bboxes` from the stable payload projection precisely because "PDFium reports + /// platform-sensitive rectangle dimensions for otherwise identical text", while + /// `origin_locator` is fingerprint-critical and stable. Ordering on a bbox-derived + /// `center_y` fed that platform-sensitive value straight back into span order, element + /// text, and element type — all of which *are* in the stable projection. + /// + /// Falls back to the bbox centre for spans with no locator, which is every non-PDF source. + order_key: (i64, i64), } struct Line<'a> { @@ -602,9 +614,19 @@ fn build_element( }) } +/// `(baseline y, baseline x)` from the span's origin locator, or the bbox centre when it has +/// none. See `SpanRef::order_key`. +fn span_order_key(span: &Span) -> (i64, i64) { + match span.origin_locator.as_ref() { + Some(locator) => (locator.first_origin[1], locator.first_origin[0]), + None => (center_y(span.bbox), span.bbox.x0), + } +} + fn span_line_order(a: &SpanRef<'_>, b: &SpanRef<'_>) -> std::cmp::Ordering { - a.center_y - .cmp(&b.center_y) + a.order_key + .cmp(&b.order_key) + .then_with(|| a.center_y.cmp(&b.center_y)) .then_with(|| a.span.bbox.y0.cmp(&b.span.bbox.y0)) .then_with(|| a.span.bbox.x0.cmp(&b.span.bbox.x0)) .then_with(|| a.index.cmp(&b.index)) @@ -671,7 +693,7 @@ fn union_rect(a: QRect, b: QRect) -> QRect { #[cfg(test)] mod tests { use super::*; - use ethos_core::model::{Page, Region, Warning}; + use ethos_core::model::{Page, Region, SpanOriginLocator, Warning}; fn span(id: &str, page: &str, bbox: QRect, text: &str) -> Span { styled_span(id, page, bbox, text, None, Some(1200)) @@ -714,6 +736,52 @@ mod tests { } } + fn origin_span(id: &str, bbox: QRect, text: &str, origin: [i64; 2]) -> Span { + let mut span = span(id, "p0001", bbox, text); + span.origin_locator = Some(SpanOriginLocator { + policy: "origin-run-locator-v1".to_string(), + first_origin: origin, + last_origin: origin, + }); + span + } + + /// Reading order follows the baseline origin, not the bbox centre. + /// + /// The bullet's bbox centre here sits *below* the word's, so ordering on `center_y` puts + /// the word first; the baselines are identical, so ordering on the origin locator puts the + /// bullet first, by x. This is not hypothetical: PDFium returns rectangle dimensions that + /// differ by fractions of a point between the macOS and Linux builds of one pinned + /// version, which flipped exactly this comparison and changed `span_refs`, element `text`, + /// and element `type` across platforms — all inside the stable payload projection that the + /// determinism contract guarantees, and which excludes `bbox` for this very reason. + #[test] + fn reading_order_uses_the_origin_locator_not_the_bbox_centre() { + let extraction = extraction(vec![ + origin_span( + "s000001", + QRect::new(7_238, 6_836, 7_562, 6_942).unwrap(), + "-", + [7_200, 7_200], + ), + origin_span( + "s000002", + QRect::new(7_938, 6_326, 10_990, 7_451).unwrap(), + "Verify", + [7_933, 7_200], + ), + ]); + + let output = BasicLayoutEngine.layout(&extraction).unwrap(); + + assert_eq!(output.elements.len(), 1); + assert_eq!(output.elements[0].text.as_deref(), Some("- Verify")); + assert_eq!( + output.elements[0].span_refs, + vec!["s000001".to_string(), "s000002".to_string()] + ); + } + #[test] fn groups_nearby_lines_into_paragraphs() { let extraction = extraction(vec![ From 5ecd3ed95349b128912abd13feb17d0e12655b4e Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 30 Aug 2026 22:45:16 +0530 Subject: [PATCH 12/12] ci(determinism): compare what the contract guarantees off the golden platform The determinism step compared full projections including bbox on every platform. The checked-in goldens were generated on macOS arm64, and docs/determinism-contract.md computes the stable payload projection "after recursively excluding object keys named `bbox` and `bboxes`", because "PDFium reports platform-sensitive rectangle dimensions for otherwise identical text". Comparing bbox off the golden platform asserts more than Ethos promises, and fails for a reason the contract explicitly sanctions. Measured: of eighteen projections, fifteen differed across macOS arm64 and Linux x64 in bbox and nothing else. The sixteenth was the reading-order bug fixed in the previous commit, which changed the stable projection and was a real defect. macOS keeps the full comparison, so bbox regressions are still caught on the platform where the goldens are authoritative. Other platforms compare the stable projection, which is the guarantee. Both modes exit 0 locally. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/workflows/determinism.yml | 13 +++++++++- benchmarks/harness/run_fixtures.py | 39 +++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/determinism.yml b/.github/workflows/determinism.yml index 06db2bc1..f457706a 100644 --- a/.github/workflows/determinism.yml +++ b/.github/workflows/determinism.yml @@ -72,12 +72,23 @@ jobs: if [ "${RUNNER_OS}" = "Windows" ]; then ethos_bin="${ethos_bin}.exe"; fi # run_fixtures.py records failures in its JSON and prints nothing, so a red # step gave no clue which fixture broke. Report them before exiting. + # The checked-in goldens were generated on macOS arm64 and carry bbox values. + # docs/determinism-contract.md computes the stable payload projection "after + # recursively excluding object keys named bbox and bboxes", because PDFium reports + # platform-sensitive rectangle dimensions for otherwise identical text. Comparing + # bbox off the golden platform therefore asserts more than the contract guarantees. + # macOS compares everything; the other platforms compare what is actually promised. + compare_scope="" + if [ "${RUNNER_OS}" != "macOS" ]; then + compare_scope="--stable-only" + fi set +e python benchmarks/harness/run_fixtures.py \ --repo-root "${GITHUB_WORKSPACE}" \ --ethos-bin "${ethos_bin}" \ --out "${RUNNER_TEMP}/fixture-baseline.json" \ - --emit-projections "${RUNNER_TEMP}/projections" + --emit-projections "${RUNNER_TEMP}/projections" \ + ${compare_scope} rc=$? set -e if [ "$rc" -ne 0 ]; then diff --git a/benchmarks/harness/run_fixtures.py b/benchmarks/harness/run_fixtures.py index 3a515ca6..e98f1c3a 100644 --- a/benchmarks/harness/run_fixtures.py +++ b/benchmarks/harness/run_fixtures.py @@ -100,9 +100,29 @@ def c14n_projection_from_document(doc: dict[str, Any]) -> tuple[dict[str, Any], return extraction, layout -def check_equal(name: str, actual: Any, expected: Any, failures: list[str]) -> None: +def strip_unstable_geometry(value: Any) -> Any: + """Drop `bbox`/`bboxes`, recursively — the stable payload projection of the contract. + + docs/determinism-contract.md computes the stable payload projection "after recursively + excluding object keys named `bbox` and `bboxes`", because "PDFium reports platform-sensitive + rectangle dimensions for otherwise identical text". The guarantee is over that projection. + """ + if isinstance(value, dict): + return {k: strip_unstable_geometry(v) for k, v in value.items() if k not in ("bbox", "bboxes")} + if isinstance(value, list): + return [strip_unstable_geometry(v) for v in value] + return value + + +def check_equal( + name: str, actual: Any, expected: Any, failures: list[str], stable_only: bool = False +) -> None: + if stable_only: + actual = strip_unstable_geometry(actual) + expected = strip_unstable_geometry(expected) if actual != expected: - failures.append(f"{name} does not match golden") + suffix = " (stable projection)" if stable_only else "" + failures.append(f"{name} does not match golden{suffix}") def run_command( @@ -140,6 +160,7 @@ def success_fixture_result( iterations: int, timeout_sec: float, emit_projections: Path | None = None, + stable_only: bool = False, ) -> dict[str, Any]: fixture_file = fixtures_root / entry["file"] fixture_dir = fixture_file.parent @@ -188,8 +209,8 @@ def success_fixture_result( (emit_projections / f"{entry['id']}.{stage}.json").write_text( text + "\n", encoding="utf-8" ) - check_equal("extraction", extraction, extraction_golden, failures) - check_equal("layout", layout, layout_golden, failures) + check_equal("extraction", extraction, extraction_golden, failures, stable_only) + check_equal("layout", layout, layout_golden, failures, stable_only) pages = len(first_doc["payload"]["pages"]) if first_doc is not None else entry["pages"] p50 = percentile(durations, 0.50) @@ -290,6 +311,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: args.iterations, args.timeout_sec, args.emit_projections, + args.stable_only, ) ) @@ -353,6 +375,15 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=None, help="write each fixture's actual c14n projection here, for diffing against the goldens", ) + parser.add_argument( + "--stable-only", + action="store_true", + help=( + "compare the contract's stable payload projection, excluding bbox/bboxes. " + "Required off the platform the goldens were generated on, because bbox is " + "platform-sensitive by the contract's own statement." + ), + ) parser.add_argument("--iterations", type=int, default=3) parser.add_argument("--timeout-sec", type=float, default=30.0) args = parser.parse_args(argv)