Skip to content

T056, the standalone generate path, end to end (5.1 part) (plan 034) - #66

Merged
brettheap merged 61 commits into
mainfrom
build/034-p2r-t056-standalone-generate-end-to-end
Sep 30, 2026
Merged

brettheap merged 61 commits into
mainfrom
build/034-p2r-t056-standalone-generate-end-to-end

Conversation

@brettheap

@brettheap brettheap commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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

Plan 034 T056 [US2] [oDc], the standalone generate path, end to end (#1144's 5.1, in part), from specs/034-opendox-standalone-operation/tasks.md at openxFactory main 91e4685f:

python -m opendox.cli generate and generate-and-open --no-open run on the fixture with neither sibling importable, and the server STARTS (the limit measured in research R7 is lifted).

  • Falsifier: F5.3; F10.1's generate-and-open run through python -m opendox.cli, with a plain install and no --local, which arrives in phase 3 (T070); and the verb's half of spec.md's stage: edge case. python -m opendox.cli generate, over a copy of T050's fixture in which one document declares a stage: value outside the six role keys, reports it, naming the document, the value and the six keys, and the snapshot it writes reads that document as a source. T054 tests the projection's half in process. F10.1 as batch H amends it is T077's.
  • Ruled: R1Q22 (a), 5817152735.
  • After: T055.

Claimed on openxFactory#656 in comment 5901343950.

Was stacked on #59 (T055), and is now on main

What T056 found, and the one fix it needed

At #59's head the verbs already run standalone. T055's writer measured the verb's half of F5.3 there, and this PR confirms it. The server also starts. But on a pipe, it never says where it started.

  • cli.cmd_generate_and_open and serve.serve print the URL and then block in serve_forever(). Neither flushed first.

  • When standard output is a pipe or a file, Python buffers it by block. So a wrapper reading the URL never sees it while the server runs. It cannot learn an ephemeral port, or tell that the server started.

  • Measured at e3ef506a, with the siblings blocked and --port 0:

    entry point stdout on a pipe, default buffering with PYTHONUNBUFFERED=1
    python -m opendox.cli generate-and-open --no-open 0 lines in 20 s 8 lines, the URL in 0.4 s
    python -m opendox.serve --snapshot … --checkout-root … 0 lines in 15 s the URL in 0.2 s
  • The fix is flush=True on the URL line and on "serving until interrupted" in cli.py, and on serve.serve's announcement in serve.py. Each carries a comment saying why.

The tests: tests/test_standalone_generate_path.py (new)

Each case runs a real child process, python -m …, the way F5.3 is written. The child is built by tests/standalone_child.py, a new helper module that holds no case. T058's F7.2 case uses the same helper.

How "neither sibling is importable" is made true. A sitecustomize sits on a directory put first on PYTHONPATH.

  • It installs a meta-path finder that refuses openxdox, ideation_dashboard, doc_health and corpus_adapter_openxfactory, whatever is installed. It also drops any of them that a .pth file imported before it ran.
  • It logs every name it refuses, and each case asserts that the log is empty. A refused import that an except ImportError swallowed would otherwise pass as a degraded run.

The child's stdout is a pipe with default buffering. PYTHONUNBUFFERED is taken out of its environment on purpose, because that is what a wrapper sees.

case what it holds
test_F5_3_generate_through_the_module_writes_the_neutral_snapshot #1144's F5.3: generate over a fresh copy of T050's fixture. The snapshot is non-empty and of kind opendox-snapshot, and none of F5.3's 14 declared words is in a string value.
test_the_verb_reports_a_stage_outside_the_six_and_reads_it_as_a_source candidate-toolshed-rebuild.md is edited to stage: someday. There is exactly one notice:, naming the document and 'someday', and carrying the six keys as one rendered list, (source, grouping, candidate, selection, submission, completion). The document's entry is stage: source, and no string value in the snapshot contains someday.
test_the_unedited_fixture_declares_that_document_a_candidate The control: as T050 ships it, the same document is a candidate and draws no notice.
test_generate_and_open_starts_a_server_that_answers_with_no_sibling generate-and-open --no-open --port 0, with no --no-serve. The URL is read off the pipe while the child runs. /index.html is 200 and HTML. /snapshot.json is 200 and equals the written file. /capabilities is 200, with the regenerate binding. /source/notes-toolshed-inventory.md is 200, byte-equal to the document. /source/.git/config is 404. SIGINT exits 0, and the port is closed afterwards.
test_serve_main_starts_a_server_that_answers_with_no_sibling The same checks for python -m opendox.serve, over a snapshot generate wrote.
test_a_child_that_ignores_the_interrupt_is_killed_at_the_deadline The harness itself: a child that ignores SIGINT is killed at the deadline, and the timeout is raised, so a server that will not stop is reported rather than waited out.
test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it The harness itself: a child stops on the interrupt with status 0 even when the runner ignores SIGINT, as a suite started with nohup … & does. The parent's handler is back afterwards. See the section below.

What the merge of main exposed: the runner's ignored SIGINT

After merging main, I ran the whole suite as nohup pytest … &. Cases 3 and 4 failed with TimeoutExpired at the interrupt. Run in the foreground, the same module passed 6 of 6.

The cause is the runner, not the server:

  • POSIX starts an asynchronous command with SIGINT ignored when job control is off. Under nohup … &, a probe reads signal.getsignal(SIGINT) == 1, which is SIG_IGN. In the foreground it reads default_int_handler.
  • An ignored signal survives exec, and Python installs its KeyboardInterrupt handler only where SIGINT was not ignored. So every server the harness started ignored the interrupt, and the two cases reported how the suite had been launched.
  • The estate runs long suites exactly this way. Any lane running this module under nohup would have read a false red.

The fix is in the harness, not the product. A program started with SIGINT ignored should keep it ignored. The child's sitecustomize in tests/standalone_child.py now sets SIGINT back to signal.default_int_handler. That models what the cases claim, a Ctrl-C at a terminal. A child that ignores SIGINT itself, after startup, still does, and case 5 still kills it at the deadline.

The falsifier is test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it. It builds that runner in process: it ignores SIGINT while the child starts, and restores it at once.

  • Before the fix it is red (TimeoutExpired at 10 s), and that is in a foreground run.
  • After the fix it is green.
  • The module under nohup … & went from 2 failed, 4 passed to 7 passed.
  • Mutant M14, the reset removed, is killed by it.

The falsifiers, failing before and passing after

F5.3, verbatim from #1144 (a fresh venv, pip install ".[test]", the sibling-absence assertion, python -m opendox.cli generate, then the vocabulary scan):

  • At openDox-code main fa8862cc, before T055 routed the verb, it exits 1:
    opendox.consumer_reach.ConsumerReachUnavailable: 'openxdox.corpus_root' belongs to openXdox, the layer that PINS this one, …
  • At this head it exits 0:
    wrote …/snap.json
      repository=fixture kind=opendox-snapshot
      documents=8 clusters=2 possibles=1 staged_topics=1 changes=2 keywords=28
    documents: 8
    

The verb's half of the stage: edge case, over a copy of the fixture with one document edited to stage: someday:

notice: candidate-toolshed-rebuild.md: its stage: value 'someday' is not one of the six station role keys (source, grouping, candidate, selection, submission, completion), so it is not a declaration; the document is read as a source

The snapshot reads that document as ['source'].

This file:

  • At main fa8862cc: 5 failed. Every verb refuses with ModuleNotFoundError: No module named 'openxdox'.
  • At T055, serve and generate standalone (5.5, 4.3 part) (plan 034) #59's head e3ef506a, without the two flushes: 2 failed, 3 passed. The two server-start cases fail with python -m opendox.cli generate-and-open never printed a line matching '^(http://…)/index\.html$' on its (buffered) standard output while it ran: exit status None, standard output '', and the same for opendox.serve.
  • At 38761c76: 6 passed (3.4 s). It also passes under --noconftest.
  • At 149d7295, and at this head a7bda066: 7 passed, both in the foreground and under nohup … & (4.6 s).

Copilot:

  • At 65c943ff, approval recommended, with no findings.
  • At 1597511d, one finding, r4139607689: an interrupt the child ignores held the caller while its pipe readers were joined. Fixed in 42a08a31: the child is killed before the join. The new case fails without the fix and passes with it. The thread is answered with that evidence and resolved.
  • At 5a26532e, one finding, r4139809410: the stage: case rejected only a string EQUAL to someday, not one containing it. Fixed in e939c31f. A mutant that sets the summary to stage: someday passes the old check and fails the new one. Answered and resolved.
  • At e3574774, one finding, r4146289331: "serving until interrupted" was checked only after the interrupt, when Python's exit flush delivers it anyway. Fixed in caa01caf: the case reads the line while the child runs. Mutant M11, that line unflushed, now fails. Answered and resolved.
  • At 38761c76, approval recommended, with no findings.
  • At 149d7295, one finding, r4147767447: the stage: case checked each role key as a word anywhere in the notice. But candidate is in the document's name and source is in "read as a source", so a list that left both out still passed. Mutant M15, that list, SURVIVED the old case. Fixed in a7bda066: the whole rendered list is one substring. M15 is now killed. Answered and resolved.
  • At this head a7bda066, a review is re-requested through the reviewer API.

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

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

mutant killed by
M1: generate-and-open flushes neither line (the pre-T056 code) the generate-and-open case
M2: serve.serve's URL line is not flushed the serve case
M3: /source serves a hidden path (both hidden-name checks dropped) both server cases
M4: a sibling probe is swallowed on the generate path (try: import openxdox / except ImportError: pass) F5.3, stage:, generate-and-open (the refused-import log)
M5: an out-of-six stage: is read as a candidate stage:
M6: the notice omits the six keys stage:
M7: the verb does not print the notice stage:
M8: a governed word (draft) leaks into a title F5.3
M9: no stage: value is a declaration the control
M10: generate-and-open exits non-zero on an interrupt generate-and-open
M11: the serving line is not flushed (the URL line still is) generate-and-open, which reads that line while the child runs
M12: the undeclared stage: value is carried inside a larger string stage:
M13: an ignored interrupt is joined before the child is killed (the harness) the ignored-interrupt case
M14: the child inherits the runner's ignored SIGINT (the sitecustomize reset removed) the runner-ignores-SIGINT case
M15: the notice's key list leaves out candidate and source, which both appear elsewhere in the notice stage: (survived the word-by-word check, which is why that check was replaced)

Two first spellings were equivalent, and each was re-expressed as the real regression:

  • Dropping only the URL line's flush is covered by the flush on the next line under --no-open.
  • Dropping only the first hidden-name check is covered by the second, which still refuses .git/config.

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
#59's head e3ef506a 2724 11
65c943ff 2729 11
1597511d (the harness moved into tests/standalone_child.py) 2729 11
42a08a31 (an ignored interrupt is killed before the pipes are joined) 2730 11
5a26532e (#59's 814516b7 merged in) 2753 11
e939c31f (the stage: case looks inside every string) 2753 11
e3574774 (#59's c2a8ad9f, with #58's landing, merged in) 2982 11
38761c76 (the serving line read while running; #59's 183b40fc merged in) 2983 11
a6e953ce (main merged in, #59 landed), run under nohup … & 2982, 2 failed 11
149d7295 (the harness resets SIGINT), run under nohup … & 2985 11
this head a7bda066 (the six keys checked as one rendered list), run under nohup … & 2985 11

At each head, the case-by-case junit diff against the base shows only this file's cases added, 0 removed and 0 changed outcomes, and the same 11 skips. Against 38761c76, this head adds exactly two cases, both passing. One is test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it. The other is test_a_regenerate_promotes_no_session_and_moves_no_active_key, which #59 gained after 183b40fc. pyflakes finds nothing in the two new files. No floor, workflow, pyproject.toml, conftest.py or pin is touched.

Files

  • src/opendox/cli.py: flush=True on the URL line and on "serving until interrupted" in cmd_generate_and_open.
  • src/opendox/serve.py: flush=True on serve.serve's announcement.
  • tests/test_standalone_generate_path.py: new, the seven cases.
  • tests/standalone_child.py: new, the child-process harness (a helper module, no case). Its sitecustomize refuses the siblings and sets SIGINT back to Python's handler.

Both are created files, with no carve-manifest row (RULED OQ-C).

For the holder

  • The overlap. With T055, serve and generate standalone (5.5, 4.3 part) (plan 034) #59 landed, this PR's edits in cli.py and serve.py are three print calls in two functions. It does not touch default_registry.py or serve_project.py. But cases 3 and 4 GET /source/notes-toolshed-inventory.md, expecting 200 and byte-equal, and /source/.git/config, expecting 404, and both requests run through serve_project and default_registry.resolve_source. So the follow-up to T055, serve and generate standalone (5.5, 4.3 part) (plan 034) #59 on resolve_source's second lookup should run this module.
  • An observation, not changed here. Standalone, /capabilities answers actions.gate: true: it is local_human, meaning a resolved actor, a real checkout and loopback. I did not check whether any gate route answers without openXdox's contributed verbs.

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

🤖 Generated with Claude Code

brettheap and others added 30 commits September 27, 2026 16:06
…an 034)

tests/fixtures/plain-documents/ carries eight .md documents: three sources
(no `stage:` header, two sharing the phrase "rain barrel" so a future
topic-based grouping pass has a pair to find) and one document each
declaring `stage: grouping`, `stage: candidate`, `stage: selection`,
`stage: submission` and `stage: completion` (RULED R1Q13 (a) with (c),
openxFactory#656 comment 5850003126). Every document carries the small
neutral field set the default adapter will require regardless of station
(`title`, `summary`).

tests/test_plain_documents_fixture.py is the vocabulary test T050 names as
its falsifier: it asserts the fixture carries none of the eight controlled
`Status:` words or the change/spec/delta nouns, that it covers all six
stations with exactly one document per explicit station, and that at
least two (but not all) sources share the topic fixture code will need to
group on.

Not yet wired into .github/workflows/validate.yml's explicit pytest list:
phase2-ahead scope keeps this PR out of that file, the pin files and
conftest.py/pyproject.toml/README.md, which the phase-1 chain still edits.
This suite runs by node id today.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erator (plan 034)

Box 5.4 of openxFactory's add-neutral-product-standalone-operability:
"DECLARE THE GENERATOR SEAM - it does not exist and CorpusAdapter is not
it." This declares it, in src/opendox/generator_seam.py, beside
domain_profile.register():

- the operation handed over: generate(repo_root, repository, *,
  source_revision=None, generated_at=None, **inputs) -> the snapshot;
- the registration point: register(<SnapshotGenerator>) for a host,
  and register_default(...) for an entry point, which registers only
  where nothing is registered;
- the conformance a contributed generator must meet. It declares the
  contract it writes and every further input it reads, its operation
  takes the seam's call, and it answers a snapshot whose kind is that
  contract and whose schema_version is an integer. The seam checks the
  declaration when it is made and each snapshot when it comes back,
  and it refuses, never drops, an undeclared input.

The conformance clause names T053's neutral snapshot kind,
"opendox-snapshot" (openDox-spec#16), for openDox's own generator:
src/opendox/default_generator.py's GENERATOR. The entry points
(cli.build_parser, cli.main, serve.build_server, serve.main) register
it where no host has (R1Q10 (a), openxFactory#656 comment 5850003126,
in R1Q3 (a)'s pattern). A bare process still refuses, naming the seam
and the call. A host replaces the default only before a snapshot has
been generated from it. CorpusAdapter stays closed at six members.

openDox's own generator refuses until T054 builds its projection. Plan
034 orders T052 before T054, and T054 edits neither cli.py nor serve.py
(their single-writer order runs T052, then T055). No verb reaches the
seam before T055 routes the generate verbs, which comes after T054.

Falsifier: the seam tests, tests/test_generator_seam.py (59 cases).
F5.2 is quoted by T059 and T061.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nce it answers

Copilot's review of this PR at 834f8ea raised two findings. Both are
addressed here.

1. A declared input the operation cannot do without was accepted when it
   was declared. SnapshotGenerator bound the seam's call with every
   declared input given. But generate() passes an input only when its
   caller has a value for it, so such an operation failed with a raw
   TypeError the first time the option was unset. The declaration now
   binds the call twice, once with every declared input given and once
   with none given, and every call the seam can make lies between the
   two. Conformance clause 2 now says that each declared input is
   optional.

2. The default's window closed before its operation ran. generate()
   recorded a generation from the entry point's default before the call.
   So a generation that failed shut every host out although it wrote
   nothing, and that included openDox's own refusal before T054. The
   record is now made once a conformant snapshot comes back. A
   generation that fails, or whose answer the seam refuses, records
   nothing. While a generation from the default is under way, a host's
   registration is refused, because that snapshot would come back after
   the swap. The early record gave that property. A count of the
   generations under way now holds it.

The registry's bookkeeping moves under one threading.Lock, because
serve.py's ThreadingHTTPServer answers each request on a thread of its
own. generate() holds the lock only to resolve and mark, and to record
the end, and never across the generator's call. So a generator may
register, unregister or generate from inside its own call.

Tests: 59 -> 66 cases. Against 834f8ea's seam logic, 8 of the 66 go
red: the new declared-input case, the pre-T054 default case, the two
wrote-nothing cases, the three under-way cases and the re-entrant case.
The concurrency and re-entrancy cases run in a process of their own with
a time limit, so a lock held across a call fails them rather than
hanging the suite. 13 of 13 mutations are caught.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (plan 034)

tests/fixtures/malformed/ carries two .md documents: notes-bee-boxes.md is
an ordinary valid source (non-empty title and summary), and
notes-empty-title.md declares `title:` with nothing after the colon --
present in the header, but an empty string once parsed -- while its
`summary:` stays ordinary. That is exactly one violation of the neutral
snapshot schema T053 adds (opensoft/openDox-spec#16 at cd49eb25):
`documents[].title` and `.summary` are each `type: ["string", "null"],
minLength: 1` (x-rule `title-and-summary-are-text`, "each non-empty text,
or null"). A document that declares no title/summary line at all yields
null, which is valid (openDox-spec's own no-front-matter example); an
empty string is the one value that is neither null nor non-empty text.
Holder decision: T054 copies title/summary verbatim, without coercing an
empty declared value to null or excluding the document, so the violation
survives unchanged into the generated snapshot.

tests/fixtures/malformed/EXPECTED_RULE holds the violated rule's
identifier verbatim, `title-and-summary-are-text`, for the future
`opendox generate --strict` to name (spec.md AT-R1 scenario 2).

T051 carries no falsifier of its own (tasks.md: "used by F7.2"); this PR
adds only the fixture and the sentinel file.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T054's branch stacks on T052's (openDox-code#54, build/034-p2g-t052-generator-seam
at 4ca45d2), which was cut from main 80acead. main has since landed T036 (the
required check runs the whole suite) and T037 (the margin), so this takes main
at 2d11641, and the PR is measured against the whole-suite check it will be
judged by. No conflict: T052's five files and main's changes are disjoint.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…9560ee) into T054's branch

T054's falsifier runs the neutral projection over T050's
tests/fixtures/plain-documents, so this branch carries that fixture until
#53 lands on main. The merge adds only #53's files. Once #53 lands, they
drop out of this branch's diff against main.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… into T054's branch

The holder ruled that T054 copies title and summary without coercing or
excluding them, so T051's malformed fixture keeps its one
title-and-summary-are-text violation in the snapshot. T054 proves that
over T051's tests/fixtures/malformed, so this branch carries that fixture
until #56 lands on main. The merge adds only #56's three files.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…usAdapter (plan 034)

A checkpoint commit, pushed on the coordinator's usage stop. It is not
the finished task: four node-harness cases in tests/test_display_facet.py
still carry the governed snapshot values in their fixtures and fail
against the new defaults, and T054's own test file is not written yet.

What this commit holds:
- src/opendox/neutral_projection.py (new): the projection. It writes
  T053's opendox-snapshot, places a document by its neutral stage: key
  (reading a value outside the six role keys as a source and reporting
  it), copies title and summary verbatim, and applies the topic rule and
  the group rule its docstring names.
- src/opendox/default_generator.py: generate() projects the home corpus
  instead of refusing. NeutralProjectionNotBuilt is retired, and so is
  its refusal case in tests/test_generator_seam.py.
- src/opendox/display_profile.py and web/views/display.js:
  SNAPSHOT_VALUES' defaults become the neutral snapshot's values.
  display.js keeps its line count, so the web census row is unchanged.
- src/opendox/runtime/local_git_adapter.py: leading_header() is public,
  NEUTRAL_FIELDS is declared, WorkingTreeCorpus defaults required_fields
  to it, and the suffix branch of classify reports missing fields when
  fields are required.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ay-facet cases (plan 034)

This finishes the work 25fe752 checkpointed.

The holder ruled on two of T054's open questions, relayed by the
coordinator:
- An entry the adapter cannot classify (a Makefile, an image) is not a
  document. The projection now leaves it out unread, and nothing mentions
  it.
- The wheel's grouping tile counts a group's edges, and the neutral schema
  is not widened with a tally. views/wheel-model.js now counts
  document_edges where a group carries no tallies.document_links, and
  uses the tally where one exists, as the governed snapshot's groups do.
  The file keeps its 1358 lines, so its census row is unchanged.

tests/test_neutral_projection.py (new) holds T054's falsifier:
- in a fresh process with every sibling blocked, the CLI entry point's
  defaults generate over T050's fixture through the seam, and the result
  validates against T053's schema with no F5.3 word;
- the topic rule groups a copy of AT-R1's repository (b), which has no
  front matter;
- a stage: value outside the six is reported, naming the document, the
  value and the six keys, and is read as a source.
Around them it tests:
- the holder's T051 rule (the malformed fixture breaks only its
  EXPECTED_RULE);
- what a document is, the stations, and the anchors;
- the default adapter's field set through the entry point;
- the neutral display values in Python and in display.js, and the
  wheel's edge count;
- that the projection makes no reach.
The schema is a byte-identical copy of openDox-spec#16's at cd49eb25,
held to its sha256, until T057 ships the packaged one.

tests/test_display_facet.py: four node-harness cases carried the
governed snapshot values in their fixtures. Each now reads the value
openDox ships (SNAPSHOT_VALUES), and the property each one tests is
unchanged. The canvas case's neutral assertion no longer compares
against a value the neutral install can never write. It now asserts
openDox's own word.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(plan 034)

validate failed at 0f42f67 in one case,
test_the_anchors_come_from_the_source_revision_and_the_bytes_repeat.
CI's git prints `%cI` for a zero offset as `2026-09-27T12:00:00Z`, and
the git this was written against (2.43.0) prints
`2026-09-27T12:00:00+00:00`. The projection was right: it records the
stamp exactly as git gives it, and the neutral schema admits both
spellings. The test compared that stamp against the fixture's date as a
string. It now compares the two as instants. The case's first assertion
still holds generated_at to the checkout's own `git show` output, so a
date read from anywhere else is still caught.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…what is true now (plan 034)

Copilot's review of 0f42f67 left three threads.

- r4117298988 (neutral_projection.py): a `topics:` header that listed
  nothing fell back to the derived topics, which contradicted the rule
  the module's own docstring states. The header is now a declaration
  even when it is empty. The document carries no topic, nothing is
  derived for it, and it joins no group. That follows the holder's
  literal-copy principle for title and summary. New case:
  test_an_empty_topics_header_declares_no_topic. With the old rule
  restored, it fails.
- r4117299013 and r4117299033 (tests/test_plain_documents_fixture.py,
  T050's file, merged here from #53): its docstring said T052 and T054
  had not landed. It also said the suite was run by node id outside
  validate's explicit list. T054 makes the first false. The second has
  been false since T036 (#52), when validate began running the whole
  suite. Both paragraphs, and the two phrases beside them ("a future
  topic-based grouping pass", "will require"), now say what is true.
  Only the docstring changes.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…snapshot (plan 034)

Copilot's review of c0747e9 raised two points.

- Its overview said digit-adjacent topic names were tokenized wrongly,
  and they were. The docstring defines a word as a run of three or more
  letters, but the tokenizer took letter-and-digit runs and then dropped
  any run holding a digit, so `Q3planning` lost `planning`. Letter runs
  and digit runs are now separate tokens. New case:
  test_a_word_is_a_run_of_letters_even_beside_digits. With the old
  tokenizer restored, it fails.
- r4117331489 asks that a supplied source_revision be passed into the
  CorpusRef. That is not taken, and the answer is on the product's own
  contract:
  - the seam defines source_revision as the source anchor to pin, and
    the CLI's help for --source-revision says "pin the source_revision
    anchor";
  - openXdox's governed generator records a supplied revision and scans
    the tree it is handed;
  - the holder ruled for T054 that content comes from the working tree,
    per Brett's T022 ruling.
  default_generator's docstring now says so in a paragraph of its own.
  Only the docstring changes for it.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T052's branch was cut from main 80acead. main has since landed T036 (the
required check runs the whole suite) and T037 (the margin). This takes
main at 2d11641, so the PR is measured against the whole-suite check it
will be judged by. There is no conflict: T052's five files and main's
changes are disjoint.

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's overview of openDox-code#57 at c0747e9 said that the
generator seam's "replacement-generator tracking can reject valid
registrations". It opened no thread. The claim is real, and there are
two cases, both reproduced at 4ca45d2:

1. The count of generations under way was global, not tied to the
   registration it counted. A generation from a default that
   unregister() had dropped kept counting against whatever default was
   registered next. So a host was refused over a fresh default with "a
   snapshot is being generated from it now", although nothing was.

2. A generation that outlived its registration recorded its late
   answer against a fresh registration of the same declaration. That
   shut the fresh window, against unregister()'s own promise that the
   record of a generation goes with the registration.

Now every change of registration goes through one helper: register(),
register_default() where it registers, and unregister(). The helper
moves a registration serial on and starts the two records afresh. A
generation carries the serial it began under. When it ends, it touches
the records only if that registration is still current. The generation
is not stopped, and its caller still gets its snapshot.

Tests: 66 -> 70 cases. The new case
test_a_generation_that_outlives_its_registration_touches_no_later_one
runs in four variants, in a process of its own. The follower is another
default or the same declaration, and the host registers either while
the generation runs or after it answers. Three variants are red against
4ca45d2's seam logic, and the fourth guards the new scoping.
test_unregister_clears_the_default_and_its_generation now registers the
same default afresh before the host tries. 15 of 15 mutations are
caught.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T054's branch (openDox-code#57) stacks on this one and rewrites the last
sentence of point 6 of tests/test_generator_seam.py's docstring. The
previous commit re-wrapped that same paragraph, so taking this branch
would have conflicted there. Point 6 is restored to its 4ca45d2 text,
and the new sentence moves to point 5, ONE REGISTRATION, where it
belongs. A trial merge of this head into #57's head 5a6fe63 is clean,
and 94 seam and projection cases pass in the merged tree.

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's review of this PR at bce09c5 (r4117421102) found that
current() read _registered twice: once for its None check, and once for
its answer. A thread that unregistered between the two reads made it
answer None after passing the check, against its SnapshotGenerator-or-
refusal contract. generate() calls it while holding the seam's lock, so
that path was safe, but a bare caller was not. current() now reads the
registration into a local once, and answers that local. It still takes
no lock of its own, because generate() holds the seam's lock, which is
not re-entrant, when it calls it.

Test: test_current_answers_the_registration_it_checked forces the
interleaving deterministically. A line tracer drops the registration
before every line of current() after its first. The test is red against
97b5b01's seam logic ("current() answered None after passing its
check") and green here. 16 of 16 mutations are caught, the new M16
among them.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d507c5) into T055's branch

T055's branch stacks on T054's (openDox-code#57, build/034-p2p-t054-neutral-projection
at 5a6fe63), which stacks on T052's branch at 4ca45d2. T052 has since
moved on to 8d507c5: each registration keeps its own records (bce09c5),
the docstring point T054 edits (97b5b01), and current() reads the
registration once (8d507c5). T055 routes the generate verbs through that
seam, so this takes the seam as it now stands. T052's writer trial-merged
8d507c5 into 5a6fe63 without a conflict.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The snapshot registry and source, the corpus-root predicate, the snapshot
writer and the validator lookup each get a declared seam
(src/opendox/projection_seams.py) and an openDox default of their own
(default_registry.py, default_projection.py), per R1Q10 (a). The four entry
points register the defaults where no host has, in R1Q3 (a)'s pattern. With
nothing registered, a seam refuses and names itself and its call (4.2).
A host's registration replaces a default until a consumer has read it, and
is refused after that.

The generate verbs go through the seams. cli.py's _generate_and_write and
_gate_snapshot, and the default source's regenerate, call
generator_seam.generate() and look the generator up on each call. They write
through the registered writer. A snapshot is validated by the validator
registered for its own kind. The core /snapshot.json arm's four handlers
(_query_key, _read_snapshot, _serve_snapshot, _hosted_entry_refused) and
hosted_ref_refused are serve.py's own now, read over the registry seam, so a
lone openDox builds a server and answers /snapshot.json, /capabilities and
/source/. branch_session's _change_rows goes through the corpus-root seam,
and is_rfc3339_datetime is openDox's own (rfc3339.py). The seams do not carry
either of them.

consumer_reach retires snapshot_registry, snapshot, corpus_root, generator,
find_validator, corpus_root_refusal, generate_snapshot, is_rfc3339_datetime,
hosted_ref_refused, scanned_roots, and the function/constant stand-ins.
LateProjectionRoutes now forwards only _serve_index. workbench.validate_manifest
asks the validator lookup for 'ideation-workbench'. The _default_home_factory
docstrings in cli.py and serve.py now name NEUTRAL_FIELDS as the adapter's
default required_fields. _report prints the neutral kind in place of a
project that snapshot does not carry.

The validator lookup's default for openDox's own kinds is a stand-in that
concludes nothing and names T057. openDox-code#58 carries T057's validator;
T058 wires it in.

F4.1's scan falls from 19 deferred reaches to 11, all T084's. The census of
consumer_reach sites falls from 65 to 29. Whole suite: 2684 passed,
11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… is a declaration

Taken from Copilot's review of c27eac3:

- r4125556296. resolve_within read the dot-directory and dot-file rule
  only from the URL's spelling. A symlink inside the root that led to a
  dot-directory, or to a dot-file with no document extension, got past it:
  link -> .git served /source/link/config, which is .git/config, and a
  symlink named alias.md served .env. The same rule now also runs on the
  canonical path, relative to the root, once symlinks are resolved. A
  symlink to a document is still served. Five cases are added, and
  dropping the new check makes them fail. openXdox's governed
  resolve_within has the same gap, and it is flagged to the holder.
- r4125556360. data_source_from_options ignored token_env, so
  --data-source-token-env on its own was dropped in silence. Any declared
  option is now refused by name, an explicitly empty one included
  (is not None). The CLI's refusal names the flag.
- r4125556420. An extra "by" is gone from the sentence in
  test_source_core_arm.py, and from the same sentence in
  resolve_source_path's docstring and in one assertion message.

Whole suite: 2689 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…g the active entry clears its key

Taken from Copilot's review of ea49c42:

- r4125666251. serve.main computed `url=args.data_source_url or (...)`,
  which turned an explicitly empty --data-source-url into None, and
  `if args.data_source_github` did the same to an empty GitHub slug. So
  the registry never saw either. Each option is now tested against None.
  A GitHub source is composed into the URL only when no URL was given. A
  slug that cannot be composed, such as an empty one, is refused as
  "serve refused: --data-source-github: ..." rather than raising
  ValueError. openDox's own registry then refuses every one by name, and
  new cases cover the empty URL, a GitHub slug and an empty slug.
- The same review's overview (no thread) notes that
  SnapshotRegistry.drop left the active key pointing at a removed entry.
  In that state no later register() became active, and a ref-less
  request met a key with nothing behind it. Dropping the active entry now
  clears the key. A new case covers it, and removing the clear makes it
  fail. openXdox's governed registry drops the same way, and that is
  flagged to the holder.

Whole suite: 2690 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…appened

Taken from the overview of Copilot's review of bfb9c47. That review had
no findings and no threads.

_warn_validator_not_found described a filesystem search, "no validator
for kind X was reachable from <output> ... or from <checkout>", in both of
its cases. When nothing is registered for the kind, the lookup is the
process's own registry, and no path could make a validator reachable, so
the old text pointed at a remedy that does not exist. The warning now
separates the two cases:

- Nothing is registered for the kind. The warning names no path, and
  gives the lookup's refusal whole, on one line. That refusal ends with
  the call that registers a validator. It used to be cut at its first
  line, which ended in "at process start with". workbench.validate_manifest
  made the same cut, and it is corrected there too.
- The registered validator reached no verdict. The warning says it was
  offered both roots to search from, the OUTPUT path first and
  --repo-root second (T092 defect 8), and then gives the validator's own
  reason.

Two assertions pin the cases apart, and both mutations fail them.

Whole suite: 2690 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he entry its refusal checked

Taken from Copilot's review of 244d7b7:

- r4125900060. The default writer's json.dumps ran with allow_nan=True,
  so a registered generator's NaN or infinity was written as NaN or
  Infinity. No JSON reader parses those. The writer now refuses them with
  allow_nan=False. It also refuses a value of no JSON type and a structure
  that contains itself. The refusal is SnapshotNotWritable, a
  ProjectionSeamError, raised before anything is written. A generate verb
  reports it as "generate refused: ...". Five writer cases and a CLI case
  are new.
- r4125900164. On the query-less path, _serve_snapshot read the active
  entry once for the hosted refusal, and _read_snapshot() read it again
  for the body. So a refresh that made a session active between the two
  could pass the refusal with main and serve the session's bytes, which
  FR-048 forbids. The handlers came from openXdox unchanged, and the race
  came with them. The active entry is now resolved once, and that entry
  is handed to the refusal, the body (_read_snapshot(entry)) and the
  headers. A new case makes a session active right after the refusal
  check passes. The response is still main's bytes and ref, and the
  double read fails it.

Whole suite: 2697 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y that entry's pair

Taken from Copilot's review of 19c2678 (r4126022918).

_serve_source resolved the entry twice. It called
registry.resolve_source(repository, ref, rest) for the path, and then
registry.resolve(repository, ref) for the hosted refusal and the headers.
In the unkeyed form both read the ACTIVE entry. A refresh that switched
the active entry from a session to main between the two reads could take
the path from the session's worktree, pass the refusal with main, and
serve the session's file on a hosted plane. That breaks FR-048, the same
race the snapshot arm had.

The arm now resolves the entry once, checks the refusal against it, and
asks resolve_source for that entry's own (repository, ref), never for
"the active entry" again. The path is still confined through the
registry's resolve_source, so the single-entry-point rule that
test_source_core_arm holds is kept. That test now also counts one
resolve() in the arm.

A new case holds a session active until the first path lookup, then
main. The response is 403, and the session's bytes never appear in it.
With the double read restored, the session's file is served and the
case fails.

Whole suite: 2698 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Taken from Copilot's review of 8c09d74 (r4126138808).

serve.py answers /snapshot.json on threads of its own while a refresh
rewrites the snapshot it serves. The default writer used
OutputBoundary.write_output, which truncates the file and then writes
it, so a request could read a truncated snapshot. The writer now sends
the bytes to a temporary sibling and moves them over the target with one
os.replace. A reader sees the whole old snapshot or the whole new one.

The boundary still decides the destination. permit_output, the same
check write_output makes (root, allowlist, refusal and ledger), runs
first, so a refused target leaves nothing behind. The sibling is created
exclusively beside the permitted target, with an ordinary write's mode.
Its name is a dot-file with no document extension, so /source never
serves it, and it is removed if the write or the move fails.

Three new cases. A reader holding the old file still reads the whole old
snapshot, and the new bytes arrive by rename. A failed move leaves the
old snapshot and no sibling. The sibling's name is refused by /source.
Restoring the in-place write, or dropping the cleanup, makes a case fail.

Whole suite: 2701 passed, 11 skipped.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Phase 1 is closing (T049, openxFactory#1204), and phase-2 PRs go READY
after it. This branch was one commit behind main. The merge brings in
T037's floors and its binding-stylesheet cases, and touches no file this
branch adds.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since T036 (#52), `validate` runs the whole suite (`python -m pytest -q`
over the configured testpaths) instead of an explicit list of files. So the
paragraph that said this suite was "not yet wired" into that list, and ran
by node id, has been false since T036 landed. It now says that the required
check collects this suite like every other.

The text is #57's correction of the same paragraph, byte for byte. Copilot
raised it on #57 (r4117299033). It is taken here because the paragraph is
already false at main, so it must not land with #53. When #57 takes this
head, that hunk merges clean.

The opening paragraph ("T052 and T054 ... have not landed") stays. It is
true until T054 lands, and #57 corrects it.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… phrase

Copilot at 521de95 (r4135803935) is right. The case showed only that the
source outside the rain-barrel pair lacks the literal phrase. A later
fixture edit could give the sources another topic in common and still pass,
which defeats the guarantee the case exists for.

The case now compares the two things a topic that sources share can be
derived from when nobody declares one (R1Q13 (a) with (c)): the words of
each document's name, and the other documents each one names.
- Each rain-barrel note's name carries the pair's topic.
- The source outside the pair shares no name word with either note. Only
  the article and conjunction in FUNCTION_WORDS are dropped.
- No document on either side names one on the other, by title or by file
  name.

It needs no projection, because T054 is not in this tree. T054's projection
test proves the grouping itself over this fixture.

The new case goes red on three plants, and the old one passes all three:
- a shared name word ("garden" in both titles), which is Copilot's scenario;
- the unrelated note naming a pair member by its file name;
- a pair member naming the unrelated note by its title.
The real fixture passes, with 20 cases, so the triple does not move.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s shape

Copilot at 7028f61 (r4135883800) is right. Comparing name words and
direct naming misses a topic two sources share through a third document:
if both name the same other document, both inherit its name words.

The case now derives each source's whole topic set: the words of its name,
plus the name words of every other fixture document it names, whatever that
document's station. Then it compares whole sets. That catches a common name
word, one source naming the other, and both naming the same third document.
Only "the" and "and" are dropped, so the comparison is stricter than a
topic rule with a longer stop list, never looser.

The review's overview also asked that the intended source declaration and
count be enforced. The module docstring states both, so the cases now hold
them:
- no document declares `stage: source`, because a source here is a
  document that declares nothing (R1Q13 (a) with (c));
- there are three sources, two of which share "rain barrel".

Six plants each turn the cases red:
- a shared name word;
- the unrelated note naming a pair member;
- a pair member naming the unrelated note;
- both naming grouping-compost-corner.md (the review's scenario);
- `stage: source` on the unrelated note;
- a fourth source.
7028f61's cases pass the third-document plant and the fourth source.
Still 20 cases, all passing, so the triple does not move.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Phase 1 has closed (T049, openxFactory#1204 -> 9d2e5bc3), and phase-2 PRs
go READY after it. This branch was one commit behind main. The merge brings
in T037's floors and its binding-stylesheet cases, and touches no file this
branch adds.

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

🟡 Changes recommended

One newly flushed status line is checked only after process exit, leaving its real-time buffering behavior untested.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread tests/test_standalone_generate_path.py Outdated
brettheap added a commit that referenced this pull request Sep 30, 2026
#66 carries #59's head c2a8ad9, which took main 8ec08e9 (T057 landed,
#58). This branch carried #58's head 753ffa1, whose content the squash
8ec08e9 repeats, so the merge adds nothing of T057's.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap and others added 2 commits September 30, 2026 15:38
Copilot at openDox-code#66 e357477 (r4146289331). The generate-and-open
case looked for "serving until interrupted" only after the interrupt. By
then Python's exit flush delivers an unflushed line anyway, so dropping
that line's flush=True (cli.py) would still have passed. The case now
waits for the line while the child runs, before any request and before
the interrupt.

Checked with the mutant Copilot names (the URL line flushed, the serving
line not): the case now fails, "never printed a line matching ... serving
until interrupted".

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T056 is stacked on #59, so it carries #59's current head.

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 15:44

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 buffering fix is targeted and comprehensively covered by subprocess-level regression tests.

Review effort: Balanced
Findings: None

Resolved since last review (1)

brettheap added a commit that referenced this pull request Sep 30, 2026
#66 now reads the serving line while the server runs (Copilot at #66,
r4146289331), and carries #59's head 183b40f.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@brettheap
brettheap changed the base branch from build/034-p2r-t055-serve-and-generate-standalone to main September 30, 2026 17:10
brettheap and others added 2 commits September 30, 2026 17:14
#59 landed as a squash of 0c946f4, and fa14087's tree equals 0c946f4's
(empty `git diff 0c946f4 fa14087`). This branch had last merged #59 at
183b40f, and #59 moved after that only in default_registry.py and
tests/test_projection_seams.py (`git diff --stat 183b40f 0c946f4`). T056
never touches either file, so the two add/add conflicts take main's side.

Proof that the merge carries exactly T056's delta: the stable patch-id of
`git diff 183b40f 38761c7` equals the patch-id of `git diff origin/main`
against this merge (eee3f01c both times). That delta is four files:
cli.py, serve.py, tests/standalone_child.py and
tests/test_standalone_generate_path.py.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he runner ignores

Measured at a6e953c (this branch with main merged): the whole suite, run as
`nohup pytest ... &`, failed cases 3 and 4 of
tests/test_standalone_generate_path.py with a TimeoutExpired at the
interrupt. The same module passed 6 of 6 in the foreground. The cause is the
runner, not the server. POSIX starts an asynchronous command with SIGINT
ignored when job control is off (a probe under `nohup ... &` reads
signal.getsignal(SIGINT) == 1, SIG_IGN). An ignored signal survives exec,
and Python installs its KeyboardInterrupt handler only where SIGINT was not
ignored. So every server the harness started ignored the interrupt, and
both cases reported how the suite was launched. The estate runs long suites
exactly this way, so any lane that runs this module under nohup would read a
false red.

The fix is in the harness, not the product. A program started with SIGINT
ignored should keep it ignored. tests/standalone_child.py's sitecustomize
now sets SIGINT back to signal.default_int_handler, which models the case's
claim: a Ctrl-C at a terminal. A child that ignores SIGINT itself, after
startup, still does, and case 5 still kills it at the deadline.

Falsifier: test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it
builds that runner in process. It ignores SIGINT while the child starts and
restores it at once, then requires the child to stop on the interrupt with
exit 0 and the parent's handler to be back. Red before the fix (TimeoutExpired
at 10 s, in a foreground run). Green after. The module under
`nohup ... &` went from 2 failed / 4 passed to 7 passed. Mutant M14
(the reset removed) is killed by the new case, and mutants are 14/14 killed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

🟡 Changes recommended

The stage-notice test can pass when two required role keys are omitted from the advertised key list.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread tests/test_standalone_generate_path.py Outdated
Copilot at 149d729 (r4147767447): the case checked each role key as a
word anywhere in the notice. `candidate` is in the document's own name,
candidate-toolshed-rebuild.md, and `source` is in "read as a source". So a
notice whose parenthesized list left both out still passed. Measured:
mutant M15, the list rendered without candidate and source, SURVIVED the
old case (1 passed).

The case now requires the whole list, "(source, grouping, candidate,
selection, submission, completion)", as one substring of the notice. M15 is
killed (1 failed), and so are M5, M6 and M12, the other stage mutants.
The module still passes 7 of 7.

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:17
@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 buffering fix is correct and thoroughly covered by focused end-to-end regression tests.

Review effort: Balanced
Findings: None

Resolved since last review (1)

brettheap added a commit that referenced this pull request Sep 30, 2026
…ult_registry.resolve_source) (#70)

## 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](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>
@brettheap
brettheap marked this pull request as ready for review September 30, 2026 18:27
@brettheap

Copy link
Copy Markdown
Contributor Author

READY at a7bda06 — 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 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit a23e422 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:28:29Z, PR #66 → a23e422 (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
#66 now carries main fa14087 (T055 landed as #59), the harness's SIGINT
reset (149d729) and the whole-list stage-notice check (a7bda06). This
branch last merged #66 at 38761c7, the merge-base, and the merge is clean.
T058's F7.2 case builds its child through tests/standalone_child.py, so it
takes the reset too.

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

Copy link
Copy Markdown
Contributor Author

Record correction (no code change). The landed squash commit a23e422's message cites three 8-character shas with a wrong 8th character, taken from 7-character oneline output: 42a08a3a should be 42a08a3, caa01ca7 should be caa01ca, and e939c315 should be e939c31. The PR body and review replies were corrected before landing; main is not rewritten.

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

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