Skip to content

T055 follow-up: confine to the resolved entry, no second lookup (default_registry.resolve_source) - #70

Merged
brettheap merged 2 commits into
mainfrom
build/034-p2r-t055-followup-confine-to-entry
Sep 30, 2026
Merged

brettheap merged 2 commits into
mainfrom
build/034-p2r-t055-followup-confine-to-entry

Conversation

@brettheap

@brettheap brettheap commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What this is

A small follow-up to T055 (#59, landed as fa140875). It takes the one finding Copilot's review of #59 at 0c946f4e raised as "previously missed", after the last push that could take it.

The finding, quoted

Copilot review overview on #59 at 0c946f4e (review 5368674478), medium, "Avoid unstable second lookup during source path confinement", src/opendox/default_registry.py:444:

resolve_source() performs a second lookup by (repository, ref) after the caller has already resolved an entry. A concurrent refresh can replace that key between the two operations, so the returned path can be confined under a different entry's source_root than the entry whose metadata/listed paths the caller is using (for example, serve_project._resolved_listed_edit_entry). Expose an entry-based confinement operation or make the caller pass the resolved entry so lookup, validation, and path confinement use one stable entry.

It is real. #59 already closed the same pattern in serve.py's /source arm (Copilot r4136585695 and r4136863569): that arm resolves the entry once and confines to that entry's own root with resolve_source_path(Path(root), rest). serve_project._resolved_listed_edit_entry was the one caller left.

What changed

  • src/opendox/serve_project.py: _resolved_listed_edit_entry resolves the entry ONCE and confines the path to THAT entry's own root through the registry seam's declared resolve_within (read as projection_seams.registry.current() inside the function). The listed-path check already read the entry in hand, and the editor is launched over entry.source_root, so the lookup, the validation and the confinement are now one entry. An entry with no root serves nothing, as before.
  • The caller needs no method the seam does not declare. resolve_source is on no seam's list (REGISTRY_CALLABLES), so a contributed registry is never asked for one. That is why the fix confines the entry in hand rather than adding an entry-based method to openDox's own registry: a host's registry would not carry it.
  • src/opendox/default_registry.py: SnapshotRegistry.resolve_source keeps its behaviour (one lookup, confined to that entry). Its docstring now says it is for a caller that holds only a pair, and why a caller that already holds an entry must not ask again by its pair.
  • tests/test_edit_action_one_entry.py (new, 8 cases).

No module-level proxy is bound in serve_project.py: tests/test_projection_seams.py::test_no_proxy_over_a_seam_is_read_at_import_time pins the exact set of modules that bind one (serve.py, serve_workbench.py), and this PR does not edit that file.

Every caller of the two-step path

git grep resolve_source -- src at fa140875 finds the definition and exactly one production caller, serve_project.py:111. The other registry lookups in src/ are one resolution each: serve.py _serve_snapshot and _serve_source (already one entry), serve_workbench.py (resolve then resolve_within(entry.source_root, ...), three sites), and branch_session.py (stamping an entry after a register, no confinement). _keyed_source's registry.get(*parsed) is a parse-time existence check whose result is a key, and _serve_source then resolves that key once.

The new test test_no_module_asks_a_registry_for_a_path_by_a_pair holds that set empty, so a future caller has to be argued for.

Evidence

Red at main, green after. The new module against fa140875's sources (serve_project.py and default_registry.py as on main):

FAILED test_the_edit_arm_asks_the_registry_once_and_never_for_a_path_by_a_pair
FAILED test_no_module_asks_a_registry_for_a_path_by_a_pair
FAILED test_a_host_registry_needs_only_what_the_seam_declares
FAILED test_a_refresh_that_replaces_the_key_cannot_take_the_file_the_route_refuses
FAILED test_a_refresh_that_replaces_the_key_cannot_take_the_file_the_route_accepts
5 failed, 3 passed

With this PR: 8 passed. The three that pass at main are the control (a listed file opens with no refresh), confinement kept, and "no root serves nothing", which pin what must NOT change.

The race is tested at the ROUTE, both ways, with a real server, a real POST /actions/edit and the console token. A registry whose key is replaced right after the route's first resolve (as a refresh on another thread would) lands the replacement between the resolution and the confinement (asserted):

  • Entry's root has NO file, the replacement's root has it. At main the route took the file from the replacement's root, read the first entry's listing, and started the editor over the first entry's root: 200 and an editor over a file that is not there. Now: 404 document_unavailable, no editor.
  • Entry's root has the file, the replacement's root has none. At main the route refused a file its own entry holds (404). Now: 200, and the editor is started over that entry's root.

Mutants of the fix, all killed (the new module only, serve_project.py restored after each):

mutant killed by
M1 the second lookup again (registry.resolve_source(repository, ref, path), i.e. main) one-lookup count, the no-module-asks scan, the host registry, both race cases
M2 confine by Path(root) / path, no containment rule test_the_entrys_own_root_still_confines_what_the_route_accepts
M3 the no-root check dropped host-registry and no-root cases
M4 the listed-path check dropped the confinement case (an unlisted file inside the root)
M5 the listing read through a second lookup the one-lookup count (the race cases cannot see it, as both entries share a snapshot)

T056's module against this fix. Fetched #66's head 38761c76 read-only into a scratch worktree, merged main (fa140875; the two add/add conflicts, default_registry.py and tests/test_projection_seams.py, resolved to main's blobs, as T056's own diff does not touch either), cherry-picked this commit on top, and ran tests/test_standalone_generate_path.py (its children run from that worktree's src via PYTHONPATH):

tests/test_standalone_generate_path.py + tests/test_edit_action_one_entry.py: 14 passed
control, this commit reverted: tests/test_standalone_generate_path.py: 6 passed

That module's requests are GET /source/notes-toolshed-inventory.md (200, byte-equal) and GET /source/.git/config (404). They go through serve.py's /source arm (resolve_source_path), which #59 already moved off resolve_source, not through serve_project, so this PR leaves them as they were. The module passes identically with and without this commit.

Whole suite at 5666505b, LANG=C.UTF-8, run in the foreground with a throwaway postgres:16 and OPENDOX_TEST_DATABASE_URL set as CI sets it:

python -m pytest -q tests          2379 passed, 11 skipped
python -m pytest -q tests_runtime   607 passed
total                              2986 passed, 11 skipped, 0 failed

#59's CI at 0c946f4e was selected=2989 passed=2978 skipped=11. This PR adds 8 cases: 2997 selected, 2986 passed, 11 skipped.

Overlap with open PRs

None. gh pr diff --name-only on #60 to #69, read against each PR's own merge-base (#66 and #68 carry #59's commits, which lists default_registry.py spuriously): their own diffs touch neither serve_project.py nor default_registry.py. #66's own serve.py change is one flushed print near line 2216, outside the /source arm.

Review rounds

  • Copilot at 0471f8c7: "Approval recommended", no findings, 0 threads. The SonarCloud quality gate failed there on 4.9% duplication on new code (required at most 3%): the new test module carried a copy of test_projection_seams.py's autouse isolation fixture and git helper.
  • 5666505b: imports both instead of copying them (the tests/test_doxbench_*.py precedent), a test-only change. The autouse fixture still applies to all eight cases (eight SETUPs under --setup-show), the eight cases pass, and M1 to M5 are still killed.
  • Copilot at 5666505b: "Approval recommended", no findings, 0 threads. validate and SonarCloud ("Quality Gate passed") green at 5666505b.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

🤖 Generated with Claude Code

…ult_registry.resolve_source)

Copilot at openDox-code#59 0c946f4 ("previously missed", medium,
default_registry.py:444): resolve_source() performs a second lookup by
(repository, ref) after the caller has already resolved an entry, so a
concurrent refresh can put another entry's source_root behind the path.

serve_project._resolved_listed_edit_entry, the only production caller of that
two-step path, resolved an entry and then asked the registry for the path by
the entry's pair. It now resolves once and confines the path to THAT entry's
own root through the registry seam's declared resolve_within, as serve.py's
/source arm does since #59. The listed-path check and the editor launch already
used the entry in hand, so lookup, validation and confinement are one entry.
The route no longer needs a method the seam does not declare, so a contributed
registry without resolve_source serves it.

SnapshotRegistry.resolve_source keeps its behaviour (one lookup, confined to
that entry) and its docstring now says it is for a caller that holds only a
pair. tests/test_edit_action_one_entry.py holds the rule: one lookup, both
directions of the race at the route, confinement kept, a host registry, and no
module asking a registry for a path by a pair.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 17:58
@sourcery-ai

sourcery-ai Bot commented Sep 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR removes the remaining unstable second registry lookup from the project edit route by confining paths through the entry returned by its single resolution, while retaining existing listing and containment rules. It documents the intended scope of resolve_source and adds focused tests covering races, security checks, and contributed registry compatibility.

Sequence diagram for single-entry project edit path resolution

sequenceDiagram
    participant Client
    participant EditRoute as serve_project
    participant Registry
    participant Seam as projection_seams.registry
    participant Entry
    participant Editor

    Client->>EditRoute: POST /actions/edit
    EditRoute->>Registry: resolve(repository, ref)
    Registry-->>EditRoute: Entry
    EditRoute->>Entry: read listed source paths
    EditRoute->>Seam: resolve_within(Path(Entry.source_root), path)
    Seam-->>EditRoute: confined target
    alt target is a listed file
        EditRoute->>Editor: launch editor over Entry.source_root
        EditRoute-->>Client: 200
    else target unavailable or unlisted
        EditRoute-->>Client: 404 document_unavailable
    end
Loading

Sequence diagram for refresh-safe source confinement

sequenceDiagram
    participant EditRoute as serve_project
    participant Registry
    participant Seam as projection_seams.registry
    participant FirstEntry as resolved entry
    participant Replacement as refreshed entry

    EditRoute->>Registry: resolve(repository, ref)
    Registry-->>EditRoute: FirstEntry
    Note over Registry,Replacement: A refresh may replace the key after resolution
    EditRoute->>Seam: resolve_within(Path(FirstEntry.source_root), path)
    Seam-->>EditRoute: target confined to FirstEntry
    EditRoute->>FirstEntry: read listed source paths
    EditRoute-->>Replacement: no lookup by repository and ref
Loading

File-Level Changes

Change Details Files
Confine edit requests using the already-resolved registry entry instead of performing a second (repository, ref) lookup.
  • Resolve the entry once and reject entries without a source root.
  • Use the projection seam's declared resolve_within(Path(entry.source_root), path) for confinement.
  • Keep listed-path validation tied to the same entry and avoid requiring contributed registries to implement resolve_source.
  • Clarify SnapshotRegistry.resolve_source as a pair-based API for callers that do not already hold an entry.
src/opendox/serve_project.py
src/opendox/default_registry.py
Add regression coverage for stable-entry behavior, confinement, host registry compatibility, and refresh races.
  • Verify the edit route performs one lookup and no module calls resolve_source by pair.
  • Exercise both refresh race directions at the real edit route, including editor launch behavior.
  • Preserve root confinement, listed-path checks, no-root handling, and seam-only host requirements.
tests/test_edit_action_one_entry.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The focused change correctly removes the second lookup and is comprehensively covered.

Review effort: Balanced
Findings: None

What changed in this PR

Fixes a registry-refresh race by confining edit paths to the already-resolved snapshot entry.

Changes:

  • Uses one stable registry entry throughout edit validation.
  • Documents resolve_source’s pair-only contract.
  • Adds focused confinement and race coverage.
File Description
src/​opendox/​serve_project.py Confines edits to the resolved entry’s root.
src/​opendox/​default_registry.py Clarifies lookup semantics.
tests/​test_edit_action_one_entry.py Tests single lookup, confinement, host seams, and refresh races.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…r, do not copy them

SonarCloud's quality gate on this PR read 4.9% duplication on new code
(required at most 3%): the new test module carried a copy of
test_projection_seams.py's autouse registry-isolation fixture and its git
helper. Import both instead, as tests/test_doxbench_*.py import their shared
helpers from test_doxbench_view. The autouse fixture applies to the new
module's cases (eight SETUPs under --setup-show), the eight cases pass, and
all five mutants of the fix are still killed.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 18:07
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The implementation closes the identified race while preserving confinement and is thoroughly covered by focused tests.

Review effort: Balanced
Findings: None

@brettheap
brettheap marked this pull request as ready for review September 30, 2026 18:17
@brettheap

Copy link
Copy Markdown
Contributor Author

READY at 5666505 — Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 20 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 75bd870 into main Sep 30, 2026
4 checks passed
@brettheap

Copy link
Copy Markdown
Contributor Author

Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

LANDED — lane openxfactory-4, 2026-09-30T18:19:52Z, PR #70 → 75bd870 (opensoft/openDox-code main; plain gate)

Brett: land the phase 1 PRs when green

brettheap added a commit that referenced this pull request Sep 30, 2026
main now carries #70 (75bd8705, resolve_source confined to the resolved
entry) and #66's squash (a23e422). This branch already held #66's final
head, a7bda06, at 923f30d9. So main brings only #70's three files
(default_registry.py, serve_project.py and
tests/test_edit_action_one_entry.py), and T058 touches none of them. The
merge is clean.

Proof that the merge carries exactly T058's delta: the stable patch-id of
`git diff a7bda06 923f30d9` equals the patch-id of
`git diff origin/main` against this merge. That delta is seven files.

Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Sep 30, 2026
…n 034) (#68)

Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

Plan 034 **T058** [US2] [oDc], **the post-render validator in the generate verbs** (#1144's 7.2, in part), from `specs/034-opendox-standalone-operation/tasks.md` at openxFactory `main` `91e4685f`:

> It validates the neutral snapshot against T053's schema, read from T057's packaged copy. `--strict` makes a validator that cannot run fatal, and `--no-validate` skips validation.
> - **Realizes**: 7.2 (part).
> - **Falsifier**: F7.2. The good fixture exits 0; the malformed one exits non-zero, naming `EXPECTED_RULE`, with no `No such file or directory`.
> - **Ruled**: R1Q22 (a), `5817152735`; R1Q11 (a), R1Q12 (a), `5850003126`.
> - **After**: T051, T056, T057.

Claimed on openxFactory#656 in comment `5901343950`, with T056.

## Was stacked on #66, and is now on `main`, with its predecessors landed

- **It was stacked on T056's branch** (#66), which was itself stacked on #59 (T055). Each new #66 head was merged in here, the last being `a7bda066` at `923f30d8`.
- **#66 has landed** as `a23e4224`, after #70 (`75bd8703`, the `resolve_source` follow-up to #59). `main` is merged in at `5322efee`. It brought only #70's three files, `default_registry.py`, `serve_project.py` and `tests/test_edit_action_one_entry.py`, and T058 touches none of them.
- **The merge carries exactly T058's delta.** The stable patch-id of `git diff a7bda06 923f30d` equals the patch-id of `git diff main 5322efe`: `f302a8aa` both times. The same id held at `cd6b33cb` against `38761c76`.
- **#58 (T057) was merged in**, at `e94ab323`, `3351f6a7` and `753ffa19`, because T058 wires the `opendox.validator` that #58 ships. **#58 has since landed** as `8ec08e91`, which reaches this branch through #59 and #66 (`03825eb5`, no content change: T057's files here equal `main`'s byte for byte).
  - So this PR's diff is now **T058's own seven files**. Until #58 landed, it also showed #58's delta.
  - **T058's own changes are six commits**: `3d0b6d66` (six files, listed below), and `28241a4b`, `69ca0e2e`, `1ec7d2c9`, `c7768ed5` and `cd6b33cb` (Copilot's findings, below).
  - #58 and #59 fork from #57 at the same commit, `8e7da4a2`, so the merge was clean and brought only #58's own 55 files.
- **Gated on nothing but its own review.** #57 (`a691e4e4`), #58 (`8ec08e91`), #59 (`fa140875`) and #66 (`a23e4224`) have all landed.
- **The base is `main`.** It was retargeted before #66 landed, since a `--delete-branch` landing of #66 would have closed it. The diff against `main` is T058's own seven files again.

## What T058 changes

**`src/opendox/default_projection.py`: openDox's own validator takes the stand-in's place.** The stand-in, `OwnValidatorNotBuilt`, answered every validation "unavailable, T057 is not here".

- **One adapter per own kind.** `OwnValidator(kind)` keeps the lookup's protocol, `validate(path, *, strict, search_from)` → `projection_seams.ValidationResult`.
  - The seam registers one validator per kind and hands it only a path. So the adapter knows its kind from where it is registered.
  - `VALIDATORS` holds one for each of `OWN_KINDS`: `opendox-snapshot` and `ideation-workbench`.
  - `projection_seams.register_defaults()` registers each under its kind (a one-line change).
  - `OWN_KINDS` is not widened. The doxBench wire kinds reach `opendox.validator` through their own seam (T085).
- **It reads the document as its kind is written.**
  - The snapshot is read as JSON, strictly: NaN, the infinities and a key given twice are refused.
  - The manifest is read as YAML with `safe_load`, as `workbench.py` reads it.
  - Then `opendox.validator.validator_for(kind)` judges the document against the packaged copy, which `opendox.contracts` proves against its recorded digest on every call.
- **Three outcomes**, which `cli._validate` already turns into consequences:

  | the adapter meets | outcome | what the verb does |
  |---|---|---|
  | no violation | `validated` | prints `validation: opendox-snapshot: 0 violations, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)`, exit 0 |
  | any violation | `not-conformant`, rc 1; stdout is one `[<rule>] <where>: <detail>` line per violation, then a count | "validation FAILED … This is the SNAPSHOT", relays the lines on **stderr**, exit 1 (with or without `--strict`) |
  | a document that cannot be read as JSON (or YAML) | `not-conformant`, rule `document-syntax` | as above |
  | a readable document holding a number that cannot be read as written (an infinity, a NaN, one binary64 would round, or an unprovable spelling) | `not-conformant`, rule `document-number` | as above |
  | `ValidatorUnavailable` (a copy failing its identity check, or not evaluable), `UnknownKind`, or a document that cannot be read | `validator-unavailable`, with the reason | "validation SKIPPED … could not run: <reason>", exit 0; **exit 1 under `--strict`** |

- **`strict` and `search_from` change nothing** in the adapter. openDox's validator has no warnings to harden, and it searches for nothing, since its schemas are package data. `dependency_remedy` is `None`: no subprocess runs.
  - So F7.2's "no `No such file or directory`" holds by construction.
  - `--strict` still means what the verb's help says, through `cli._validate`.

**The workbench manifest's two validator rules, carried (for the holder).**
- The manifest schema says of two rules that it cannot state them, and leaves them to the validator:
  - every `recipe.pinned` keyword is also in `recipe.checked`;
  - no `recipe.new_candidates` document is already a member or excluded.
- The consumer's script (`validate-ideation-dashboard-contracts.py`, `check_workbench_rules`) checked both. `opendox.validator` checks the schema only, as #58's body says.
- The holder decided (2026-09-28, "To T055: carry two workbench rules") that routing `validate_manifest` to openDox's validator must not drop them. T055 routed it to the stand-in, so they fall due here.
- The adapter carries them for `ideation-workbench`, under the script's identifiers, `workbench-pinned-not-checked` and `workbench-candidate-overlap`. They are judged beside the schema, and are total over any shape.
- If the holder prefers them in `opendox.validator` itself, that is a move within #58's module, and the ids stay.

**The schema copy.**
- `tests/test_neutral_projection.py` reads the neutral contract from the packaged copy, through `opendox.contracts.verified_bytes("opendox-snapshot")`.
- T054's `tests/fixtures/opendox-snapshot.schema.yaml` and its `SCHEMA_SHA256` are removed. The bytes were identical (`f9e3e111…584a` both), so no case's verdict moved.
- The digest case now asserts that the bytes read are the recorded ones, that a strict JSON read equals `contracts.load()`'s YAML read, and that the tree carries one copy.

**The stand-in's cases in `tests/test_projection_seams.py`**, as T055's hand-off listed them:

| before | after |
|---|---|
| `test_the_validator_stand_in_concludes_nothing_and_names_T057` | `test_openDoxs_own_validator_is_bound_to_each_own_kind` |
| `test_openDoxs_own_kind_meets_the_stand_in_and_strict_makes_it_fatal` | `test_openDoxs_own_kind_meets_openDoxs_own_validator` (a bare snapshot is now REJECTED, naming `[envelope-keys]`), and `test_openDoxs_own_validator_unavailable_is_skipped_and_strict_makes_it_fatal` (a copy refused by `opendox.contracts`: skipped, then fatal under `--strict`) |
| the stand-in half of `test_a_manifest_is_validated_by_the_validator_for_its_kind` | a bare manifest is now judged, naming `[required]` |

Two identity asserts also move from `default_projection.VALIDATOR` to `VALIDATORS[kind]`.

**`tests/test_post_render_validator.py` (new, 29 cases):**
- F7.2 through `python -m opendox.cli generate --strict`, with the siblings refused by T056's `tests/standalone_child.py`;
- `generate-and-open --no-open --no-serve --strict` over both fixtures;
- `--no-validate`;
- the three outcomes, including five not-JSON documents, an unreadable path, `ValidatorUnavailable`/`SchemaNotEvaluable`, and a copy tampered below `opendox.contracts`;
- `strict`/`search_from` inert;
- each validator reading as its own kind;
- the two workbench rules, their bounded detail, and `workbench.save(validate=True)` keeping a valid manifest and unwinding a broken one.

## F7.2, failing before and passing after

**#1144's F7.2, verbatim**: a fresh venv, `pip install .` (package data on disk, a non-editable install), both fixtures as fresh repositories, `generate --strict` twice, the rule grep, and the negative grep asserted as exit status 1.

- **Before**, at this branch's base (`3b13f141`: #59 + #66 + #58, with the stand-in), it exits **1** on the good fixture's `--strict` run:
  ```
    validation SKIPPED — this snapshot was NOT checked against the pinned schema
      the validator registered for kind 'opendox-snapshot' reached no verdict. …
      openDox's own validator is plan 034's T057, and this build does not carry it yet, so nothing of openDox's own kinds is checked
      --strict was given and it means what it says: a run that COULD NOT be validated FAILS rather than continuing unchecked
  ```
- **After**, at `cd6b33cb`, and again at `5322efee`, after `main` was merged in, it exits **0**. The malformed run's stderr:
  ```
    validation FAILED — the pinned validator REJECTED …/bad.json. This is the SNAPSHOT, not the environment: the validator ran fine and found the data non-conformant.
      [title-and-summary-are-text] /documents/1/title: '' is shorter than 1
      1 violation(s) of the opendox-snapshot contract, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)
  ```
  `EXPECTED_RULE` is `title-and-summary-are-text`, and no `No such file or directory` appears.

**`tests/test_post_render_validator.py`**: at the base, with the stand-in, **26 failed and 2 passed**. The two that pass hold what T058 does not change: `--no-validate`, and `EXPECTED_RULE` being one of the contract's rules. At `3d0b6d66`, **29 passed**. At this head `1678ccd0`, with Copilot's later rounds, **50 passed**, and also 50 under `--noconftest`.

## Mutation check: 29 of 29 killed, re-run at this head

Each mutant was applied, the named cases were run, and the sources were restored and checked by sha256.

| mutant | killed by |
|---|---|
| M1 violations answer `validated` | 17 cases |
| M2 violations answer `unavailable` | 12 |
| M3 the rule lines are dropped from stdout | 17 |
| M4 `ValidatorUnavailable` read as a pass | 4, including the `--strict` case |
| M5 NaN admitted as JSON | 1 |
| M6 a repeated key admitted | 1 |
| M7 the pinned rule dropped | 4 |
| M8 the overlap rule dropped | 1 |
| M9 the overlap ignores `excluded` | 1 |
| M10 the validator reads the document's `kind`, not its own | 1 |
| M11 the entry points register only the snapshot's validator | 2 |
| M12 the rules compare unhashable entries | 1 |
| M13 an unreadable document reads as valid | 1 |
| M14 `--strict` does not make an unavailable validator fatal (`cli.py`) | 1 |
| M15 `--no-validate` does not skip (`cli.py`) | 4 |
| M16 T054's fixture copy of the schema comes back (a tree mutant) | 1 |
| M17 a rule's detail quotes every name | 1 |
| M18 name membership tested against the list again (quadratic) | the linear-time case, at 17.0 s |
| M19 an `OSError` from the lookup escapes | the unreadable-file case |
| M20 a quoted name is not cut | the long-name case |
| M21 a key given twice picks the last `kind` again (`cli.py`) | the doubled-kind case |
| M22 a non-finite number is read as JSON | the `1e999` and YAML `.inf`/`.nan` cases |
| M23 a rounded number is read as written | the `1.0000000000000001` and `1.5e-400` cases |
| M24 every inexact binary fraction refused (over-strict) | the controls (`0.1`, `2.50`, …) |
| M25 the manifest's floats are read unproved | the four manifest-number cases |
| M26 the JSON read proves no float | the snapshot number cases |
| M27 an unprovable number spelling keeps its float | the two base-60 manifest cases |
| M28 a refused number is reported as a syntax error | the number-rule cases |
| M29 the syntax message names the kind as an adjective again ("a 'opendox-snapshot' document") | the two exact-message syntax cases (6 failed) |

## The repository's own checks

The whole suite ran locally as CI runs it: `CI=true`, `LANG=C.UTF-8`, PostgreSQL 16, `-e ".[runtime,test]"` with the constraints file, at the committed head, with a clean tree.

| tree | passed | skipped |
|---|---|---|
| base `3b13f141` | 2948 | 11 |
| `3d0b6d66` (T058's commit) | 2978 | 11 |
| `09cd1e8a` (#66's `42a08a31` merged in) | 2979 | 11 |
| `28241a4b` (Copilot's two findings) | 2981 | 11 |
| `21e4723f` (#66's `5a26532e` and #58's `3351f6a7` merged in) | 3010 | 11 |
| `69ca0e2e` (#66's `e939c31f` merged in, and Copilot's second round) | 3014 | 11 |
| `80153754` (Copilot's third round, and #58's `753ffa19` merged in) | 3031 | 11 |
| `c7768ed5` (#66's `e3574774` merged in, and Copilot's fourth round) | 3033 | 11 |
| `cd6b33cb` (#66's `38761c76` merged in, and Copilot's fifth round) | 3034 | 11 |
| `923f30d8` (#66's final head `a7bda066` merged in), run under `nohup … &` | 3036 | 11 |
| `5322efee` (`main` merged in, with #66 and #70 landed), run under `nohup … &` | 3044 | 11 |
| this head `1678ccd0` (Copilot's sixth round: the syntax message's wording), run under `nohup … &` | **3044** | **11** |

- The junit diff at `3d0b6d66` shows **+32 added** (29 in the new file, 3 in `test_projection_seams.py`) and **2 removed** (the two stand-in cases above, replaced). At `09cd1e8a` it shows +33: the extra case is #66's own.
- At `923f30d8`, against `cd6b33cb`, it shows **+2**: #66's `test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it` and #59's `test_a_regenerate_promotes_no_session_and_moves_no_active_key`. At `5322efee`, against `923f30d8`, it shows **+8**, all from #70's `tests/test_edit_action_one_entry.py`. At this head, against `5322efee`, it shows 0 added, 0 removed and 0 changed.
- **0 changed** outcomes, and the same 11 skips.
- The mutation check was re-run at `923f30d8` and `5322efee` (28 of 28 killed both times), and at this head, where it is **29 of 29**.
- **F4.1's deferred-reach scan**: 11 at the base and 11 here, the same list. The adapter imports `opendox.validator` and PyYAML when a validation runs, and names no sibling.
- No floor, workflow, `conftest.py`, `pyproject.toml` or pin is touched.

## Files (T058's own commits)

- `src/opendox/default_projection.py`: `OwnValidator`, `VALIDATORS`, `SYNTAX_RULE`, `NUMBER_RULE`, `WORKBENCH_RULES`. The stand-in is removed and the docstring rewritten.
- `src/opendox/projection_seams.py`: `register_defaults()` registers `VALIDATORS[kind]`.
- `src/opendox/cli.py` (`69ca0e2e`): `_written_kind` refuses a key given twice, and `_validate` fails on it.

These seven files are the whole of the PR's diff now that #58 has landed.
- `tests/test_projection_seams.py`: the stand-in cases above.
- `tests/test_neutral_projection.py`: it reads the packaged copy.
- `tests/fixtures/opendox-snapshot.schema.yaml`: removed.
- `tests/test_post_render_validator.py`: new. It is a created file, with no carve-manifest row (RULED OQ-C).

## Copilot

- At `3d0b6d66`, "Needs a closer look", with two findings. Both are fixed in `28241a4b`, answered with evidence, and resolved:
  - **r4139734412**: an unreadable packaged record or copy escaped `validator_for()` as a `PermissionError` traceback. `opendox.contracts` converts only a missing file.
    - Measured with `copies.yaml` at mode 000: `generate --strict` exited 1 with the traceback.
    - The adapter now reports an `OSError` from the lookup as validator-unavailable, and the verb warns, or fails under `--strict`, in its own words.
    - The root conversion is #58's file, and it has been relayed to #58's owner.
    - New case: `test_a_packaged_file_that_cannot_be_read_is_unavailable_not_a_traceback`.
  - **r4139734444**: `_names()` was quadratic, and the schema bounds none of the three lists. Membership is now a set's. New case: `test_the_rules_read_a_long_list_in_linear_time`, which took 17.5 s before the fix and 0.01 s after.
- At `21e4723f`, "Needs a closer look", with four findings, each answered with evidence and resolved:
  - **r4139769819**: the verb chose a validator by `kind` with plain `json.loads`, which keeps the last of two keys. So `"kind": "opendox-snapshot", "kind": "unknown"` found no validator, and an ordinary run exited 0.
    - Fixed in `69ca0e2e`: `cli._written_kind` refuses a key given twice, and the verb fails whatever `--strict` says. New case: `test_a_kind_given_twice_chooses_no_validator_and_fails`.
    - NaN and the infinities are left to the chosen validator, since they do not make the kind ambiguous.
  - **r4139840593**: `1e999` reads as `inf` without `parse_constant`. Fixed in `69ca0e2e` (`parse_float=_finite`), with two new not-JSON cases.
  - **r4139769791**: a quoted name was not cut. Fixed in `69ca0e2e`: each is cut at 80 characters. New case: `test_a_rules_detail_cuts_a_long_name`.
  - **r4139769759** (`validator.py`, #58's file): `_close` consuming earlier siblings' canons at `count == 0`. **Not reproduced**: the slice is `done[len(done) - count:]`, which is empty at 0, and `_canon([1, []])` and `_canon([[], 1])` are distinct (`a2:n1:1a0:`, `a2:a0:n1:1`). No change here; the note went to #58's owner.
  - (r4139734412's root, `opendox.contracts` refusing an unreadable file, has since landed in #58 as `2b8ad24`, merged in here. With it, a record at mode 000 gives `could not run: opendox.contracts has copies.yaml, and it cannot be read (PermissionError: Permission denied)` and no traceback. The adapter's own `OSError` guard stays as defense in depth.)
- At `69ca0e2e`, one finding, answered with evidence and resolved:
  - **r4139937566**: `float()` rounds `1.0000000000000001` to `1.0`, which meets `const: 1`. A manifest so written read as "0 violations", and jsonschema 4.26 reads the snapshot case the same way.
    - The contract has no `number` type, so rather than carry decimals through #58's validator, `1ec7d2c9` refuses a float literal that is not finite, or not equal to the shortest spelling of the float read from it, under `document-syntax`. The same proof applies to the manifest's YAML floats.
    - `0.1`, `2.50`, `1E2` and every float openDox's writer writes read as written.
    - Six new cases fail without the fix, and seven controls pass either way.
- At `80153754`, one finding, answered with evidence and resolved:
  - **r4146201125**: a YAML base-60 float (`0:1.0000000000000001`) is read and rounded by PyYAML, but `Decimal` cannot parse it, so the proof let it through, and the manifest read as "0 violations".
    - Fixed in `c7768ed5`: a spelling the proof cannot compare is refused, under `document-syntax`.
    - Two new cases fail without the fix, and mutant M27 is killed.
- At `c7768ed5`, one finding, answered with evidence and resolved:
  - **r4146428769**: a valid document refused for a number was reported as "cannot be read as YAML/JSON".
    - Fixed in `cd6b33cb`: such a number breaks a rule of its own, `document-number`. `document-syntax` stays for a document that cannot be read at all.
    - Ten cases fail without the change, and mutant M28 is killed.
- At `cd6b33cb`, "Needs a closer look", with nothing open.
- At `5322efee`, "Needs a closer look", with one finding, answered with evidence and resolved:
  - **r4148179148**: `document-syntax`'s message read "a 'opendox-snapshot' document", which takes the wrong article.
    - Fixed in `1678ccd0`: it now reads "a document of kind 'opendox-snapshot'".
    - The two exact-message cases, updated first, failed against the old wording (6 of 6 runs), and mutant M29 is killed.
- At this head `1678ccd0`, a review is re-requested through the reviewer API.

## For the holder

1. **The two workbench rules** (above): carried in the adapter, under the consumer script's ids. Tell me if they belong in `opendox.validator` instead.
2. **The verb relays at most the last 20 lines** of a rejection (`cli._report_non_conformance`, T055's, unchanged). A snapshot breaking more than 19 rules shows the count line and the last 19. That is enough for F7.2's single rule, and not changed here.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

🤖 Generated with [Claude Code](https://claude.com/claude-code)


Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants