Skip to content

T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) - #73

Merged
brettheap merged 71 commits into
mainfrom
build/034-p3e-t075-served-bundle
Oct 3, 2026
Merged

brettheap merged 71 commits into
mainfrom
build/034-p3e-t075-served-bundle

Conversation

@brettheap

@brettheap brettheap commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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

Arc: neutral-product-standalone-operability

Plan 034 (specs/034-opendox-standalone-operation/tasks.md, read at openxFactory main 2140f5a7), phase-3 slice P3-E, the door, its first task:

Ruled:

  • R1Q22 (a), 5817152735;
  • batch H's F10.1 amendment: R1Q15 (b), R1Q16 (iii), 5850003126;
  • the drafting word, 5960162524 ("Draft all of them now (Recommended)").

Claimed on openxFactory#656 in 5960231968. Authored ahead as a DRAFT under 5960162524. T063 and T072 have since landed. The holder posts READY.

What the measurement found, at #69's head 19e32f0c

The suite's own runs use an editable install, which puts src/ on the path, so each of them serves the tree's 42 files. pip install ".[local]" installs a wheel, and the wheel is what was measured, from a directory outside the checkout:

  • The wheel carried 41 of the tree's 42 web files. vendor/.gitkeep was missing. setuptools expands package data with the standard library's glob, and web/** never matches a name that starts with a dot. The census (tests/fixtures/web_boundary_census.yaml) counts that file, and pyproject.toml's own note says the glob "ships exactly what that census counts". Slice S5 had measured the same omission and let it stand as a directory keeper (tests/test_gate_loop_contributed.py). 10.2 counts it among the 42, so it ships.
  • The installed opendox generate-and-open --local --no-open served the other 41, each with the tree's bytes, and answered GET /vendor/.gitkeep with 404. F10.1's own fetch of / passed. The content types were text/javascript, text/css and text/html. The bundled server started, migrated ['0001', '0002'], and stopped on SIGTERM with exit 0.
  • Nothing else was missing. The static route (SimpleHTTPRequestHandler, over opendox/web/ beside the installed opendox/cli.py) already serves whatever the wheel carries. So serve.py and cli.py are NOT edited, and this PR joins neither single-writer order.

What changes

  • pyproject.toml: opendox = ["web/**", "web/**/.*"], and the note above the line now counts 42 files across two subdirectories. A pattern whose last part starts with a dot is the one that matches such a name, at any depth under web/. Measured: 42 files from pip's isolated build, 42 from --no-build-isolation over the test extra's setuptools 84.0.0, and 42 from a wheel built out of the sdist (whose tarball carries src/opendox/web/vendor/.gitkeep). This is T072's file in phase 3's single-writer table, and this PR is stacked after T072.
  • tests_runtime/test_served_bundle.py (new, 3 cases, the in-suite falsifier):
    1. the wheel built from this checkout carries every file under src/opendox/web/, and nothing else there;
    2. F10.1's fetch, widened. The wheel is installed under a prefix of its own (T072's recipe), and its opendox console script runs generate-and-open --local --no-open --port 0 from a directory that is not a checkout. The preconditions are checked first: none of the four siblings is importable, opendox --help exits 0, and the WEB_DIR it serves is the wheel's copy. Then / answers with <html, and each of the 42 files answers 200 with the tree's bytes and a type a browser accepts (a JavaScript MIME type for .js, text/css, text/html). The module graph a browser walks from / closes inside the bundle. The walk follows the page's src/href, then each module's static imports, then every dynamic import() target the bundle carries (views/projection-index.js is reached that way). Each module's imports are read with its comments removed.
    3. The walk's import reader, over the tree, with no server: its edges equal an independent line-by-line reading of every relative specifier, which is 80 edges. The edge a commented multi-line clause declares (views/doxbench-editor.js → views/doxbench-state.js) is named, and an in-memory mutant of only that specifier is caught. The process stops on SIGTERM with exit 0, and its bundled server is gone.
    • The child's environment drops every OPENDOX_*, PG*, GIT_* and XF_* name (the suite exports XF_GATE_PRINCIPALS). The fixture repository gets an explicit identity. The child is stopped by SIGTERM, which the local lifecycle reads as its interrupt, so a runner that ignores SIGINT (a nohup job) changes nothing. Under CI, a missing bundled server FAILS the module rather than skipping it, as test_bundled_postgres.py does. The short /tmp state directory's removal is retried, bounded, and asserted (71d24af6). A red mutant run had once left a postgres/data/pg_wal/ behind, because a backend was still writing after its postmaster went.
  • tests/test_gate_loop_contributed.py and tests/test_validator_input_set.py: each pinned the bundle's package-data line verbatim, and each now pins the new line. The recursive-glob guarantee and the setuptools floor are unchanged. S5's measurement in the first docstring (40 entries, 36 under views/) is kept as S5's. T075's re-measurement sits beside it: 41 entries and 37 views from web/** alone, and 42 with both patterns.

10.2a: intent-feed.js is not owed (the declaration)

views/intent-feed.js stays at openxFactory under RULED OQ-F (not_moved / stays_openxfactory_adapter), and it is not owed to openDox. openDox's replacement is views/intent-binding.js. That module reaches ./intent-feed.js only by a dynamic import(), so an absent file is an absent binding, not a broken bundle. It is not counted as a missing file. The 42 do not include it, and the census has no row for it. Case 2 holds the declaration: across the whole bundle, the one module the graph reaches for and does not carry is views/intent-feed.js, reached only dynamically, and the installed server answers it 404.

The falsifier: before and after

f10-1-widened.sh is #1144's F10.1 as batch H amends it: pip install ".[local]", and opendox generate-and-open --local …, with every other line as written. T075's widening is appended after its last line. Each run starts from a clean checkout, in a FRESH venv, with every OPENDOX_*/PG*/GIT_*/XF_* variable unset. Two host preconditions apply, and neither is a change to the command:

  • OPENDOX_STATE_DIR=$HOME/.local/state/p3-t075. This host's /workspace/projects is mode 777, and the bundle refuses a state directory under a world-writable, non-sticky ancestor, by name.
  • A python → python3 shim on PATH, for F10.1's python -m venv.
the script
# #1144 F10.1, as T007 batch H amends it (`pip install ".[local]"`, `generate-and-open --local`),
# with T075's widening appended after its last line: the fetch, made over every
# file of the bundle. Run with bash, from a CLEAN checkout of openDox-code only.
set -euo pipefail
W=$(mktemp -d)                                        # scratch space, resolved at run time (never a host path)
python -m venv --clear "$W/v10"                      # a FRESH environment: nothing already installed stands in
. "$W/v10/bin/activate"
pip install ".[local]"
if python -c "import openxdox" 2>/dev/null; then echo "FAIL: sibling present"; exit 1; fi
opendox --help >/dev/null                       # the console script MUST exist
export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=fixture@example.invalid GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=fixture@example.invalid
R=$(mktemp -d)/plain-documents
cp -r tests/fixtures/plain-documents "$R"   # a FRESH repository (preamble)
git -C "$R" init -q
git -C "$R" add -A
git -C "$R" commit -qm fixture
opendox generate-and-open --local --repo-root "$R" --repository fixture --no-open --port 8080 &
SERVER=$!
trap 'kill "$SERVER" 2>/dev/null || true' EXIT   # cleanup cannot mask the verdict
ready=0
for _ in $(seq 1 30); do
  if curl -sf http://127.0.0.1:8080/ >/dev/null; then ready=1; break; fi
  sleep 1
done
test "$ready" -eq 1                              # a server that never started FAILS here
curl -sf http://127.0.0.1:8080/ > "$W/bundle.html"   # no pipeline: curl's status is the status
grep -qi '<html' "$W/bundle.html"                # and it is really the bundle
# ---- T075's widening: every one of 10.2's 42 files, by the same fetch ----
find src/opendox/web -type f | sed 's|^src/opendox/web/||' | sort > "$W/bundle.txt"
test "$(wc -l < "$W/bundle.txt")" -eq 42         # 10.2's count, from the tree
while read -r f; do
  curl -sf "http://127.0.0.1:8080/$f" > "$W/served" || { echo "NOT SERVED: $f"; exit 1; }   # curl's status is the status
  cmp -s "$W/served" "src/opendox/web/$f" || { echo "NOT THE TREE'S BYTES: $f"; exit 1; }
  echo "served $f"
done < "$W/bundle.txt"
test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/views/intent-feed.js)" = 404   # 10.2a: not owed
echo "F10.1 (as amended), widened to 42 files: PASS"

Before, at #69's head 19e32f0c: F10.1's own fetch of / passes, and the widening stops at the first file the wheel does not carry (rc 1):

  database $HOME/.local/state/p3-t075/postgres/run (bundled, pid 1837880, migrations applied now: ['0001', '0002'])
  validation: opendox-snapshot: 0 violations, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)
  serving http://127.0.0.1:8080/index.html
served app.js
served index.html
served styles.css
NOT SERVED: vendor/.gitkeep
rc=1

After, at 67b8d9da (rc 0). Re-run at d61bb9e9, a7cf2665, cac12d91, a3cf5e9a, 0457b383, d7511388, 1e642b21, 0b0d762c and the final head 308523d9: rc 0 each time, 42 served, F10.1 (as amended), widened to 42 files: PASS.

A finding for T077, about F10.1's fixed --port 8080: the first run at a3cf5e9a read NOT THE TREE'S BYTES: views/doxbench-chat.js. The body it fetched was 101660 B, while the installed file is 98535 B and equal to the tree's. That run's log has no serving line from its own server. Another run on this shared host already answered 127.0.0.1:8080 while this run's bundled server was still starting, and the readiness loop cannot tell whose server answered. The re-run adds one marked T075 guard line before the start, which is not F10.1 text: if curl -s -o /dev/null http://127.0.0.1:8080/; then echo "FAIL: port 8080 already serves"; exit 1; fi. Its log shows its own serving http://127.0.0.1:8080/index.html before the first fetch. The in-suite case binds --port 0 and reads the URL its own server prints, so it is not exposed to this.

  database $HOME/.local/state/p3-t075/postgres/run (bundled, pid 2340484, migrations applied now: ['0001', '0002'])
  validation: opendox-snapshot: 0 violations, by opendox.validator, over its packaged copy opendox-snapshot (sha256 f9e3e111af1d)
  serving http://127.0.0.1:8080/index.html
served app.js
served index.html
served styles.css
served vendor/.gitkeep
served vendor/markdown-it.min.js
served views/account-menu.js
served views/board.js
served views/bullseye.js
served views/canvas-model.js
served views/canvas.js
served views/composed-model.js
served views/display.js
served views/doc-wheel.js
served views/docs.js
served views/doxbench-chat-model.js
served views/doxbench-chat.js
served views/doxbench-editor.js
served views/doxbench-save.js
served views/doxbench-state.js
served views/edit.js
served views/explorer.js
served views/funnel.js
served views/grouping.js
served views/helpers.js
served views/intent-binding.js
served views/lens-model.js
served views/lens.js
served views/lineage.js
served views/model.js
served views/notebook.js
served views/outline-model.js
served views/projection-index.js
served views/repo-selector-model.js
served views/repo-selector.js
served views/settings.js
served views/staging-workbench-model.js
served views/staging-workbench.js
served views/swb-model-intake.js
served views/view_extension.js
served views/viewer.js
served views/wheel-model.js
served views/wheel.js
F10.1 (as amended), widened to 42 files: PASS
rc=0

The in-suite falsifier, LANG=C.UTF-8 python -m pytest -q tests_runtime/test_served_bundle.py:

Mutants: 8 applied, 8 killed (at 0457b383, and again at d7511388)

Each mutant was applied to the committed tree, tests_runtime/test_served_bundle.py was run, and the tree was restored.

mutant killed by
M1: the dotfile pattern dropped (opendox = ["web/**"], #69's line) both cases: ['vendor/.gitkeep'] omitted, then status 404
M2: the recursive pattern made single-level ("web/*") both cases: vendor/markdown-it.min.js and every views/ file omitted, then 404
M3: the static route serves .js as text/plain (a DashboardHandler.extensions_map override) case 2: ('app.js', "served as 'text/plain'"), and every other module
M4: app.js statically imports ./views/board-missing.js case 2's module graph: ('views/board-missing.js', 404). The per-file fetch alone passes this one.
M5: a views/intent-feed.js shipped in the bundle (10.2a broken) both cases: 43 == 42, and the declared absence set() == {'views/intent-feed.js'}
M6: a fake openxdox on the child's path case 2's precondition: a sibling is importable: importable siblings: ['openxdox']
M7: views/projection-index.js, a carried dynamic target, statically imports ./missing-below-dynamic.js case 2's walk: ('views/missing-below-dynamic.js', 404). It survived the walk at d61bb9e9, which did not follow dynamic targets (Copilot's "previously missed" finding).
M8: in views/doxbench-editor.js, only the commented multi-line clause's specifier points at ./doxbench-state-missing.js (Copilot, r4170756761) case 2: ('views/doxbench-state-missing.js', 404); case 3: the named edge absent. It survived a3cf5e9a's reader (2 passed).

The suite and CI

  • Locally, at the final head 308523d9, in a venv installed as validate installs it (--only-binary :all: -c constraints-cpython312-linux.txt -e ".[runtime,test]"), with LANG=C.UTF-8: 3443 passed, 177 skipped, 0 failed (3230 / 177 / 0 at bbd91a3f, 3225 / 177 / 0 at 0b0d762c, 3176 / 177 / 0 at 1e642b21, 3172 / 177 / 0 at d7511388, 3066 / 177 / 0 at 0457b383, 3061 / 177 / 0 at a3cf5e9a, 3035 / 177 / 0 at cac12d91, 3029 / 177 / 0 at a7cf2665). At 67b8d9da it was 3020 passed and 177 skipped, which is T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's 3195 selected plus the 2 new cases. The 177 skips are the DB-backed cases on a machine with no OPENDOX_TEST_DATABASE_URL. CI runs them against its postgres:16 service, and EXPECT_SKIPPED stays 11, because the new module never skips under CI.
  • CI: validate green at 67b8d9da, d61bb9e9, a7cf2665, cac12d91, 71d24af6, a3cf5e9a, 0457b383, d7511388, 1e642b21 and the final head 0b0d762c. SonarCloud green at each since d61bb9e9.
  • SonarCloud: at 67b8d9da the quality gate failed on 3.4% duplication on new code (required ≤ 3%). Its one block was the pipe reader, copied from test_bundled_postgres.py:160-175. In 65b2058f the entry point's streams go to files, and _served_url() polls the stdout file for the flushed URL line. The gate passed at d61bb9e9.
  • Copilot: round 1 (at 67b8d9da) raised 2 low findings, the stale "41 files" note and a precondition that did not name the sibling it found. Both are fixed in d61bb9e9, and both threads were answered and resolved. Round 2 (at 65b2058f) had no new finding. It listed 2 "previously missed" ones, the dynamic-import walk and S5's stale counts. Both are fixed in 4df679b6 and answered in this comment. Round 3 (at 4df679b6) was "Approval recommended", with no finding. Rounds 4, 5 and 6 (at a7cf2665, cac12d91 and 71d24af6) were "Needs a closer look", with no finding, for the stacked, unlanded T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69 alone. Round 7 (at a3cf5e9a) was "Changes recommended" on one finding: the import pattern missed a multi-line clause with commented quotes (r4170756761). It is fixed in d99e5ccc (M8 above), and the thread was answered and resolved. Rounds 8 through 11 (at 0457b383, d7511388, 1e642b21 and 0b0d762c) were "Needs a closer look", with no finding. Round 8 listed that thread as resolved. 0 unresolved review threads.

Not in this PR

  • T077 (the F10.1 run itself, quoted in its own PR) and T076 (the root README.md).
  • One observation, not a gap: the static route takes its content types from the platform's mimetypes table. On Linux and on CI that table answers text/javascript for .js. A host whose table does not would fail case 2 (M3 shows it would be caught). Pinning the types would be a serve.py edit, so it is left to the holder.

🤖 Generated with Claude Code

Summary by Sourcery

Package and verify the complete standalone web bundle so an installed openDox entry point serves all 42 files while keeping intent-feed.js explicitly out of scope.

Bug Fixes:

  • Ensure installed wheels include the complete 42-file web bundle, including the tracked vendor dotfile.

Enhancements:

  • Add runtime coverage verifying that an installed local entry point serves the full browser-loadable bundle and preserves the declared absence of intent-feed.js.
  • Validate bundle module-graph closure, content types, package contents, process lifecycle, and import discovery from an isolated installation.

Build:

  • Update setuptools package-data configuration to include dotfiles beneath the web bundle.

Tests:

  • Add end-to-end tests for wheel contents and serving all web assets from an installed openDox entry point.
  • Update package-data contract tests to enforce the recursive and dotfile glob patterns.

brettheap and others added 30 commits September 28, 2026 18:50
…a collapsed DSN pair (plan 034)

Realizes #1144 13.2 and 13.3, falsifier F13.1's `load_settings` block.

- 13.2: `_refuse_non_postgresql_dsn` refuses either DSN (`OPENDOX_DATABASE_URL`
  or `OPENDOX_MIGRATION_DATABASE_URL`) whose URI scheme is not `postgresql://`
  or `postgres://`, naming the setting and the dialect kept. The keyword/value
  conninfo form (`host=h dbname=d …`) names no dialect at all and is
  unaffected — that syntax is libpq's own grammar, and no other driver reads
  it.
- 13.3: `OPENDOX_MIGRATION_DATABASE_URL` stops being optional in
  `load_settings` (the `Setting` row's `required` flag, `_require` in place
  of `_optional`, and `RuntimeSettings.migration_database_url`'s type). A new
  `_refuse_the_same_dsn_in_both_settings` refuses the two DSNs being the exact
  same STRING, naming `OPENDOX_MIGRATION_DATABASE_URL`, once they are already
  known to agree on where they land
  (`_refuse_two_dsns_that_select_different_schemas`, unchanged, now called
  first): two DIFFERENT secrets for one role still pass, as the existing
  "single-role install" case documents.
- Explicitly NOT in this task: 13.4-13.6 (`OPENDOX_INSTALL_MODE`, T070).
  Nothing here reads or names that setting, and `load_settings`'s only new
  required input is the migration DSN itself.

Every existing call site that built an environment without
`OPENDOX_MIGRATION_DATABASE_URL` needed one once it became required:
`tests_runtime/conftest.py` gains a `migration_dsn` fixture (a `postgres_dsn`
distinguished by a URI fragment, invisible to every DSN reader this module
has); `test_api_endpoints.py`, `test_migrations_apply.py`,
`test_runtime_cli.py` and `test_runtime_surface.py` thread it or a literal
peer through. `test_two_dsns_that_select_different_schemas_are_refused`'s
"a migration DSN that is simply absent" case is rewritten from accepted to
refused, which is the behavior 13.3 changes. Two new tests
(`test_a_non_postgresql_dsn_is_refused_naming_the_dialect_kept`,
`test_the_same_dsn_in_both_settings_is_refused_naming_the_migration_one`)
cover the two new refusals directly.

Measured locally against this change (own Postgres container, bridge IP —
this sandbox's host-mapped loopback ports are unreachable): `python -m
pytest -q` reports 2469 passed, 11 skipped, 1 failed — the one failure is
`tests/test_model_provider_broker.py::test_the_broker_child_inherits_no_
credential_shaped_environment`, already red against unmodified `main`
(2d11641) in the same environment (an `LC_CTYPE` ambient in this sandbox,
unrelated to runtime/config.py). Against `main`'s own reading (2479
selected / 2468 passed / 11 skipped, matching this repo's last recorded CI
triple), this change is +2/+2/+0 for the two new tests — `validate.yml`'s
`Pin the triple` floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`,
`EXPECT_SKIPPED=11`) permit the rise unchanged.

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 review of this PR)

`urlsplit` itself raises for a DSN it cannot parse — MEASURED,
ValueError("Invalid IPv6 URL") for an unbracketed IPv6 host, which
tests_runtime/conftest.py's own postgres_dsn docstring names as "the
ordinary way to mis-set this variable". `_refuse_non_postgresql_dsn` called
`urlsplit(dsn).scheme` unguarded, so that ValueError escaped load_settings
as a bare exception instead of the promised ConfigurationError — the CLI's
boundary catches only ConfigurationError, so a malformed OPENDOX_DATABASE_URL
or OPENDOX_MIGRATION_DATABASE_URL would have printed a traceback instead of
a redacted refusal.

Wrapped the same way _split_url already wraps it for the broker settings
(Copilot review of openDox-code#25, round 24), with DSN-appropriate wording
rather than reused verbatim ("set it to the broker endpoint" does not fit
a database DSN). New test
test_an_unparseable_dsn_is_refused_and_never_raises_a_bare_valueerror
proves both DSNs are covered and that the value is never repeated in the
message.

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

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

Brett ruled on the held conflict (openxFactory#656, on the claim thread for
plan 034's T071, 2026-09-28), choosing "Required only for migrate
(Recommended)" over making the setting required everywhere:

- OPENDOX_MIGRATION_DATABASE_URL goes back to OPTIONAL in `load_settings`
  (the `Setting` row's `required` flag, `RuntimeSettings.migration_
  database_url`'s type back to `str | None`, `_optional` in place of
  `_require`). `load_migration_settings` is unaffected either way — it
  already independently required one, for `migrate`/`reset` alone.
- Both refusals from the previous commits stay, and are now no-ops on an
  ABSENT migration DSN rather than being unreachable: `_refuse_non_
  postgresql_dsn` and `_refuse_the_same_dsn_in_both_settings` each return
  early when the migration value is falsy, exactly the way `_refuse_two_
  dsns_that_select_different_schemas` already treated "nothing to compare"
  as nothing to fault. When BOTH are given, every check still runs, in the
  same order as before (dialect, then schema-mismatch, then collapse).
  It is never defaulted from OPENDOX_DATABASE_URL.
- This matches #1144 13.3's own text and `deploy/compose/docker-compose.
  yaml`'s separation (the `opendox` service never gets a migration DSN;
  `docs/runtime.md` § 3 never lists it as required) — neither file needed
  a change; both already said the now-ruled behavior. The plan's "stops
  being optional" line is a holder-side correction, not part of this PR,
  and #1144's own wording is unchanged.

Reverted the 27-call-site ripple the `required` flip had forced, now that
it is not needed: `tests_runtime/conftest.py`'s `migration_dsn` fixture is
gone; `test_api_endpoints.py`, `test_migrations_apply.py`, `test_runtime_
cli.py` and `test_runtime_surface.py` are back to threading only the
served DSN through every call site that does not itself test the
migration path. All four files after conftest.py are byte-for-byte
`main` again. `test_two_dsns_that_select_different_schemas_are_refused`'s
"absent migration" case is back to ACCEPTED (with a note on why it was
briefly the opposite), which is what the setting being optional again
means for that test.

Added three tests showing the ruled behavior, at the CLI dispatch level
rather than only `load_settings` directly, next to the existing `migrate`
counterpart:
- `test_serve_and_status_load_with_no_migration_dsn_configured`: `status`
  reports no configuration refusal and `settings[…MIGRATION_DATABASE_URL]
  ` as `null` with only the served DSN set; `serve` starts (`ok: true`)
  the same way.
- `test_the_collapse_is_refused_through_the_served_workload_too`: 13.3's
  collapse refusal still fires through `status`, not only through
  `load_settings` called directly, the moment both DSNs are given and are
  the same value.
- `test_migrate_refuses_rather_than_borrowing_the_served_identity`
  (pre-existing, untouched) already covers "migrate refuses without it".

Measured locally against this change (own Postgres container, bridge
IP): `python -m pytest -q` reports 2472 passed, 11 skipped, 1 failed —
the one failure is the same `tests/test_model_provider_broker.py::
test_the_broker_child_inherits_no_credential_shaped_environment` LC_CTYPE
sandbox artifact already characterized as pre-existing and unrelated in
the first commit on this branch. Against main's 2479 selected / 11
skipped in this same environment, this change is +5/+5/+0 (five tests:
the three already on this branch plus the two new ones above) —
`validate.yml`'s `Pin the triple` floors (`MIN_SELECTED=2476`,
`MIN_PASSED=2465`, `EXPECT_SKIPPED=11`) permit the rise unchanged, and
the exact skip count is unchanged.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ttings has

Copilot review of this PR (thread on _refuse_non_postgresql_dsn's own
definition): 13.2's dialect gate was wired into `load_settings` only.
`load_migration_settings` — the loader `runtime migrate`/`reset` actually
use — read OPENDOX_MIGRATION_DATABASE_URL, checked only that it was
non-empty, and handed it straight to `Database`, so a non-PostgreSQL
migration DSN (`sqlite:///x.db`, say) reached the driver instead of being
refused by name at configuration. That is the same un-named failure 13.2
exists to prevent for the served loader, just reachable through the one
path F13.1's falsifier does not call.

One call to the existing `_refuse_non_postgresql_dsn`, right after the
existing empty-DSN refusal and before `database_url`/`migration_database_
url` are both set to the same value. New test
`test_migrate_refuses_a_non_postgresql_migration_dsn_at_configuration`
is the dialect-refused twin of the existing `test_migrate_and_reset_need_
no_served_identity_and_no_broker`, which already shows an unreachable but
valid-dialect migration DSN getting PAST configuration — this one shows a
wrong-dialect one refused AT configuration, naming the setting and never
repeating the DSN.

Measured locally (own Postgres container, bridge IP): 2473 passed (+1),
11 skipped, 1 failed (the same pre-existing, unrelated LC_CTYPE sandbox
artifact) — the new test is the only change to the count.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n --local (plan 034)

OPENDOX_INSTALL_MODE (`local` | `hosted`, default `hosted`) is read in
runtime/config.py beside OPENDOX_OIDC_ISSUER and decides the install shape
(#1144 13.4). `generate-and-open --local` makes the same selection
(R1Q15 (b), as T007 batch H's 13.4 addendum reads); with neither the install
is hosted (13.5).

- A flag and a setting that disagree (`--local` beside
  OPENDOX_INSTALL_MODE=hosted) are refused, naming both. This is plan 034's
  fail-closed reading (Principle VII); no answer rules it and batch H does
  not write it into #1144.
- LOCAL needs no broker: issuer, audience and key-set URL are empty.
- LOCAL binds loopback only, with no opt-in. A non-loopback `--host` or
  OPENDOX_BIND_HOST is refused, naming the rule. The set is serve.py's own
  LOOPBACK_HOSTS, and a test holds the two equal.
- HOSTED, set or by default, with no issuer refuses, naming
  OPENDOX_OIDC_ISSUER. generate-and-open asks the issuer first, so a run with
  nothing configured names it and `--local`. The hosted mode is otherwise
  unchanged (13.6).

Holder readings on openxFactory#656 (Brett may overrule):
- `runtime serve` refuses under local, because the API's identity is the
  broker's.
- `runtime status` under local reports broker_keys "not configured (local
  mode)" and does not count it as a fault.
- A broker setting beside local is refused by name.
- An unrecognised mode value is refused, case-sensitively.

The document server's generate-and-open resolves the shape before it scans,
mints or binds anything. The hosted path loads the whole runtime
configuration (R1Q16 (i); 13.4a).

Also:
- deploy/compose/.env.example gains OPENDOX_INSTALL_MODE=hosted, which
  test_every_runtime_setting_is_documented_in_env_example requires of every
  SETTINGS entry.
- tests/test_doxbench_entrypoint.py's fixture now selects `--local` and
  scrubs the runtime settings, since the unset default is hosted and refuses
  with no issuer.

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

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

A LOCAL install (T070's `generate-and-open --local`, or
OPENDOX_INSTALL_MODE=local) now brings its own database (#1144 13.1, as
T007 batch H's addendum reads; RULED R1Q16 (i)-(iv), 5850003126).

- (i) `generate-and-open --local` starts a PostgreSQL server as its own
  direct child (subprocess.Popen, never pg_ctl) and reports it. The
  document server a user reaches is the process that owns it.
- (ii) The server is started AND migrated: initdb once, an idempotent
  bootstrap (the database, the served role, and the compose stack's grants
  narrowed to this install's owner), then migrations.MigrationRunner as the
  owner, with the served role and database declared.
- (iii) It ships as the `opendox[local]` extra: `opendox[runtime]` plus
  `pgserver>=0.1.4`, whose bundled binaries link only libc and libz. The
  `test` extra joins it, so F9.1's `.[test]` install still runs every case.
- (iv) It stops with the entry point. SIGTERM is read as the Ctrl-C the
  serve loop already stops on, followed by a fast shutdown.
  PR_SET_PDEATHSIG is the backstop when the entry point is SIGKILLed.
- Its data and socket directories live under OPENDOX_STATE_DIR, a new
  setting that defaults per user and must be absolute. The server listens
  on a 0700 Unix socket with listen_addresses empty: no TCP listener at all.
- Both DSNs are supplied: two users over the one socket, which pass T071's
  three checks. An operator DSN beside `local` is refused by name, joining
  T070's broker settings (a holder reading on openxFactory#656).
- `runtime status` reports database_bundle (data_dir, socket_dir, pid).
  `runtime migrate` under local migrates the bundle.

THE MIGRATIONS GAP (assigned to T072 by the holder). pyproject maps
migrations/*.sql into the wheel's data directory (share/opendox/migrations),
without moving the root migrations/ that the image copies. An unset
OPENDOX_MIGRATIONS_DIR is `migrations` wherever the working directory has
one (today's default, unchanged), and otherwise the copy the installed
distribution records. A test builds the wheel, installs it outside the
checkout, runs from a directory with no migrations/, and migrates the
bundled server.

Also:
- deploy/compose/.env.example gains OPENDOX_STATE_DIR=, because every
  SETTINGS entry is named there.
- tests/test_doxbench_entrypoint.py stands the bundle in, since those cases
  test the model port.
- T070's own tests stop passing DSNs beside `local`.

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

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

The lock is extended under its own pins (`-c` this file), in a clean
cpython 3.12.3 venv on linux x86_64, as its header asks. Five pins are new:

- pgserver 0.1.4, with its own psutil, platformdirs and fasteners;
- setuptools, for the wheel-install test's offline build.

No earlier pin moved.

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

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

`tests/test_install_mode_entrypoint.py`'s `corpus` fixture ran `git commit`
under the caller's global and system git configuration. A global
`commit.gpgsign=true` therefore failed the setup before any install-mode
probe ran. Measured with a hostile global config (`commit.gpgsign = true`,
`gpg.program = /bin/false`): 7 errors at b50e3b1, 14 passed here. The
fixture now sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1, as
tests/test_checkout_head.py does.

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

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

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

load_migration_settings recorded OPENDOX_INSTALL_MODE=local but never asked
refuse_what_a_local_install_cannot_be. So `runtime migrate` and a
confirmed `runtime reset` accepted OPENDOX_OIDC_ISSUER, OPENDOX_OIDC_AUDIENCE,
OPENDOX_OIDC_JWKS_URL or a non-loopback OPENDOX_BIND_HOST beside `local`,
which load_settings and generate-and-open both refuse. They now refuse them
at configuration, before any database is reached.

Seven new cases:
- the three broker settings x {migrate, reset};
- the bind.

All seven fail at 32683e8 and pass here. Full suite: 2538 selected, 2527
passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 525f61c makes `load_migration_settings` refuse what a local install
cannot be (Copilot review of openDox-code#67). T072 had already restructured
the same lines: under `local`, it asks that refusal and then takes the
bundle's migration DSN. The conflict resolves to T072's structure, with
T070's reason carried into its comment. The refusal is asked once, before
the bundle's DSN is read.

The two merged cases now set the local shape as T072 defines it, with the
mode and the state dir and no operator DSN. Beside `local` a DSN is itself
refused (T072), so a merged case that set one would have tested the DSN
refusal rather than the broker or bind refusal it names.

Full suite: 2552 selected, 2541 passed, 11 skipped, 0 failed. A mutant that
drops the refusal from the migration loader fails all 7 merged cases.

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

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

When the runtime extra is absent, `runtime status` returns early, and that
return said `broker_keys: "not probed"` for every install. A local install's
broker is not configured whether or not the extra is present. That answer
comes from the configuration, not from a probe, so the early return now gives
the local install the answer the full report gives: `"not configured (local
mode)"`, with `broker_discovery: null`. Both returns write it through one
helper, so the two cannot drift. A hosted install's early return still reads
"not probed", as before (13.6).

The branch is covered now, so its `pragma: no cover` goes. A new case runs
both shapes with `opendox.runtime.db` absent from `sys.modules`. Before
(`525f61c`'s runtime/cli.py): local 1 failed and hosted passed. After: both
pass. Four mutants of the fix are killed. Full suite: 2540 selected, 2529
passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 02dadc5 makes `runtime status`, when the runtime extra is absent,
report a local install's broker as not configured on the early return too
(Copilot review of openDox-code#67). It merges cleanly: T072's
`database_bundle` report comes before that return, in another hunk.

The merged case sets the local shape as T072 defines it, with the mode and
the state dir and no operator DSN. It also asserts that the bundle is
reported on the early return (`database_bundle` present for local, `null`
for hosted).

Full suite: 2554 selected, 2543 passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tings' broker invariants are scoped (Copilot review)

A local `status` returns `ok` on the database's verdict alone. Both earlier
local cases forced a database fault and asserted exit 1, so a regression
that also counted the absent broker as a fault would still have passed. A
DB-backed case now runs `status` for a local install against a migrated
schema on the suite's own server (`database` and `postgres_dsn`, with the
schema selected in the DSN). It asserts `ok` true, exit 0, the database
reachable with nothing pending and no drift, and the broker reported as not
configured and never probed. Measured: with the local return mutated to
`ok=False`, this case fails and the other 40 in the module pass.

`RuntimeSettings`' docstring said that a local install's issuer and audience
are empty and that a hosted one always carries a real issuer. That is true of
`load_settings` alone. `load_migration_settings` carries the migration
sentinels in either shape. The docstring now scopes each statement to its
loader.

Full suite: 2541 selected, 2530 passed, 11 skipped, 0 failed.

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

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

Copilot's reviews at 95fe16f and 32db5d8 opened ten threads. Eight are fixed
here. The two about the server package (PostgreSQL 16.2, no wheel for 3.13)
wait on the holder.

- Migrations (r4139811473, r4139880241). An explicit OPENDOX_MIGRATIONS_DIR
  is used as given. Unset, a LOCAL install uses only the copy its own
  installation carries, and never the working directory's: its entry point
  runs every migration as the bundle's owner, and the canonical gate pins
  0001 alone. Where the installation carries none, it is refused, naming the
  setting. The installation's copy is the source tree the module was
  imported from (src/ beside a pyproject.toml naming opendox), then the
  RECORD of the distribution that holds the running module, and never
  another one found by name. A HOSTED install's unset default is unchanged
  (13.6).
- The pid (r4139811555). A postmaster.pid is believed only for this data
  directory's postmaster, as the kernel reports it: an executable named
  postgres whose working directory is the data directory. Another user's
  process is never believed. A lock that /proc proves stale is removed
  before the launch, so a recycled pid no longer holds the bundle.
- initdb (r4139880213). It runs into an attempt directory beside the data
  directory, which is renamed into place only on success. An attempt whose
  process is gone is removed. A non-empty data directory that holds no
  cluster is refused and left untouched.
- start() (r4139880279). Directories, initialize, launch, wait, bootstrap
  and migrate are one guarded operation, and every failure is the one named
  refusal (phase and class name), with anything started stopped.
- Interrupts (r4139880267). SIGTERM or Ctrl-C anywhere in the local
  lifecycle is a clean stop: no traceback, the bundle stopped, the handler
  restored first. Nothing was served, so the exit is 128 + the signal number.
  A served run ended by SIGTERM still exits 0.
- Refusal wording (r4139880298). Broker settings and operator DSNs are two
  classes, and each is refused with its own reason.
- The test helper (r4139811584). The launch helper is bounded by its
  deadline, through a selector. Measured with a silent 8 s child and a 1 s
  deadline: the old loop returned after 8.0 s, the new one after 1.0 s.

tests_runtime/test_local_lifecycle.py (new, hermetic) holds these cases,
plus a real-server stale-lock case and the helper's own case in
test_bundled_postgres.py. Against ac61596's source, 17 of the module's
first 18 cases fail. The one that passes is the hosted default, which is
unchanged on purpose. All 23 mutants of the fixes are killed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 859b37b adds a DB-backed case proving that a healthy local
`status` exits 0, and scopes RuntimeSettings' broker invariants to their
loader (Copilot review of openDox-code#67). It merges cleanly.

Here the case uses the local install's own database. Beside `local` an
operator's DSN is refused (T072), so the case starts the bundled server
on a fresh state directory and asks `status` about it. With the local
return mutated to `ok=False`, it fails and the other 42 cases in the
module pass.

Full suite, with this PR's fourth fix round (5e52872): 2580 selected,
2569 passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es by name (Copilot review)

Copilot's review at ac61596 opened two more threads, both real.

- r4139938402: the old fallback believed any pid it could not inspect. So a
  process that exited between the signal check and the /proc read, or any
  pid on a platform without /proc, counted as the server. Round 4 already
  treated a vanished process as gone on the /proc path. This round makes
  the rule total. `running_pid` believes a pid only when the kernel proves
  it is this data directory's postmaster. Where nothing can be asked (no
  /proc: macOS, the BSDs), it believes nothing, and this module does not
  refuse a start over it. PostgreSQL's own interlocks, the lock file's
  live-pid check and the shared-memory check, still refuse a second
  postmaster, so this never yields two servers, and never a refusal over a
  process that is not one. A lock that cannot be proven stale is left for
  PostgreSQL to judge. The price on such a platform is a `status` with no
  pid. That is recorded, not hidden: the standard library has no portable
  way to ask, and a third-party module here would be an undeclared runtime
  dependency (test_consumer_reach). Measured before: round 4's source with
  no /proc, and a python decoy in the data dir, reported the decoy's pid.
  ac61596's source reported a pid that had already exited.
- r4139938444: `Path.expanduser()` raises RuntimeError for an unknown
  `~user`, and `Path.home()` does the same where there is no home. Both now
  refuse by name, as ConfigurationError naming OPENDOX_STATE_DIR. A hosted
  install still never reads the setting and is not refused over it (13.6).

Five new cases fail against 28bdccd's source and pass here. Five mutants of
the fixes are killed. Full suite: 2584 selected, 2573 passed, 11 skipped,
0 failed.

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

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

The module docstring called every case hermetic. Since the fourth fix round,
one is not: `test_runtime_status_of_a_healthy_local_install_exits_zero` takes
the suite's `postgres_dsn` and `database` fixtures, because a healthy local
`status` exits 0 only against a database that answers. The docstring now
names that case and says it is skipped without Postgres and fails under CI,
like every DB-backed case. It says the rest stay hermetic. Docstring only:
the module runs 41 passed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 026f00e corrects the install-mode module's docstring. It now names
the one DB-backed case instead of calling every case hermetic (Copilot
review of openDox-code#67). Here that case starts the local install's own
bundled server, because beside `local` an operator's DSN is refused, so the
merged sentence says so. Docstring only: the module runs 43 passed.

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

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

The data-files note pointed readers at
`opendox.runtime.config.packaged_migrations_dir`, which fix round 4 replaced
with `installation_migrations_dir`, the source tree first and then the RECORD
of the distribution that holds the running module. The note now names that
function and says what it asks.

The packaging case now also checks that every
`opendox.runtime.config.<name>` pyproject.toml names exists, so a stale
pointer cannot come back. Against 4aed627's pyproject.toml it fails, naming
`packaged_migrations_dir`. Here it passes. Full suite: 2584 selected, 2573
passed, 11 skipped, 0 failed.

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

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

Copilot's review at a0fb7c8 opened three threads, and SonarCloud raised a
reliability finding. All four are fixed here.

- PG* defaults (r4146787926). libpq fills every parameter a DSN leaves
  unset from the environment. PGHOSTADDR outranks the socket `host` and
  sends the connection to TCP, PGSERVICE fills parameters from a service
  file, and PGOPTIONS sets the session's parameters. No DSN can name every
  parameter, and an explicitly empty `service` is itself an error. So
  `bundle.isolated_from_libpq_environment` lifts every PG* variable out of
  os.environ for the duration and puts it back afterwards. It wraps
  `generate-and-open --local`'s whole lifecycle and the runtime CLI's verbs
  under `local`. A hosted install's libpq is untouched (13.6). With
  PGHOSTADDR=192.0.2.1, PGSERVICE=no-such-service and PGOPTIONS=-c
  search_path=nowhere set, the real entry point still starts, migrates and
  serves its own server, and `runtime status` still finds it.
- The socket's path (r4146787852). Before the socket directory is chmodded
  (a chmod follows a symlink), the resolved path is checked. The state dir,
  postgres/ and run/ must be real directories owned by this user and
  writable by no one else. Every ancestor must be owned by this user or by
  root, and must be sticky if every user can write it, or if a group other
  than this user's own can write it. Anything else is refused by name.
- A relative HOME (r4146659876). It is refused for the default state
  directory, which would otherwise depend on the working directory.
- SonarCloud S6466. server_binaries no longer indexes a list. It takes the
  first search location or none, and both refusal shapes have a case.

Against a0fb7c8's source, 8 of the new cases fail and the positive control
passes. 11 mutants are killed. Full suite: 2595 selected, 2584 passed,
11 skipped, 0 failed.

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

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

Copilot's review at 0f77d5c opened two threads. Both were real.

- r4147004990: a user's primary group can have other members, so a 0775
  ancestor is not private. Every ancestor that anyone else can write, a
  group included, must now be sticky. The round-7 allowance for the user's
  own group is gone, and its positive control is now a refusal case. The
  sticky shape (/tmp) is still the control.
- r4147005063: resolving the configured path before checking it discarded
  the path that was actually configured. A link on that path could be
  repointed afterwards, while the bundle kept using the unresolved paths.
  Now:
  - the ancestors of BOTH the configured path and the resolved one are
    checked;
  - every symbolic link on the configured path must be owned by this user
    or by root;
  - `..` is refused in OPENDOX_STATE_DIR and XDG_STATE_HOME (and in a
    derived HOME), so the configured components are the ones the kernel
    walks.
  A user's own link to a private directory is still accepted.

Against 0f77d5c's source, 5 of the new cases fail and the 4 controls pass.
5 mutants are killed. Full suite: 2601 selected, 2590 passed, 11 skipped,
0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… peer (RULED 5916000030 items 2, 3)

Brett's rulings on openxFactory#656 (comment 5916000030) cover two things.

Item 2, "pixeltable-pgserver (Recommended)". The `local` extra's carrier is
now pixeltable-pgserver>=0.6.0, the maintained fork of pgserver. Only its
binaries are used, found under pixeltable_pgserver/pginstall/bin. Measured
on the installed 0.6.0 wheel:
- initdb and postgres report PostgreSQL 16.14;
- postgres links libz, libpthread, librt, libdl, libm and libc only, and
  initdb links the wheel's own vendored libpq through $ORIGIN;
- the highest GLIBC symbol any binary or server module needs is 2.25, and
  the wheels are tagged manylinux_2_27/2_28;
- the licence is Apache-2.0 (dist-info LICENSE and classifier);
- the cp312 x86_64 wheel is 24,704,230 bytes;
- wheels exist for cp310 to cp314.
The lock was re-resolved in a clean environment under the existing pins
less pgserver. The only line that moved is pgserver==0.1.4 ->
pixeltable-pgserver==0.6.0.

Item 3, "Peer auth + accept (Recommended)".
- initdb now runs with --auth-local=peer --auth-host=reject.
- Before every launch the bundle writes pg_hba.conf and pg_ident.conf
  atomically, mode 0600. pg_hba.conf holds one local rule, peer map=opendox,
  and host reject for IPv4 and IPv6. pg_ident.conf maps the running OS user
  (from the password database), and nobody else, to opendox and
  opendox_runtime.
- listen_addresses stays empty.
- An OS user name the map cannot hold plainly is refused, as is a uid with
  no password entry.
- A cluster that an older build left as trust is put back to peer on its
  next start.

The server's own reading proves it. pg_hba_file_rules has exactly those
three rules and pg_ident_file_mappings exactly those two mappings, and
system_user is peer:<os user> for both roles. The same OS user asking for a
role outside the map is refused ("peer authentication failed").

Against 379fbb1's source and packaging, 13 of the new cases fail. Nine
mutants of the carrier and the authentication are killed. The auth mutants
are also killed by the real-server cases alone. Full suite: 2613 selected,
2602 passed, 11 skipped, 0 failed.

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

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

Copilot's review at 84a6c04 made three points, all real.

- r4147680113: the tree check left out postgres/data. An existing data
  directory, a broken link included, now joins the own-tree check: a real
  directory, owned by this user, writable by no one else, not a link. A link
  to a cluster elsewhere would otherwise have been given this install's
  authentication files and launched outside the state tree. A fresh data
  directory needs no check, because _initialize renames it into place.
- Fresh directories and the umask (overview, previously missed).
  mkdir(parents=True) creates intermediate directories with the default mode
  less the umask. Under umask 0002, a fresh ~/.local/state/opendox would
  create group-writable parents, which the tree check then refused. Each
  missing component is now created on its own and set to exactly 0700,
  whatever the umask.
- Readiness (overview, previously missed). A successful connection proves
  only that some server answered. Two entry points racing from an idle
  state both launch, and the loser's postgres lives a moment while the
  winner's socket answers. So readiness now also needs the data directory's
  lock file to name this child. Otherwise the wait goes on until this child
  exits and is refused. One check after the connection is enough, since the
  lock admits one postmaster per data directory and the socket directory
  belongs to exactly one data directory. A before-check was tried and
  dropped: no mutant distinguishes it.

Against 84a6c04's bundle.py, all 6 new cases fail. 4 mutants are killed.
Full suite: 2619 selected, 2608 passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main now carries T054 to T058, T055's follow-up (#70) and T056's
standalone test. This PR edits src/opendox/runtime/config.py and
tests_runtime/test_runtime_cli.py, and main touches neither, so the merge
is clean.

Full suite on the merged tree: 3061 selected, 3050 passed, 11 skipped,
0 failed. EXPECT_SKIPPED=11 holds exactly, and the floors are met.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T071 (#60) now carries main 047bb4f: phase 2, with T054 to T058, T055's
follow-up #70 and T056's standalone test. Git auto-merges cli.py and
test_doxbench_entrypoint.py without a conflict: main's
_refuse_empty_source_options sits after the install shape is resolved, and
--local still precedes --host.

Four callers on main relied on generate-and-open's old default, and since
this PR an unflagged run is HOSTED and refuses without its broker's issuer.
They get --local in the next commit, which T070 owes now that T056 has
landed.

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

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

Since this PR, generate-and-open with neither --local nor
OPENDOX_INSTALL_MODE=local is a HOSTED install, which refuses without its
broker's issuer (#1144 13.4, 13.5). Four cases that landed on main with
phase 2 run generate-and-open as the single-user install and relied on the
old default, so each now says --local:

- tests/test_standalone_generate_path.py (T056), case 3: the server starts,
  answers and stops. The module docstring names the change and moves F10.1's
  plain-install run to T077.
- tests/test_post_render_validator.py (T058),
  test_generate_and_open_gives_the_same_verdicts, both fixtures.
- tests/test_projection_seams.py (T055),
  test_generate_and_open_refuses_an_empty_source_option_before_its_run_dir.

No case means hosted, so none takes a hosted fixture. Before this commit,
all four fail on the merged tree with the hosted issuer refusal; after it
they pass. Three mutants of the local path are killed, each failing all
four cases: --local ignored, local refusing its own loopback default, and
local also asking for the hosted issuer. Full suite: 3117 selected, 3106
passed, 11 skipped, 0 failed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070 (#67) now carries T071's merge of main 047bb4f: phase 2, with T054
to T058, T055's follow-up #70 and T056's standalone test. It also carries
the four generate-and-open callers that now say --local. Git auto-merges
pyproject.toml (main's validator package data beside this PR's local extra
and data files), src/opendox/cli.py and tests/test_doxbench_entrypoint.py
without a conflict.

On their own, the merged callers run --local, and here that starts the
bundled server. Three of them would do so under the user's own state
directory. The stand-in driver no longer stands in for anything, so
three bundled cases fail on this merge alone. The next commit takes both
in hand.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l child keeps its own state

Phase 2 is on this stack's base, so four things T072 owed at its merge
round are done.

- The stand-ins go. tests_runtime/local_entrypoint_driver.py is deleted.
  Its stand-ins patched names that T055 has since replaced, so on the
  merged tree they stood in for nothing, and all three background cases
  failed: the real corpus-root check refused the stand-in corpus, a
  directory with no repository. test_bundled_postgres.py now launches
  `python -m opendox.cli generate-and-open --local`, with the validator on,
  over T050's tests/fixtures/plain-documents copied into a fresh repository,
  as F13.1's preamble does.
- Every cheap refusal comes before the database start. main's T055 added
  _refuse_empty_source_options to the generate path, so the local path
  asks it before it builds the bundled server, beside the corpus-root and
  generated-at refusals. test_projection_seams.py's empty-option case now
  carries a tripwire bundle, so a regression neither starts a server nor
  passes.
- No child touches the user's state directory. A `generate-and-open
  --local` child now starts the bundled server, and OPENDOX_STATE_DIR
  defaults to the user's own ~/.local/state/opendox. tests/standalone_child.py
  gives every child a fresh, short, private state directory under /tmp and
  removes it when the child is stopped. Measured before: the three --local
  children of T056 and T058 initialized a cluster in the (sandboxed) default
  state home.
- T056's case 3 asserts that its bundled server's data directory is under
  the child's own state directory while serving, and that the directory is
  gone after the stop.

Four mutants are killed. They drop the cheap refusal, the private state
dir, its removal, and the fixture's repository. Full suite: 3195 selected,
3184 passed, 11 skipped, 0 failed. Nothing is left under
~/.local/state/opendox or /tmp/odx-child-*.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d.js is not owed (10.2, 10.2a)

Plan 034 T075 (#1144 10.2 and 10.2a). The falsifier is F10.1's fetch as
T007 batch H amends it (a ".[local]" install, "generate-and-open --local"),
widened to every one of the bundle's 42 files.

Measured at openDox-code#69's merged head (19e32f0): an installed wheel
carried 41 of the tree's 42 web files, and the installed console script
answered GET /vendor/.gitkeep with 404. setuptools expands package data with
the standard library's glob, and "web/**" never matches a name that starts
with a dot. The other 41 were served with the tree's bytes and a type a
browser accepts (text/javascript, text/css, text/html), and F10.1's own
fetch of / passed.

- pyproject.toml: "web/**/.*" beside "web/**". The wheel now carries all
  42 files, from pip's isolated build, from --no-build-isolation, and from
  a wheel built out of the sdist.
- tests_runtime/test_served_bundle.py (new): the wheel is held to the tree,
  and the installed opendox console script, run with --local from a
  directory that is not a checkout and with no sibling importable, answers
  /, then every one of the 42 files with the tree's bytes and a browser's
  content type. The module graph a browser walks from / closes inside the
  bundle. The one module it reaches for and lacks is views/intent-feed.js,
  only by a dynamic import(), and it answers 404 (10.2a, a declaration).
  It stops on SIGTERM, and its bundled server stops with it.
- tests/test_gate_loop_contributed.py, tests/test_validator_input_set.py:
  both pinned the bundle's package-data line verbatim, and now pin the new
  line. Slice S5's docstring had recorded the dotfile's omission as benign;
  10.2 counts the file, so it ships.

serve.py and cli.py are not edited. The static route already served
everything the wheel carried.

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

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

🔵 Needs a closer look

It remains a blocked draft stacked on the unmerged draft PR #69.

Review effort: Balanced
Findings: None

brettheap and others added 3 commits October 3, 2026 14:00
…view)

The bundle's DSNs left search_path implicit, so PostgreSQL used its default,
"$user", public. The owner (opendox) and the served role (opendox_runtime)
are different users. So in a reused cluster holding a schema named after
either role, the migration's ledger and the served workload's reads landed
in two different schemas. That is the split-schema fault
_refuse_two_dsns_that_select_different_schemas refuses for an operator's
DSNs.

DatabaseBundle.dsn now appends options=-c search_path=public
(BUNDLE_SEARCH_PATH_OPTION, over the new BUNDLE_SCHEMA). public is the
schema _bootstrap grants in, and schema_selected_by reads it back from both
DSNs.

New real-server case: a running bundle gains schemas `opendox` (the
owner's) and `opendox_runtime` (authorization opendox_runtime). After a
restart:
- both roles' current_schema() is public;
- both read the same ledger;
- the restart applies nothing;
- status is reachable with nothing pending.
Without the pin, the restart migrates into `opendox` and fails with
RuntimeAccessMissingError. The two-DSN layout case also asserts that both
select public. Both mutants are killed: the option dropped, and the path
left at "$user", public.

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

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

_identity read every OSError from /proc/<pid> as "gone", and _serves turned
that into "not this server". But a procfs mount option or a security module
can withhold a LIVE process of this very user, a PostgreSQL server
included. So _remove_a_proven_stale_lock could unlink the postmaster.pid of
a live server, and a second server was launched beside it.

- _identity now returns None only when the entry has vanished
  (FileNotFoundError or ProcessLookupError). Any other OSError raises
  _Withheld, a LookupError, so _serves answers None (unknown), not False.
- _remove_a_proven_stale_lock fails closed on a withheld live pid. The lock
  is kept, and the start is refused by name: it names the pid and says
  "will not describe", and tells the user to stop that process or remove
  the lock once they know it is not a server on this data directory.
- With no /proc at all the lock is still left for PostgreSQL to judge, as
  before.
- A gone pid is still "not ours", and a lock /proc proves stale is still
  removed.

New case (Linux): an existing cluster whose lock names this process's own
live pid, with that pid's /proc links withheld. _serves is None, the start
is refused by name, the lock is kept, and the stand-in server never runs.
Afterwards a gone pid reads False, and the now-describable stale lock is
removed. The case fails against 4a9dbe9's bundle.py. Both mutants are
killed: withheld read as gone, and withheld left to PostgreSQL.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
openDox-code#69 moved from 52a2b8d to c04690f, its final pre-squash
head: fix round 17, and fix round 18 (search_path=public in both bundle
DSNs; a live pid that /proc withholds fails closed). Merged, never
rebased, so #73 contains #69's final head before #69 squashes. The plain
merge is clean, and against #69 the diff is still T075's four files.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@brettheap
brettheap changed the base branch from build/034-p3i-t072-bundled-postgres to main October 3, 2026 14:16
#69 landed as squash 5e7ab00 over 1130e99 (#63, T080). This branch
already contains c04690f, #69's final pre-squash head (bbd91a3). Merged
with git merge-tree --merge-base c04690f, because the squash's own
ancestry does not hold c04690f. Both parents are recorded, and nothing is
rebased. c04690f..5e7ab00 changes exactly #63's five files, so the
merged tree is main plus T075's four files and nothing else.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 3, 2026 14:33
@brettheap brettheap changed the title DRAFT (phase 3, after T063): T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) Oct 3, 2026
@sonarqubecloud

sonarqubecloud Bot commented Oct 3, 2026

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 packaging fix is focused and comprehensively exercised without unresolved correctness issues.

Review effort: Balanced
Findings: None

@brettheap
brettheap marked this pull request as ready for review October 3, 2026 14:59
@brettheap

Copy link
Copy Markdown
Contributor Author

READY at 308523d — phase 3 is open (T063 landed, #1218 → a883bbf6); T072 landed (#69 → 5e7ab00). The holder checked: validate and SonarCloud green; Copilot's latest review at exactly 308523d is "Approval recommended", Findings: None; 0 unresolved threads; no closing keywords in the body or any commit. #73 contains #69's final pre-squash head c04690f and merges main 5e7ab00 (merge-tree --merge-base c04690f); its diff against main is T075's four files. T075's After line (T056, T038, T063, T069, T072, T007 batch H) is met; T073 (#72) is a sibling, not a predecessor. Brett: draft phase 3 ahead, land in plan order when green. 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 6 hours and 31 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 9197ccd into main Oct 3, 2026
4 checks passed
@brettheap

Copy link
Copy Markdown
Contributor Author

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

LANDED — lane openxfactory-4, 2026-10-03T15:00:18Z, PR #73 → 9197ccd (opensoft/openDox-code main; plain gate)

Brett: land phase 1 / phase 2 PRs when green

brettheap added a commit that referenced this pull request Oct 3, 2026
Brings in #69 (T072, the bundled PostgreSQL server and the `local`
extra), #73 (T075) and #64 (broker-path credential hardening). No file
this branch edits changed on main; the merge is clean.

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 added a commit that referenced this pull request Oct 3, 2026
main 90ac703 is #72's squash over 8e37782, which brings #73 (T075, the
served bundle) and #64 (broker-path credential hardening). The tree is
written with `git merge-tree --write-tree --merge-base 93f77dc`, #72's final
pre-squash head and already merged here, so the squash's copy of T073 is not
merged a second time. Both parents are recorded. 90ac703's tree equals
93f77dc merged onto 8e37782 over 5e7ab00, so the squash carries T073
exactly. Merged, never rebased.

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 Oct 3, 2026
…kflow

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 added a commit that referenced this pull request Oct 3, 2026
#77 moved from ebe0a35 to d556c3f. The move brings main 90ac703,
where T073 (#72) landed as a squash, together with #64 and #73. #77 now
bases on main. The move touches no file under src/opendox/web/ and not
the census, so the merge is clean. Merged, never rebased.

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 added a commit that referenced this pull request Oct 3, 2026
…the base

#64 landed as the squash 8e37782, so main's history no longer contains the
pre-squash commits this branch merged. The merge is computed with #64's
final pre-squash head as its base,
`git merge-tree --write-tree --merge-base 04bcb68 HEAD 8e37782`, so only
what main added after that head arrives (#73, T075), and #64's own lines
are not offered twice. Both parents are recorded. It merges cleanly.

After it, this branch's diff against main is T100's own files only.

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 added a commit that referenced this pull request Oct 3, 2026
…_reach retired (plan 034) (#77)

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

Arc: neutral-product-standalone-operability

Plan 034 (`specs/034-opendox-standalone-operation/tasks.md`, read at openxFactory `main` `2140f5a7`), phase 3:
- **T084, the last deferred reaches** (#1144's 4.3, with T007's batch G and batch L addenda).
- **Realizes:** 4.3.
- **Falsifiers:**
  - F4.1 whole;
  - `tests/test_capability_honesty.py` (new);
  - `tests/test_rejection_report.py` (new).
- **After:** T073 (openDox-code#72, landed as `90ac7033`), for `serve.py`'s single-writer order T055 → T073 → T084, and T070 for `cli.py`'s, T070 → T084.
- **Based on `main`**, retargeted by the holder once T073 landed, so this diff is T084 alone: its own 28 files, the same list as `93f77dc1..7536bfe`.
  - While it was stacked on #72's branch, #72 was merged in at each of its moves, never rebased: `620bee27` at `b333bf16`, then #72's final pre-squash head `93f77dc1` at `7536bfe2`. That head carries #69's final pre-squash head `c04690f8` and openDox-code `main` `5e7ab003` (#69's squash). Through stack A it also carries T085 (#71), T088 (#65), T071 (#60), T078 (#61), T070 (#67), T079 (#62) and T081 (#74).
  - After #72 landed as the squash `90ac7033`, `main` was merged at `d556c3fb` with `git merge-tree --merge-base 93f77dc`, recording both parents, so the squash's copy of T073 is not merged twice. `90ac7033`'s tree equals `93f77dc1` merged onto `8e377823` over `5e7ab003`. Through `main` this branch also takes T075 (#73) and the broker-path hardening (#64).

**Ruled:**
- R1Q1 (a), R1Q22 (a), `5817152735`;
- R1Q10 (a), `5850003126`;
- items 1 and 3 of `5920216845`;
- the model approval: Brett Heap, 2026-10-02, "Refuse by name, hide intake (Recommended)", confirmed at `5961364221`, item 1;
- the chat scope: `5961651355`, "Tile's own documents editable (Recommended)";
- drafted now: `5960162524`.

Claimed on openxFactory#656 in [`5960235138`](opensoft/openxFactory#656 (comment)). T063 has landed (openxFactory#1218 → `a883bbf6`), and T073 has landed (openDox-code#72 → `90ac7033`). The holder posts READY and the landers merge.

## F4.1 whole

At this branch's base (T073's `bb05e3d7`, before T084), the scan lists eleven reaches:

```
AssertionError: 11 deferred reach(es) still name the consumer or the publisher:
  src/opendox/branch_session.py:1592: openxdox.register
  src/opendox/branch_session.py:2010: openxdox
  src/opendox/serve_project.py:271: openxdox.gate_console
  src/opendox/serve_project.py:272: openxdox.kickoff
  src/opendox/serve_workbench.py:1219: openxdox
  src/opendox/serve_workbench.py:1669: openxdox
  src/opendox/serve_workbench.py:2611: openxdox
  src/opendox/serve_workbench.py:351: openxdox
  src/opendox/serve_workbench.py:411: openxdox
  src/opendox/serve_workbench.py:412: openxdox
  src/opendox/serve_workbench.py:548: openxdox
```

`src/opendox/consumer_reach.py` was present. At `b333bf16`, and again at `d556c3fb` after the merge of `main`:

```
no deferred reach names the consumer or the publisher
```

`src/opendox/consumer_reach.py` is absent. `tests/test_projection_seams.py::test_the_stand_ins_module_is_retired` holds the absence.

## What it does, commit by commit

1. **Every broken rule, once** (`5920216845` item 3). `cli._report_non_conformance` prints each broken rule id once, with its count and up to five of the places it is broken, then the validator's other lines.
   - `tests/test_rejection_report.py` (new) covers a snapshot that breaks one rule several times and a second rule once.
   - Before: 3 failed, 1 passed. After: 6 passed.
   - Mutants killed: always prints `1` (5 failed), never groups (5 failed), drops the places (3 failed).
2. **Capability honesty** (`5920216845` item 1). `compute_capabilities(route_bindings=)` sets `gate` true only where a contributed binding answers `POST /actions/gate/<verb>`, and `refresh` true only where one answers `POST /actions/refresh`. A standalone server reads both false.
   - Before, at `047bb4fa`: a standalone server with an identity answered `gate` and `refresh` true, and both routes answered `404 unknown_action`. The module's 20 cases then: 20 failed. After: 20 passed.
   - Mutants killed:
     - gate ignores the routes: 4 failed;
     - refresh ignores the routes: 4 failed;
     - a predicate that is always true: 5 failed;
     - every flag off: 6 failed;
     - gate drops the actor: 1 failed.
3. **The consumer columns' seams.** `opendox.column_seams` declares four seams in `projection_seams`' discipline: `gate`, `scope`, `kickoff` and `register`. `opendox.default_columns` holds openDox's own default for each (R1Q10 (a)). The four entry points register them where no host has, beside `projection_seams`' defaults.
   - The gate default carries the vocabulary-free primitives as real code.
   - The governed record functions refuse BY NAME (`GateRecordsNotRegistered`, naming `opendox.column_seams.gate` and its registration call). openDox writes no governed shape it does not own. This is the holder's reading (B).
4. **The routing.** Every reach above now reads its seam:
   - the chat turn's and the document abstract's scope authority (the abstract's reach moves below its step-1 check);
   - the thread read's live-session question;
   - the first-edit Save gate;
   - model approval;
   - the project register's prefix and kickoff readers;
   - `branch_session`'s gate, register and kickoff;
   - `cli`'s `gate_mod`.

   The scope value types are openDox's own (`doxbench_scope_types`).
5. **A tile's own documents are editable** (`5961651355`). This supersedes the holder's read-only reading. The neutral scope marks each section a tile projects as owned, as openXdox's authority marks its owned sections: a group's members, a selection's files, and a candidate's claiming groups' members. The set is ONE named function, `default_columns.editable_paths`. Nothing outside the tile is editable, an unresolved row is not, and neither is a created path.
6. **`opendox --help` names the installed command and openDox only.** This is the holder's addition, found by T099's PyPI writer. T084 is `cli.py`'s last phase-3 writer.
   - The installed help printed `usage: ideation-dashboard` and `cli.py`'s module docstring, which is openxFactory's pre-carve history ("validates it against the pinned openxFactory validator", `python3 -m ideation_dashboard.cli`).
   - `cli.PROG = "opendox"`, and `PARSER_DESCRIPTION` and `PARSER_EPILOG` are neutral.
   - Two subcommand help lines in the same listing lose their internal words: `create` ("ideation doc" becomes "document") and `runtime` (drops "(split-opendox § 3.5)").
   - `tests/test_installed_help.py` (new) runs the INSTALLED console script, after checking that its entry point is `opendox.cli:main`. It asserts `usage: opendox` and no openxFactory, xFactory, ideation-dashboard, split-opendox or `scripts/` wording.
   - Before: 3 failed. Mutants killed: the prog reverted, 3 failed; the description back to `__doc__`, 3 failed.
7. **The static bundle's content types are pinned.** This is **the holder's addition**: T084 is `serve.py`'s last phase-3 writer. It comes from T075's finding on openDox-code#73 and realizes 10.2, "reachable in a browser from an openDox-only install".
   - The static route's `guess_type` reads `extensions_map` first, and the platform's `mimetypes` table only after it. A host whose table maps `.js` to `text/plain` serves every ES module as text, and a browser refuses to run it. Windows reads its table from the registry.
   - `serve.STATIC_CONTENT_TYPES` pins every extension the bundle ships, measured from a built wheel: 41 files under `opendox/web/`, 39 `.js`, one `.html` and one `.css`. It also pins `.mjs`, `.json`, `.svg`, `.png`, `.ico` and `.woff2`.
   - Each value is the standard library's built-in one. `.woff2`, which that table lacks, takes its registered type (RFC 8081).
   - Any other extension falls back to the platform table.
8. **`consumer_reach` is retired.** `LateGateRoutes` and `LateProjectionRoutes` leave `DashboardHandler`'s bases. The gate and projection columns are a host's, composed in through the handler-contribution facet beside the bindings that name their methods (R1Q1 (a)). A host that contributes a binding without its column is refused at wiring, before a socket. openXdox contributes both columns that way at T086.

## The three crash sites, and a fourth

`tests/test_capability_honesty.py` runs a standalone `python -m opendox.serve` child with neither sibling importable. Each site gets a structured answer, never `RemoteDisconnected`:
- the document abstract: `model_capability_unavailable` at step 1, now that `gate` reads false;
- model approval: `approval_refused`, with the sentence naming the gate seam (see below);
- `GET /project-register.json`: today's 404 "no project register", from openDox's own kickoff default;
- the chat rail's **thread read**, with the exact query the rail sends on opening a document (`repository=fixture&ref=main&tile_kind=cluster&tile_id=barrel-rain&document=<member>`, `views/staging-workbench.js` `loadThread`): the stated no-session absence. T095's AT-R1 harness found this site on openDox-code#75. A query-less GET stops at the 400 check first, which is why batch L's measurement missed it.

Composed in-process hosts take the chat turn and the document abstract past their early checks, to the scope step that dropped the connection. The answer is a refusal in the released envelope.

**Before** (`a39e0201`, the routing files at their pre-routing state), each of these fails with `http.client.RemoteDisconnected: Remote end closed connection without response`:
- the crash sites;
- the composed chat turn;
- the composed abstract;
- the host approval;
- the thread read.

Before, too, the standalone intake surface read `offered: true`. It now reads `offered: false`, with the reason (next section).

## Model intake and approval (Brett Heap, 2026-10-02; `5961364221` item 1)

The enrolment ends in a recorded approval, a governed gate-action record that only a HOST's gate writes. With no host's gate registered (`column_seams.gate_records_writable()`):
- `GET /workbench/model-intake` answers `offered: false` with `column_seams.GATE_RECORDS_REFUSAL` as its reason, even beside a hand-written broker block;
- the intake act refuses with that sentence, before a broker is spawned or a declaration is written;
- so does the approval, before a record is built;
- each drains the body it was sent, so the refusal survives its transport.

The order is: no gate-record writer first, then no broker. A composed host that registers its gate is offered the flow, and its approval is recorded before the declarations document moves.

Mutants of the predicate killed: always true, 3 failed; always false, 2 failed.

**Known, and out of this PR's scope:**
- `declared_model_port_factory` serves only the FIRST approved binding (`approved[0]`).
- An approval's `expires_at` is recorded and not enforced.

## The chat scope (`5961651355`)

`tests/test_neutral_turn_scope.py` (new) runs over a composed host: the plain fixture, a loopback bind, an authenticated actor, the binding `opendox model-binding add` declares, and the port the entry points declare over it. The host also has the released validators, as a plane with a readable contract has them.
- A turn over cluster `barrel-rain`'s own document passes the guard and reaches the model step. It answers `model_unavailable`, because it names a model the catalog lacks, so nothing is spawned or contacted.
- A turn over a corpus document outside the tile is refused `turn_scope_refused`.
- Save is still the gate's: `POST /actions/gate/first-edit` answers `unknown_action`, and the record a Save writes is refused naming `opendox.column_seams.gate`.
- **Standalone** (case 4, a `python -m opendox.serve` child with a binding configured): the same turn passes the guard and reaches the model step, `model_unavailable`, with openDox's own validators (T085, merged in at `3387293e`) and no stand-in. Until that merge the case asserted only a structured answer, as the holder accepted, and the merge commit narrowed it. `tests/test_capability_honesty.py`'s standalone turn with a binding configured likewise now asserts the scope step's `turn_scope_refused`.

Before (the read-only default): 9 failed. The own-document turn answered 403 `turn_scope_refused`.

Mutants killed:
- widen the editable set to every corpus document: 7 failed;
- empty it: 9 failed;
- widen the tile itself to the corpus: 2 failed, including the outside-tile turn.

## The content types

`tests/test_static_content_types.py` (new):
- under a hostile table (every guess `text/plain`), every bundle file keeps its pinned type;
- an extension outside the pin still reads the platform table;
- the pinned types are the standard library's built-in ones;
- every extension the bundle ships is pinned.

Mutant killed: drop the pin, 2 failed. Under the hostile table, `index.html` and every `.js` were served `text/plain`.

## The suite

**The whole suite, locally** (`LANG=C.UTF-8`), at `213344ad`: `tests/` `3026 passed, 11 skipped`, and `tests_runtime/` `632 passed, 166 skipped`. The skip count is unchanged from T073's. The database-backed cases skip locally without `OPENDOX_TEST_DATABASE_URL`. No new case skips, so `EXPECT_SKIPPED` does not move. As #67, #69 and #72 did, this PR does not edit `validate.yml`.

`tests/test_consumer_reach.py` keeps its direction census. Its `CONVERTED_SITES` guard is retired into `tests/test_projection_seams.py`'s import-time rule, which now holds the column seams' proxies. `opendox.consumer_reach` leaves `NEUTRAL_MODULES`, the census "a removal from which has to be argued for". The argument: the module no longer exists.

## Fix round 1 (`b1db1965`, Copilot's review at `897029d8`)

- **A seam registration with the names but not their shape is refused at registration** (r4170607959, r4170608011). `projection_seams._Seam` gains an optional `shape` check, run after the name probe in `register()` and `register_default()`.
  - `column_seams.gate` requires `GateRefused` to be an exception class, because openDox's verbs catch it.
  - `column_seams.register` requires `CrossReferenceIndexAdapter` to carry a callable `discover`, because `branch_session` calls it.
  - Mutant killed: skip the shape check, 2 failed.
- **`tests/test_column_seams.py`'s docstring** now describes the scope default as the ruling made it (r4170608087).
- **This body's crash-sites section** now states the intake surface's before and after (r4170608052).

## Fix round 2 (`ff70ac1e`, Copilot's review at `3387293e`)

- **A gate verb on the default gate is refused, not a traceback** (r4170914922). openDox's own gate default refuses the governed `GateConsole` at construction, and `cli._commission_cli` built the console before its `try`. A contributed gate verb that reached the default with no host's gate registered therefore ended in an uncaught traceback. The console is now built inside the refusal boundary.
- `tests/test_column_seams.py::test_a_gate_verb_on_the_default_gate_is_refused_not_a_traceback` asserts exit status 1 and `propose refused: ...`, naming `opendox.column_seams.gate.register(`. Before (`3387293e`): an uncaught `GateRecordsNotRegistered`.

## Fix round 3 (`ebe0a35f`, Copilot's review at `1d6a4f19`)

- **A group's edges name documents by id, and the scope projects them by path** (r4171136778).
  - A group's `document_edges[].document` names a document by ID, and a selection's `files` by PATH (the snapshot schema's `$defs/id` and `$defs/path`). openDox's default scope looked every reference up as a path.
  - So a valid snapshot with id `notes/soil-test` and path `notes/soil-test.md` left every group and candidate member unresolved, with empty editable and context sets, and valid turns were refused.
  - The fix: `default_columns._document_index` maps each listed document's id, then its path, to the document's path, as openXdox's authority does. `_section` looks each reference up there, carries the document's path (confined to the root as before) and keeps the reference as the row's id.
  - A reference that no listed document answers stays unresolved under its own spelling, which must still be a safe path, so the confinement cases are unchanged.
- `tests/test_column_seams.py::test_a_group_edge_names_its_document_by_id` covers a snapshot whose ids differ from its paths. Before (`b333bf16`): failed, with `editable_paths` `()` where `('a.md', 'b.md')` was expected. Mutant killed: references looked up as paths only, 1 failed.

## Fix round 4 (`1fb81cbd`, Copilot's review at `d556c3fb`, the holder's ruling: ACCEPT both)

- **A selection's files are paths and a group's edges are ids, looked up apart** (r4173844321).
  - Fix round 3's index answered every reference id first, and it also served a selection's `files`, which are PATHS. The contract does not forbid one document's path from spelling another document's id.
  - `_document_index` now returns `_DocumentIndex(ids, paths)`. `ids` (id first, then path) serves group edges and a candidate's claiming groups. `paths` (path only) serves a selection's files.
  - `tests/test_column_seams.py::test_a_selection_file_is_a_path_where_it_spells_another_documents_id`. Before (`d556c3fb`): failed, resolving to `a.md` where `sel.md` was expected. Mutants killed: the files looked up as ids (1 failed); the edges looked up as paths (2 failed).
  - openXdox's authority (`openxdox/doxbench_scope.py`) still answers a selection's files from its one id-first map. Whether it follows is outside this PR.
- **The server's help names loopback as the default, not a promise** (r4173844338): "on a loopback address unless --host names another", with no "locally". `tests/test_installed_help.py::test_the_servers_help_states_loopback_as_the_default_bind`. Before: failed. Mutant killed: the `--host` clause dropped, 1 failed.

## Fix round 5 (`213344ad`, Copilot's review at `1fb81cbd`, the holder's ruling: ACCEPT both)

- **An in-root alias of a settings document is the settings document** (r4173903232).
  - `resolve_within` follows a symlink to the canonical file, but a scope row keeps the spelling it was named by. So `alias.md -> ideation/dashboard/model-provider-bindings.yaml`, or a directory link on the way to one, stayed owned and editable, bypassing M1.
  - `default_columns._settings_test(root)` now compares the file a row REACHES with the files the settings documents reach. `_without_settings` moves such an alias, under its own name, into the `settings` section that nothing owns: readable, never editable.
  - `tests/test_column_seams.py::test_an_in_root_alias_of_a_settings_document_is_never_editable` tests a file alias and a directory alias. Before (`1fb81cbd`): 2 failed, e.g. `('a.md', 'b.md', 'alias.md')` where `('a.md', 'b.md')` was expected. Mutants killed: the target comparison dropped (2 failed); the alias compared by spelling (2 failed).
- **The typeless package marker is set aside by its reason** (the review's "previously missed" note on `tests/test_static_content_types.py`).
  - `UNSHIPPED` claimed the wheel leaves `.gitkeep` out, but since T075 it ships (`web/**/.*`). It is renamed `TYPELESS_MARKERS`: the shipped, empty, extensionless marker has no content type to pin.
  - `test_the_set_aside_markers_are_empty_and_extensionless` holds that reason. Mutant killed: the set widened to `index.html` (2 failed).

## Adversarial review 2 (at `b1db1965`), folded in

Each item has a case that failed before its fix, and a mutant that fails it.
- **M1, openDox's own settings documents are never a tile's editable material** (`5a3b51ee`).
  - The model-provider bindings (`doxbench_binding.DEFAULT_BINDINGS_RELPATH`) and the model declarations (`doxbench_intake.DEFAULT_DECLARATIONS_RELPATH`) live in the checkout, so a tile can name them. Under `5961651355` a group listing one made it editable, and a turn's proposal could then rewrite which provider a chat talks to.
  - `default_columns.SETTINGS_DOCUMENTS` holds both. `resolve_scope` moves them out of every owned section into a trailing `settings` section that nothing owns, so they stay readable. `editable_paths` refuses them whatever section carries them.
  - The corpus scan's own exclusion (openDox-code#76) is a second layer, not the only one.
  - Before (`1d6a4f19`): 3 failed. Mutants killed: the function admits settings (1 failed); settings left in owned sections (2 failed).
- **L1, an unknown `tile_kind` is a malformed request, never a dropped connection** (`278855d4`). `ScopeKey` refuses one with a `ValueError`, which escaped three routes. Each now answers its fixed code:
  - the thread read: `invalid_turn_request`;
  - the document abstract: `invalid_abstract_request`;
  - the chat turn: `invalid_turn_request`, in the released envelope.
  - Before (`5a3b51ee`): 3 failed, each `RemoteDisconnected`.
- **G7, `generate-and-open` removes the run directory it minted** (`5a9cb0d8`).
  - With no `--run-dir` it minted `ideation-dashboard-*` and never removed it. Now it mints `opendox-*` (`cli.RUN_DIR_PREFIX`) and removes it when the run ends: a served run stopped by an interrupt (and, in a local install, by SIGTERM), a `--no-serve` run, a refusal, a failure.
  - A `--run-dir` the caller names is kept.
  - `tests/test_run_dir_lifetime.py` (new). Before (`278855d4`): 2 failed. Mutant killed: the minted directory kept, 2 failed.
  - **Known Release 1 limit, accepted by the holder:** a HOSTED run killed by SIGTERM leaves its `opendox-*` directory, because hosted mode installs no SIGTERM handler. A local run reads SIGTERM as an interrupt and cleans up.
- **`python -m opendox.serve --help` names openDox only** (`6519660e`). This is the same fix as `opendox --help`, for the server entry point's parser.
  - `serve.SERVE_PROG = "python -m opendox.serve"`, and `SERVE_DESCRIPTION` is neutral.
  - Before (`278855d4`): 1 failed. Mutant killed: the prog reverted, 1 failed.
- **Not here:** M4 and L2, the DNS-rebinding Host check on every route, belong to **T103**, a new writer stacked on this PR. M2 is #74's writer's, and M3 is covered by the turn patch below.

## Merges, done and ahead

- **T085 (#71), merged at `3387293e`.** It conflicted only where measured: `cli.py` and `serve.py`, at the four `register_defaults()` call sites and one import. Both sides are kept, T085's `doxbench_defaults` first and T084's `column_seams` after.
  - T084's own turn tests needed T082's patch, checked and not taken blind: the turns carry the schema-required `last_assistant_turn_id`, and `_structured()` reads the released failure envelope. Without it, both standalone turns failed with 400 `invalid_turn_request`.
- **T088 (#65), merged at `3387293e`,** with the ruled one-line edit in `tests/test_lens_seed_actions.py`: `capabilities["actions"]["gate"] is False` in both runs, with the identity asserted on the resolved `actor` field.
- **T070 (#67), T079 (#62) and T081 (#74), merged at `b333bf16`** through stack A's merges of main and #72's merges of #69.
  - T081's hoisted no-model refusal and this task's scope seam sit two unchanged lines apart in the chat turn, and both are kept.
  - T082's second patch, which moves `tests/test_chat_model_configuration.py`'s `openxdox` stand-in to a scope authority registered at `opendox.column_seams.scope` (restoring the seam's state exactly), no longer applied once the landed file had moved. It is applied by hand and checked: before, 4 failed and 45 passed; after, 49 passed.
- **T073 (#72), its final pre-squash head `93f77dc1`, merged at `7536bfe2`,** a plain merge with no conflict. It brings #69's final rounds (17, 18a and 18b, through `bc85f214`), `main` `5e7ab003` (#69's squash, merged on #72 with `merge-tree --merge-base c04690f`), and #72's Copilot fix round (`BundledServer.report()` reads the process once).
- **`main` `90ac7033` (#72's squash), merged at `d556c3fb`** with `git merge-tree --write-tree --merge-base 93f77dc`, recording both parents (`7536bfe2`, `90ac7033`), with no conflict. It brings T075 (#73) and the broker-path hardening (#64), whose `doxbench_binding.py` and `doxbench_provider.py` changes merge clean with T084's. The F4.1 scan stays clean.

## Holder readings in this PR (Brett may overrule)

- **The gate default:** primitives as real code, governed record functions refused by name, (B).
- **kickoff and register:** answer nothing, and `/project-register.json` keeps its 404.
- `hosted_ref_refused` stays `serve.py`'s own.
- **The content-type pin** is the holder's addition for 10.2.
- **The neutral `opendox --help`** is the holder's addition, from T099's finding.

🤖 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 added a commit that referenced this pull request Oct 3, 2026
…cope (plan 034) (#81)

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

Arc: neutral-product-standalone-operability

Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3: **T102**, a new task. Its plan entry is being added to openxFactory#1220 by another writer.

**Ruled:**
- [`5963618568`](opensoft/openxFactory#656 (comment)), Brett Heap, 2026-10-03: "Edit and chat by scope (Recommended)". The editors and the chat rail appear wherever the scope lets the document be edited. Only creating documents and Save stay behind the gate, so Save is refused by name.
- It builds on [`5961651355`](opensoft/openxFactory#656 (comment)), "Tile's own documents editable (Recommended)". openDox's neutral scope default, which T084 builds, marks a tile's OWN documents editable: a group's members, a selection's files, and a candidate's claiming groups' members.

Claimed on openxFactory#656 in [`5963868498`](opensoft/openxFactory#656 (comment)). Still a draft. The holder posts READY, and the landers merge.

**Based on main**, and the diff is T102's own 10 files. The branch was built stacked on #77's branch, and the holder retargeted it to main when #77 landed.
- The branch starts at #77's `3387293e`.
- #77 was merged in five times as it moved, never rebased:
  - `1d6a4f19` at `60393003`;
  - `b333bf16` at `7535cd3e`;
  - `ebe0a35f` at `0fbdba67`;
  - `d556c3fb` at `0ddca7f0`;
  - its final head `213344ad` at `af4adc13`.
- #77 then landed as squash `e49b17c3`. That squash was merged at `b41fbe0a` with `git merge-tree --merge-base 213344a`: the tree is unchanged, and both parents are recorded.
- #80 (T103) landed as `390e2c28`, merged at `02e0aa50`. It touches `serve.py` and no web file.
- The head is `02e0aa50`.

## The gap

The T096 prep run found it: a local AT-R1 browser-half dry run on the phase-3 drafts. A standalone workbench opened read-only, and said "read-only: the create/edit gate is off here, so editing, chat, and Save are not offered".
- `canvasOffered()` (`views/staging-workbench.js`) and `presentationPosture()` (`views/staging-workbench-model.js`) both required `createColumn.createGateLive(caps)`.
- Only openXdox's `gate.workbench.create` binding answers that, so a standalone install's null column answered `false`.
- The docs tile's `edit` verb was therefore disabled, and no editor or rail was ever mounted. This held even though a standalone `/capabilities` reads `gate: false, edit: true, session: true`, and its own scope makes the tile's documents editable.
- With the gate forced true in the browser, every T096 check passed. This was the one blocker.

## The design

**One posture.** `editingPosture()` is new and pure, in the model. Facts go in and a frozen answer comes out. The facts are:
- `governed`: a host's gate column is registered;
- `gateLive`: that column's `createGateLive(caps)`;
- `surfaceHidden`: the hosted plane;
- `editLive`: `/capabilities` `actions.edit`, which must be stated `true`;
- `editablePaths`: the scope's answer.

The shell reads it through `editingNow()`. `canvasOffered()` is now `!!scope && editingNow().editors && keyed`.

| Facts | Mode | Editors and rail | Create | Save |
|---|---|---|---|---|
| a gate column, gate live | `gate` | yes, every document loadable | yes | the governed Save |
| a gate column, gate off | `read-only` | no | no | none |
| no column, `edit` true, something editable | `scope` | yes, only the scope's documents | absent | **visible, refused by name** |
| no column, `edit` true, nothing editable | `nothing-editable` | no; the note says why | no | none |
| no column, `edit` not true | `read-only` | no | no | none |
| the hosted plane | `hidden` | no | no | none |

**The scope's answer.** `tileOwnEditablePaths()` mirrors `default_columns.resolve_scope` and `editable_paths`. It reads the same snapshot fields in the same order, resolves and deduplicates, and gives up on the WHOLE tile where the server's `_canonical` would raise. A node-and-Python parity test holds the two together, tile by tile.
- The canvas's projection reads it through `doxbenchScopeProjection(..., { editableBy: "tile" })`. With that option, `context_paths` and `editable_paths` are the tile's own documents, as the server's neutral projection has them.
- The docs tiles read it as a per-document set (`docWheelEntries(scope, { editable })`). The `edit` verb is offered on exactly the scope's documents. Every other document states "this document is context in the opened tile, not one of the tile's own, so it is not offered for editing here". It never fails on press.

**What stays behind the gate:**
- **Creating a document** stays absent standalone. The null column mounts nothing, as before.
- **The document abstract's generation** stays absent. Ruling 7.7 keys it on the gate, and the route refuses at its step 1 without it. So `capable` gains `createColumn.createGateLive(caps)`, which is implied wherever the gate offered the canvas before.
- **Save stays visible, and is refused by name.** I chose that over hiding it, for three reasons:
  - The ruling's own words are "Save is refused by name". A hidden control refuses nothing; it just isn't there.
  - RULED Q10's fallback for a shell with no gate column is "a refusal-shaped fallback, never a blank".
  - A human who has typed into a buffer must be told why it does not persist, at the control they press.

  So the standalone Save transport (`app.js` `refusalTransport`) now carries the model's `GATELESS_SAVE_REFUSAL`: "Save needs the first-edit transport, and no column on this install contributes it (`gate.workbench.session`), so a governed Save cannot be sent. Your edits stay in this browser's buffers, unsaved." It used to end "Run the CLI verb in your pinned checkout", a remedy a standalone install does not have. It names only the session transport because `app.js` picks this fallback on that half alone (Copilot's second review, below).

  The canvas Save shows "Save refused -- <that sentence>", and the docs tile's Save shows "the governed Save did not land — <that sentence>". The text stays in the buffer. The pill reads `editing by scope`, and the posture note states the by-scope plane fact beside the chat rung's.

**A governed host is unchanged, by construction.** Where a host registers either gate column, create or session, `editingPosture` returns the gate's answer:
- `editors` is `createGateLive(caps) && !sessionSurfaceHidden(caps)`, the old conjunction;
- the projection, the tile entries, the pill and the posture ladder take their old paths, because the by-scope options are passed only where no column is.

The by-scope arm is openDox's own default and never a host's. So a governed host whose gate is off stays read-only even with `edit: true`. This also covers a composed openXdox host before T086 contributes its gate routes, where `gate` reads false, and a host that registers only the session column, whose create gate reads off.

**The neutral scope moved under this PR, and the mirror follows it.** #77's later rounds changed `default_columns`, so the mirror follows each of them in its own commit. Parity now compares the whole tile projection (`context_paths`, `editable_paths`, `active_document_candidates` and `outline_path`) with `resolve_scope` over 17 tiles.
- **M1** (`b333bf16`; followed at `770156ce`): openDox's own settings documents, `default_columns.SETTINGS_DOCUMENTS`, are never a tile's editable material. They are the model-provider bindings and the model declarations.
  - The mirror spells them as `OWN_SETTINGS_DOCUMENTS`, and a case holds the two spellings equal.
  - It leaves them out of the editable set and puts them at the end of the projection's context, as `resolve_scope` keeps them readable in a trailing section nothing owns.
- **Fix round 3** (`ebe0a35f`; followed at `3528b2fa`): a group's edges name documents by ID, and a selection's files by PATH. The mirror now resolves every reference through the same index as `_document_index`: id first, then path.
- **Fix round 4** (`213344ad`; followed at `ce85d573`): the two namespaces are kept apart. A group's edges and a candidate's claimed members are looked up in `ids`, which answers an id first and then a path. A selection's files are looked up in `paths`, which answers a path only, so a selection's file `x` never resolves to the document whose ID is `x`.
- **Fix round 5** (`213344ad`) is **not** mirrored: an in-root symlink that reaches a settings document is moved into the unowned settings section. That is a fact about the file a row resolves to, which the browser cannot see, so it is a stated limit below.

**Copilot's first review, two fixes** (`e2d106e8`; both threads answered and resolved):
- **By scope, the canvas sends no outline** ([r4173502649](#81 (comment))). The neutral scope projects none (`resolve_scope` returns `outline_path=None`), and the turn guard's `_require_buffer_binding` requires the outline buffer's path to equal it. A tile-mode projection that kept a staged or candidate tile's primary file as the outline would have had every turn there refused. `doxbenchScopeProjection` now derives no outline when `editableBy` is `"tile"`, which also returns that file to the active document candidates, as the server has it. A governed host's projection is unchanged. With no outline buffer by scope, the outline tab's add-section controls are inert, bind no listener, and say why: "adding a section writes into an outline buffer, and here this tile's own <files> are edited directly, with no outline buffer: open the file with its edit verb instead".
- **A restore is held to the scope** ([r4173470792](#81 (comment))). The canvas restores persisted buffers verbatim, and that restore is the one route into the loaded set that the scope does not gate (for example, after a regenerated snapshot drops a member from the group). Once the canvas is ready, the shell reconciles the restore with the projection it mounted over, by scope only:
  - a CLEAN buffer that is no longer the tile's own is unloaded;
  - a DIRTY one stays, because unloading it would lose the human's text. The posture note names it and says to copy the text out and unload it, since a chat turn that carries it is refused by the server's scope check. The note goes away once the buffer is unloaded.

**Copilot's second review, two fixes** (`257853de`; both threads answered and resolved):
- **Either gate half makes a host governed** ([r4174293974](#81 (comment))). The two bindings resolve independently, and `app.js` sends Save through a contributed session column's `firstEditTransport` whenever one exists. So a host that registered only the session half used to read as standalone: it edited by scope, and its Save was not the by-scope refusal. Now `governed` is `createColumn !== NO_CREATE_COLUMN || sessionColumn !== NO_SESSION_COLUMN`. Such a host has no create column, so its gate reads off and it stays read-only, exactly as before T102.
- **The Save refusal names only the missing session transport** ([r4174293950](#81 (comment))). A host with a live create column and no session column reaches the same fallback, and there "this install has no create gate" would be false. The sentence is quoted above.

**One small consequence in `views/doc-wheel.js`.** Only a LOADED buffer's ownership outranks the tile's own answer now. The shell answers an unloaded path with a placeholder `owned: true`, which used to make a by-scope context tile read "load this document for editing before saving it". Under the gate, no entry carries `owned`, so this reads as before.

## Gap G8: the display text

- `views/lens.js`, the plan-only note: it was "The tested engine (lens.py) materialises this through the boundary; the read-only surface confirms the plan — nothing is written from the browser." It is now "Shown for confirmation only: this console cannot carry the plan out from the browser, so nothing is written."
- `views/lens.js`, the persist pane: it was "Persisted through the interactivity boundary to ideation/workbench/ (gitignored). Nothing enters the register or any queue from here." It is now "Each button shows its plan below before anything is written; this pane itself writes nothing." The now-unused `WORKBENCH_DIR` import goes with it.
- `index.html`, the about dialog: "one repository's ideation governance state" is now "one repository's Markdown files and how they group". "read-only" goes too, since the workbench now edits.
- `views/wheel.js`, the workbench verb's title. This one is beyond the three named sites, because it became false. It said "scoped to this cluster (read-only)" and now says "scoped to this group", using the facet's word, not the seam key.

Not touched, and named here so that nobody reads them as done: `lens-model.js`'s `PENDING_PROPOSAL_NOTE` ("cross-reference queue", "topic cluster"), which is pinned byte-identical to `lens.PENDING_PROPOSAL_NOTE` on the Python side, and the plan's `lands at: ideation/workbench/…` line, which is `workbench.WORKBENCH_DIR`.

## Tests

**`tests/test_workbench_edit_by_scope.py` (new), 44 cases, in three layers:**
1. **The posture matrix, pure, under node.** It covers every cell of gate on/off × edit on/off × document editable/not, with the governed column as the fourth fact.
   - It checks each cell's mode, editors, create, Save, and both `documentEditable` answers.
   - It covers the hosted plane, and `edit` absent rather than false.
   - It checks `presentationPosture`: a governed caller's answers are byte-identical to before, with and without the new fact; the by-scope rungs carry `scopeNote`; and the nothing-editable rung names no gate.
   - It checks the tile projection against the host projection, and the per-document tile entries.
2. **Parity with the server.** `tileOwnEditablePaths` and the whole `editableBy: "tile"` projection (context, editable, candidates and `outline_path`) are compared with `default_columns.resolve_scope(...)` over 17 tiles. The projection runs with the shell's own outline derivation (`primaryFragmentPath`), and a guard case shows that a governed host's projection of the same selection does keep its outline (`sel.md`), so the tile-mode `None` is reached rather than vacuous. The tiles include:
   - a member that is not catalogued;
   - a citation that is also a claimed member;
   - an empty group;
   - a candidate claimed by an unknown group;
   - a path the scope cannot name (the server raises, and the browser offers nothing);
   - a group naming both settings documents (M1);
   - a document whose id is not its path, named by id from a group and a candidate and by path from a selection (fix round 3);
   - one document whose ID is `p.md` and another whose PATH is `p.md`. A group's edge `p.md` names the first, a selection's file `p.md` names the second, and a selection's file that is only an id resolves to nothing (fix round 4);
   - unknown tiles.

   A guard case stops the table from agreeing vacuously, and a case holds `OWN_SETTINGS_DOCUMENTS` equal to `SETTINGS_DOCUMENTS`.
3. **The real shell**, `mountStagingWorkbench` over the shared DOM instrument:
   - **Standalone**, with the null columns exactly as `app.js` hands them down and a standalone `/capabilities`. The canvas and rail mount and send stays disabled with no model. The pill and note show. Create and generate are absent. The tile's own document loads. Save is refused by name from the canvas AND from the tile, and the typed text survives. A candidate's citation states the absence, while its claimed members load. A tile with nothing of its own stays read-only and says why. Without `edit`, the workbench is read-only.
   - **Governed**, with the contributed column the other shell harnesses mount. With the gate live, it has the gate pill, the canvas, the rail, the generate control, and every document loadable. With the gate off and `edit` true, it is read-only with the old note.
   - **Copilot's first review.** S7 loads both of a group's members, types into one, and restores the session into a regenerated snapshot where the group holds another file: the clean buffer leaves, and the dirty one stays and is named in the note. S8 opens a selection's outline tab by scope: every add-section control is disabled, has no listener, and states the absence.
   - **Copilot's second review.** S9 mounts a host that registers only the session column: read-only, with the governed host's old note. A case holds the Save refusal to the session transport, and never to the create gate.
   - Each scenario records its own failure, so a regression names its step.

**Unchanged and passing, as the proof that a governed host is untouched:** `tests/test_doxbench_view.py`, `tests/test_doxbench_composition.py` (including F3, the gate-off workbench), `tests/test_doxbench_abstract_pane.py`, `tests/test_doxbench_context_panes.py`, `tests/test_outline_tab.py` and `tests/test_gate_loop_contributed.py`.
- One source pin is **re-pinned**, `test_staging_workbench_composes_the_doxbench_canvas_without_new_transport`. It asserted the literal old conjunction. It now asserts `editingNow().editors` and that the gate and hidden predicates are still read off the same `caps`, and its comment gives the reason.
- `tests/fixtures/web_boundary_census.yaml`: six rows are re-measured with a provenance sentence each, and the totals are re-derived. `views/lens.js` stays `?`.

**Mutants**, at the head `02e0aa50`. Each is applied to the committed tree, and an anchor that is not found aborts the run, so a mutant that never applied cannot read as one that survived. Then the new module, `test_doxbench_composition.py` and `test_doxbench_view.py` run. All twelve are killed.

| Mutant | Result |
|---|---|
| the old gate-only posture (`canvasOffered` back to `createGateLive(caps) && !sessionSurfaceHidden(caps)`) | 9 failed |
| editable-everything, in the posture (no nothing-editable rung; by scope, every document loadable) | 3 failed |
| editable-everything, in the scope (the scope answers every listed document) | 19 failed |
| the settings documents editable again (M1 not mirrored) | 2 failed |
| references looked up by path only (fix round 3 not mirrored) | 4 failed |
| the tile-mode projection keeps an outline (r4173502649) | 4 failed |
| no reconciliation of a restore (r4173470792) | 1 failed |
| the reconciliation also discards a dirty restored buffer | 1 failed |
| by scope, the outline tab offers a live add-section | 1 failed |
| only the create half makes a host governed (r4174293974) | 1 failed |
| the Save refusal claims the create gate is missing (r4174293950) | 1 failed |
| one index for every reference, so a selection's files are looked up as ids too (fix round 4 not mirrored) | 2 failed |

**The whole suite**, locally, with `LANG=C.UTF-8 python -m pytest -q` and the basetemp under `~/.local/state`:
- at the head `02e0aa50`: `3769 passed, 177 skipped`, 0 failed (the merges of #77's final head and #80 bring their tests);
- at `b41fbe0a`: `3702 passed, 177 skipped` (run in two halves);
- at `0ddca7f0`: `3693 passed, 177 skipped`;
- at the branch point `3387293e`: `3222 passed, 177 skipped`.

The skip count does not move. As #67, #69, #72 and #77 did, this PR does not edit `validate.yml`.

## AT-R1, the browser half, on a local integration

The integration the brief names (main plus #69, #72, #77, #74 and this branch) is now this branch's head itself. Every other part has landed on main, and `02e0aa50` contains main `390e2c28` (which also carries #80, T103: every loopback route checks the Host). So the local integration branch (never pushed) sits at `02e0aa50`, and the venv was reinstalled from it (117 installed files compared, 0 differ).

The harness is the prep run's `run-pass.sh` and `t096_drive.py`, unmodified except for paths. It ran on a fresh venv with `.[local]`, a fresh state dir per pass, and `TMPDIR` off `/tmp`. **No diagnostic patch was used**: the create gate is NOT forced.

All 15 checks passed in each pass.

| Check | Pass a (`plain-documents`) | Pass b (plain notes, no front matter) |
|---|---|---|
| load, wheel, lens (4 checks), grouping tile, workbench verb, workbench opens | PASS | PASS |
| **step 7:** the rail shows "No model configured…" with `opendox model-binding add`, before any turn | PASS | PASS |
| **step 7:** a turn is refused `model_capability_unavailable` (the composer and send are reachable, and send is disabled; the HTTP turn answers 403 `model_capability_unavailable`) | PASS | PASS |
| **step 7:** both editors stay usable (Outline and Document typed) | PASS | PASS |
| zero `pageerror` | PASS (0) | PASS (0) |
| nothing undeclared (the three declared 404s only) | PASS | PASS |
| no 5xx | PASS | PASS |

`FAILED CHECKS: []` in both passes. The workbench pill read `editing by scope`, no process referenced either state dir after the stop, and the product removed its own run dir (G7). Three earlier runs gave the same verdict, 15/15 in both passes: on `0ddca7f0`, on `c61fef3f` (this branch at `3528b2fa`, #73's head and main `8e377823`), and on `34fbf96f` (this branch at `60393003` with main `9a490405`).

## Known limits

- **Facts the browser cannot see:** whether a catalogued path still resolves inside the checkout (the server's `resolve_within`), and whether a path is an in-root symlink to a settings document (#77's fix round 5). Such a document, deleted after the snapshot or an alias, is offered by the browser and refused by the server's own scope rule.
- **A restored outline buffer** is not reconciled: the outline is reserved and cannot be unloaded. By scope, the canvas mounts its outline with no path, so only a session persisted by this branch before `e2d106e8` could carry one.
- **The holder's T104 note**, "reopen the console file" in place of "reload the page": not taken. It depends on T104's opener file, which is not in this branch's base. At this base, "reload the page" is still correct.

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 added a commit that referenced this pull request Oct 5, 2026
…(plan 034) (#84)

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

Arc: neutral-product-standalone-operability

Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3:
- **T104, the console token travels in the opened URL, not `/capabilities`.** RULED on openxFactory#656 in [`5963851934`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963851934) (Brett, 2026-10-03, *"Token via the opened URL (Recommended)"*). Its plan entry landed with openxFactory#1220 (`ec9308c8`).
- **From adversarial review 2's M5 (pre-existing).** A standalone openDox served its per-serve console token from `/capabilities` to any loopback caller, other OS users on the same machine included. Nothing checks which local user connects. The token lets a page edit documents and run chat turns that spend the operator's model credential.
- **The holder's ruling on openxFactory#1220's review** (Copilot `r4171166321`): the private copy refuses an `OPENDOX_STATE_DIR` that is, or lies inside, a served root. This mirrors T100's served-repository boundary.
- **After:** T103 (#80, landed as `390e2c28`) and T102 (#81, landed as `0116293a`; its follow-on #85 landed as `c4b55cc4`). `serve.py`'s single-writer order is T084 (#77) → T103 (#80) → T104. Every After line is met.
- **Base `main`.** The holder retargeted #84 when #80 landed, and the diff is still T104 alone. It was built on #77's `3387293e` and merged forward as its base moved, never rebased:
  - #80's `d0f1efcb` at `6db50b03`;
  - #80's final pre-squash head `b9025b6c` at `c1e7d8d8`;
  - main `390e2c28`, #80's squash, at `60bace00`. That merge's tree came from `git merge-tree --write-tree --merge-base b9025b6c`, with both parents recorded. The squash has `b9025b6c`'s tree, so the merge changed no file;
  - main `0116293a` (#81) at `d4b99436`, main `c4b55cc4` (#85) at `182cac76`, main `ca9e1bd5` (#76, T082) at `ddb26c34`, and main `38d3350e` (#82, T100) at `021944a6`. All are plain merges, because this branch held none of those PRs' own commits. Each time, the census's class-A total was re-derived from the rows of the merged tree, now 18621: #82 moved `doxbench-chat.js` 1890 → 1930, and T104 moves `notebook.js` 109 → 204. `validate.yml` merged cleanly each time; T104 adds no skip, so `EXPECT_SKIPPED` stays 11. The `ca9e1bd5` merge applies F2 from the holder's T096 dry run: #76's new postures fixture read a standalone child's token from `/capabilities`, and now reads the private copy (`child.console_token(base[1])`).
- **Fix round 1** (`cb899546`, Copilot `r4173806506`, `r4173806552`, `r4173806590`, `r4173806621`): every served root is in the boundary, removal is exact under a race, a plain `kill` removes the copy, and the shutdown cases look before cleanup. See "Fix round 1" below.
- **Fix round 2** (`c979747a`, Copilot `r4173889265`, `r4173889294`): the copy is judged by its own path, and no static link serves it. See "Fix round 2" below.
- **The reverse boundary** (`36ff6103`): the holder's ruling on batch N's Copilot review, openxFactory#1222 [`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203). The state directory and every served root may not overlap in EITHER direction. See "The reverse boundary" below.
- **Fix round 3** (`219d7cf9`, Copilot `r4174674625`, `r4174674702`): a directory's index page is judged, and a FIFO never blocks a read. See "Fix round 3" below.
- **12.4a, clause by clause** (`a13857ad`): openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause is checked and its gaps are closed; the opener file's lifecycle is self-reviewed in the same commit. See "12.4a, clause by clause" below.
- **One walk of the state directory** (fix round 4, `14285fbb`, Copilot `r4174785933`): every directory and link the walk passes through is judged, and the write is anchored to that walk. See "One walk of the state directory" below.
- **Fix round 5** (`9f328892`, Copilot `r4175213798`, `r4175213842`, `r4175213864`, and `r4177975845` at `ddb26c34`): the snapshot files are served roots, publication and removal take one lock, and a stop is held through publication. See "Fix round 5" below.
- **Fix round 6** (`9dcc9bb5`, Copilot `r4177975898`): an operating-system error during the walk is a refusal by name, for the writer and the reader. See "Fix round 6" below.
- **Fix round 7** (`fb8a1cc4`, Copilot `r4178041022`): Ctrl-C is held like SIGTERM while a copy is written or removed. See "Fix round 7" below.
- **Fix round 8** (`66cffdb8`, Copilot `r4178133814`, `r4178133842`): a running console's copy is reserved for its server's life, and every read that serves a file without the console check judges the file it opened. See "Fix round 8" below.
- **The holder's adversarial review** (fix round 9, `45958bf3`; findings B1-B10 on `fb8a1cc4` and round 8): every standalone plane keeps the boundary, token or not; a platform without the POSIX primitives is refused by name; a browser that cannot open the copy is told how to move it (B3, ruled by Brett); a second spelling of a directory is judged by its identity; only the first stop is raised; `--no-serve` publishes nothing; and a publication sweeps the copies of consoles that died. See "The adversarial review" below.
- **Fix round 10** (`af2a2efb`, Copilot `r4179091592`, `r4179091624`): the tokenless plane's guard walks the state directory once, as the writer does, and refuses an unsupported platform first. See "Fix round 10" below.
- **Fix round 11** (`ed6e4769`, Copilot `r4179239380`, `r4179239411`, `r4179239424`, and a finding its review at `af2a2efb` lists as previously missed): a copy is known by what it holds, in any state directory and mid-removal; a check that cannot be made denies; an entry's in-memory payload comes before its guarded file. See "Fix round 11" below.
- **Fix round 12** (`a2e36652`, Copilot `r4179793524`): a copy is written from a marker, so a part-written or growing copy is known by its first bytes; every reader judges the bytes it sends. See "Fix round 12" below.
- **Fix round 13** (`c5fcdfa4`, Copilot `r4180089809`): the console page's URL is judged as a browser reads it: an exact loopback authority, and no backslash or control character. See "Fix round 13" below.

Claimed on openxFactory#656 in [`5963979413`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963979413). The holder posts READY, and the landers merge.

## What changes: the delivery only

**Standalone means openDox's own default profile.** A plane is standalone when the profile `build_server` builds from IS `opendox.default_profile`, the one an entry point registers where no host has (`console_access.delivery_for`).
- On a standalone plane, the token is minted exactly as before, and `/capabilities` no longer carries it.
- A HOST's plane (openxFactory's `opendox_host.register_openxfactory()`, and this suite's own `_SuiteProfile`) keeps the `/capabilities` delivery, unchanged. See "The governed path" below.

**`generate-and-open` and `python -m opendox.serve` write a private copy** at `<OPENDOX_STATE_DIR>/console/<port>.html`:
- it is an HTML page that forwards to `http://127.0.0.1:<port>/index.html#console_token=<token>`. The token is in the **fragment**, never the query string, so it never reaches a request line, a server log or a `Referer`;
- it begins with a marker comment (`COPY_MARKER`), before any byte of the token, so any part of a copy that holds a token byte is known for a copy (fix round 12);
- it embeds the same record as JSON, escaped so that no value can end its `<script>` element;
- **the browser is handed the copy's `file://` path, never the tokenized URL.** A URL given to `webbrowser.open` sits on a command line (`xdg-open`, the browser), and every user of the machine can read `/proc/<pid>/cmdline`. This is Jupyter's own redirect file, for the same reason;
- the start prints the copy's path (`  console file://…`) and **never the token**, with or without `--no-open`. To re-open the page, open that file again. Beside the path, one line with no token tells a user whose browser cannot open that file how to move the state directory (B3, ruled; see "Known limits"). There is no new verb, so the governed `--help` golden is unchanged;
- the copy is removed when the server stops, BEFORE the listening socket closes. Only the file this process wrote is removed, so a later serve on the same port keeps its own. Another serve's copy is put back by a hard link, or by a rename where links fail. Every writer and remover holds the console directory's lock, so the put-back can never overwrite a newer copy. SIGTERM and SIGHUP are read as Ctrl-C from before the copy is written to after it is removed. So a plain `kill` or a closed terminal removes it too. A stop that arrives while the copy is written or removed, Ctrl-C included, is held until that is done, and only the first stop is raised (fix round 9). A SIGHUP the process was started ignoring (`nohup`), an ignored SIGINT, and a host's own handler are left as they were;
- **a copy is reserved for its server's life** (fix round 8): the writer holds an exclusive `flock` on the file it wrote, on its own descriptor, until the copy is removed. A second console on the same port number and state directory (one on 127.0.0.1, one on ::1) is refused by name and never replaces it. A copy whose console died holds no lock, and the next publication sweeps it, whatever its port (fix round 9);
- `--no-serve` writes no copy, opens none and prints no console line, since it closes the server before anything could use one (fix round 9);
- if no safe copy can be written, the start is refused by name (`generate-and-open refused: …` / `serve refused: …`, exit 1), and the listening socket is closed. A platform without the POSIX primitives the copy rests on (Windows) is refused by name as well (fix round 9). An operating-system refusal, such as a directory this user cannot make or a full disk, is refused by name too. A write that fails part way leaves no partial file, and a copy whose read-back fails is removed. A console nobody can be handed is not served as if it could be.

**The copy is checked as #69's bundle checks its tree, and by T100's trust-file rules.** `src/opendox/console_access.py` copies the rules (it does not import `runtime/bundle.py`'s private helpers):
- the state directory and `console/` must be real directories, owned by this user and writable by no one else. `console/` must also be exactly 0700: its permission bits are judged, so a setgid bit inherited from a parent is accepted;
- the state directory is resolved ONCE, by one walk. Every directory the walk passes through must be this user's or root's, and sticky if others can write it, including a directory reached only through a link's target. Every symbolic link it follows must be this user's or root's. The served-root boundary, the tree rules, the write and the read all work on the walked path, never on the configured one again;
- a missing directory is made relative to its parent's descriptor, born 0700 under a 077 umask, and opened with `O_NOFOLLOW` before anything is made beneath it;
- the file is created with `O_CREAT|O_EXCL|O_NOFOLLOW`, `fchmod`ed to 0600 on its descriptor, fsynced, then renamed into place;
- if a name already at the target is anything other than this user's own regular file of mode 0600 with one link, it is **refused by name and never followed or replaced**, and the start is refused. That covers a link, a dangling link, a directory, a FIFO, another user's file, a hard link and a loosened copy;
- **the served-root boundary** (the #1220 ruling, made two-way by the reverse ruling): the state directory and every root the plane serves may not overlap in either direction. A state directory that IS a served root, lies inside one, or HOLDS one is refused by name before anything is created (`OPENDOX_STATE_DIR (…) lies inside the served repository (…)`, or `… holds …, a root this plane serves`). The served roots (fix round 1) are:
  - the checkout;
  - the static bundle's `--web-dir`;
  - each declared `--source-root`;
  - each registry entry's root;
  - on a loopback plane, the sessions container;
  - the snapshot files `/snapshot.json` reads directly: the configured snapshot, each registered entry's, and, on a loopback plane, the session snapshots' container (fix round 5).

  Paths are compared after resolving, so a link from outside into a served root counts, and so does a served root named through a link into the state directory. Every directory on either path is also compared by its identity, `(st_dev, st_ino)`, so a second spelling of one directory, as a case-insensitive filesystem allows, is the same directory (fix round 9);
- **every standalone plane keeps that boundary, token or not** (fix round 9). A plane with no git identity mints no token and writes no copy, but it shares the state directory with the planes that do. It still refuses a state directory that overlaps its served roots, and still marks the copies' directory private;
- fix round 2 judged the copy's own path, so a served root that holds `console/` refused the write. The reverse rule covers that case and every other served root inside the state directory. That separate check is gone, and its case still passes;
- the static handler never serves a copy: `publish` marks the private-copy directory on the server, and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it (fix round 2), by name or by the directory's identity (fix round 9). For a directory request, the index page the handler would serve (`index.html`, then `index.htm`) is judged too (fix round 3). The file it would serve is judged by its own identity before it is opened, and the file the handler opened is judged again, so a hard link or a link swapped in between is never sent (fix round 8). The body sent is bounded by the length judged, and its first bytes are judged as they are read (fix round 12). Links in `--web-dir` are still followed, since a governed host's composed web root is made of them;
- `/source`, `/snapshot.json` and each registered entry's snapshot read through `serve.read_unless_private`, which judges the file it opened by its identity: a root re-pointed at the state directory after publication, or a hard link to the copy, is a 404 (fix round 8). Every such check also judges the opened file by what it holds, so a copy in another plane's state directory, or one removed mid-read, is refused, and a check that cannot be made denies (fix round 11). The bytes read are judged too, so a copy part-written or growing in another state directory is refused (fix round 12);
- a read (`read_private_copy`, which the tests and T095's harness use) asks all of it again. The file is opened without blocking, so a FIFO is refused at once (fix round 3). It is checked on its descriptor: a regular file, this user's, exactly 0600, one link.

**The page** (`web/views/notebook.js`, its only web file):
- it takes `#console_token=` from `location.hash` at import, before the shell's first fetch;
- it keeps the token in `sessionStorage` for this tab, or in memory where storage is blocked;
- it strips the fragment with `history.replaceState(state, "", pathname + search)`, and a malformed token is stripped too;
- `probeCapabilities()` fills the token into a payload that carries none, so every view keeps reading `caps.console_token` unchanged. A host's published token always wins. The query string is never read;
- `app.js`, `edit.js` and the staging workbench's JS are untouched (T102 edits the workbench). `notebook.js` stays import-free, so its census row is re-measured (109 → 204) and class A's total is re-derived.

**Every route that requires the token still requires it.** `_not_the_human_console`, the catalog, the thread read, the chat turn, the abstract, `/actions/edit` and the gate verbs are unchanged.

## Fix round 1 (`cb899546`)

Copilot's review at `6db50b03`, four threads:
- **`r4173806506`, served roots.** `build_server` reports every root the plane serves files from: the checkout; the static bundle's `--web-dir`, whose handler would serve a copy under it to anyone, 0600 notwithstanding, since the server reads it as its owner; each declared source root; each registry entry's root, which includes the bootstrapped session worktrees; and on a loopback plane the sessions container, where every later session worktree is made. Cases: a state directory under the static bundle and under the sessions container is refused and nothing is written; the reported set is asserted.
- **`r4173806552`, a removal race.** `remove_private_copy` takes the name with an atomic rename to a name only this process uses, then judges what it took. Its own file is removed; anything else is linked back under the name, never over a still newer copy. Both entry points also remove the copy BEFORE closing the listening socket. Case: a replacement written at the instant of removal survives whole.
- **`r4173806590`, SIGTERM.** While a copy exists, `python -m opendox.serve` and `generate-and-open` (local and hosted) read SIGTERM as Ctrl-C (`console_access.terminate_as_interrupt`) and restore the previous handler afterwards. A plane that wrote no copy keeps SIGTERM's default, so the governed and hosted images are unchanged. Cases: the context manager; a plain kill of each of the three entry points exits 0 with the copy gone.
- **`r4173806621`, the shutdown assertions.** They now signal and wait (`_stop`), assert while the state directory still exists, and leave cleanup to the case's `finally`.

## Fix round 2 (`c979747a`)

Copilot's review at `cb899546`, two threads. Each case failed first at `cb899546`, then passed:
- **`r4173889265`, a served root holding `console/`.** The boundary judged the state directory only, so `--web-dir` equal to `<state>/console` (state directory outside every served root) let `GET /<port>.html` serve the copy. The copy's own resolved path is now judged as well: a served root that holds it refuses the write by name. Case: `test_a_served_root_equal_to_the_console_directory_is_refused` (it failed first: DID NOT RAISE).
- **`r4173889294`, an outward link in the bundle.** The static handler follows links inside `--web-dir`, so `web/state-alias -> <state>` served the copy. Links are not refused wholesale: openxFactory's `scripts/ideation-dashboard-serve.py` composes its web root from links out of the bundle (`_composed_web_root`), and confining to the resolved root would 404 the governed bundle. Instead `publish` marks the private-copy directory (`httpd.private_roots`), and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it, for GET and HEAD, files and listings, every port's copy included. A server with no copy marks nothing. Case: `test_a_static_link_out_of_the_bundle_never_serves_a_private_copy` (it failed first: GET answered 200).

## The reverse boundary (`36ff6103`)

The holder's ruling, from batch N's Copilot review ([`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203)). `_refuse_a_served_state_dir` checked one direction only: a state directory in a served root. A served root INSIDE the state directory would let the plane serve what the state directory keeps, the copy among it. Such a root could be `<state>/console` itself, the bundled PostgreSQL's `postgres/run`, or any deeper path. Fix round 2's copy-path check caught only the root that holds `console/`.
- **The fix.** The two may not overlap in either direction. A state directory equal to a served root, inside one, or holding one is refused by name before any write.
- **Case:** `test_a_served_root_inside_the_state_directory_is_refused`, for `console`, `postgres/run`, `anything/else/deep` and `.` (the state directory itself). It failed first (DID NOT RAISE). At `cb899546`, 3 cases failed: `console`, `postgres/run` and `anything/else/deep`. At `c979747a`, 2 failed, because fix round 2 already covered `console`.
- **Pin:** `test_a_source_link_into_the_state_directory_never_serves_the_copy`. A link inside a declared source root that points at the state directory gets 404 from `/source`, which never follows a link out of its root. It passed before the change. It is pinned here and held by mutant M13.

## Fix round 3 (`219d7cf9`)

Copilot's review at `60bace00`, two threads. Each case failed first at `60bace00`:
- **`r4174674625`, a directory's index page.** For `/sub/`, the stdlib handler serves the first of `index_pages` that is a file, and `send_head` judged only the directory. So `web/sub/index.html`, linked to a copy, was served at `/sub/` while `/sub/index.html` answered 404. The index page the handler would pick is judged too. The case is `test_a_directory_index_linked_to_a_private_copy_is_never_served`, run for `index.html` and `index.htm`. It covers GET and HEAD of `/sub/`, `/sub/<index>` and `/sub/?x=1`, and checks that the bare `/sub` redirect carries nothing. A directory whose index page is the bundle's own still answers 200. Before the fix, GET `/sub/` answered 200.
- **`r4174674702`, a FIFO.** `read_private_copy`'s read-only `open` of a FIFO with no writer blocked forever, before the descriptor check could refuse it. It opens with `O_NONBLOCK` now. The case is `test_a_fifo_at_the_copy_is_refused_without_blocking`, which runs the read and the write on threads with a bounded join, so a failure fails and never hangs. Before the fix, "the read blocked on a FIFO".

## 12.4a, clause by clause (`a13857ad`)

openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause was checked against `d4b99436`. Batch N's gaps (c), the non-blocking reader, and (d), the automatic index file, closed in fix round 3. This commit closes the rest. Each case failed first at `d4b99436` unless it is marked as a pin.
- **(a) "Replaced only when it is this user's own regular file of mode 0600 with one link."** The writer checked the type, the owner and the link count, but not the mode, so a loosened own copy was replaced. It now applies the reader's own rule, so a loosened copy is refused by name and left as it is. This also answers Copilot's `r4174785965` at `d4b99436`.
- **"In a directory of mode 0700."** A `console/` loosened after it was made (0755, 0750, 0711) was accepted wherever no one else could write it. The writer and the reader now refuse it by name. A setgid bit inherited from a parent is accepted (a pin).
- **(b) The writer's and the entry points' regressions.** One is another user's file at the name (a pin). The other runs through `generate-and-open` and through `python -m opendox.serve`: each of a symbolic link, a directory, a FIFO, a hard-linked copy and a loosened copy refuses the START by name. That means exit 1, nothing printed that serves, no browser, the planted thing untouched and the socket closed. The loosened copy failed first; the other kinds are pins.
- **(e) A served root named through a symbolic link** that leads to the state directory or into it (`console`, `postgres/run`, the directory itself) is judged where it leads. Through the entry point, the case is a `--web-dir` link to `<state>/console` (pins, held by mutant M24).

**The opener file's lifecycle, self-reviewed** (write, replace, read, remove at stop, refuse before writing). Each finding below has a case that failed first at `d4b99436`, and a mutant:
- an operating-system refusal on the way, such as a parent that will not let this user make the state directory or a full disk, escaped as a raw `OSError` with a traceback and no refusal by name. It is now a `ConsoleAccessRefused` naming the copy, through both entry points;
- a write that fails part way removes its temporary file;
- a copy whose read-back fails is removed with the refusal;
- removal put another serve's copy back by a hard link only. Where links fail (EPERM: a filesystem without them, or a directory), that copy was deleted. It is now renamed back where the name is free;
- SIGHUP, which a closed terminal sends, ended the process with the copy left behind. While a copy exists it is read as Ctrl-C, as SIGTERM is, unless the process was started ignoring it (`nohup`);
- the signal handling covered only the serve loop. A SIGTERM while the browser opener ran, which can take seconds, took SIGTERM's default action on a hosted standalone plane and left the copy behind. It now covers the whole window from the write to the stop, in both entry points.

## One walk of the state directory (fix round 4, `14285fbb`)

Copilot's review at `d4b99436`, `r4174785933`. Copilot's probe reproduced here. With `OPENDOX_STATE_DIR=alias/state`, `alias -> shared/hop` and `hop -> private`, the tree rules judged the configured path's components and the directories above the resolved path. `shared`, reached only through a link's target, was neither, so at mode 0777 and not sticky it went unjudged. The write also walked the configured path again after the served-root check. So a `hop` re-pointed in between put the token's copy in a served root, at `served/state/console/8080.html`, where `/source` serves it.

`_walked` resolves the state directory once, component by component as the kernel walks it. Every directory it passes through is judged by the rule for directories above the state directory, and every link it follows by the rule for a link. The served-root boundary, the tree rules, the write and the read all work on the walked path. Both cases failed first at `182cac76`:
- `test_a_directory_passed_through_by_an_intermediate_link_is_judged`: Copilot's layout with `shared` at 0777. The write and the read are both refused by name. Before the fix: DID NOT RAISE;
- `test_a_link_swapped_after_the_checks_never_redirects_the_write`: `hop` is swapped to a served root right after the served-root check. The copy lands where the checks saw the state directory, and the served root gains nothing. Before the fix, the copy landed in the served root.

## Fix round 5 (`9f328892`)

Copilot's review at `182cac76`, three threads. Each case failed first at `ddb26c34`:
- **`r4175213798`, the snapshot files.** `/snapshot.json` reads its file directly, not through the static handler. So a `--snapshot` named at an earlier copy, `<state>/console/<port>.html`, would have been replaced by the new copy and served to anyone. The served roots now hold the configured snapshot, each registered entry's snapshot, and, on a loopback plane, the session snapshots' container. The reverse boundary therefore refuses that start by name, through a link as well. Cases:
  - `test_a_snapshot_inside_the_state_directory_refuses_the_start`, which before the fix reported "the server served";
  - `test_the_plane_reports_its_snapshot_files_as_served_roots`.
- **`r4175213842`, the rename-back race.** Where a hard link fails, another serve's copy is renamed back where the name is free. A newer copy published between that check and the rename was overwritten. Every writer and remover of `console/` now takes the directory's exclusive `flock`, so publication and removal are serialized. The case is `test_a_copy_published_during_a_rename_back_is_never_overwritten`: a third copy is published at exactly that moment, waits, and stands. Before the fix, "an older copy overwrote the newest".
- **`r4175213864`, a stop during publication.** SIGTERM was read as Ctrl-C only after `publish()` returned. Both entry points now install the handler BEFORE publication, on any plane that writes a copy, and keep it through removal. A stop that arrives while the copy is written or removed is held (`deferred_termination`): publication raises it once the copy is in hand, and removal lets it go. Cases:
  - `test_a_stop_during_publication_removes_the_copy`, for both entry points;
  - `test_a_stop_during_removal_lets_the_removal_finish`;
  - `test_deferred_termination_holds_a_stop_until_the_block_ends`.

Copilot's review at `ddb26c34` raised the same stop for `generate-and-open` (`r4177975845`), and `9f328892` answers it.

## Fix round 6 (`9dcc9bb5`)

Copilot's review at `ddb26c34`, `r4177975898`. The walk and the served-root check ran outside the writer's conversion of `OSError`. So an overlong state-path component (ENAMETOOLONG) or an unsearchable parent (EACCES) escaped as a raw `OSError`, and both entry points ended in a traceback instead of a named refusal. Both are inside the conversion now, and the reader converts the same way. The cases failed first at `9f328892`:
- `test_a_state_path_the_walk_cannot_take_is_refused_by_name`, for both kinds of path, through the writer and the reader;
- `test_a_state_path_the_walk_cannot_take_refuses_the_start`, for both kinds through `serve` and through `generate-and-open`. Each exits 1 with the refusal named, prints nothing that serves, and closes the socket.

## Fix round 7 (`fb8a1cc4`)

Copilot's review at `9f328892`, `r4178041022`. SIGINT kept Python's immediate handler, so `deferred_termination` never held it. A Ctrl-C just after publication's rename raised before the caller held the copy, and the copy was left behind. A second Ctrl-C just after a removal's take left a `.removing-*` file holding the token. `terminate_as_interrupt` now takes SIGINT too, still raised as a `KeyboardInterrupt`, but only where it has Python's own handler; an ignored SIGINT, or a host's own handler, is left as it was. The cases failed first at `9dcc9bb5`. Each one checks for the handler before it sends SIGINT, so a raw interrupt never aborts the test session:
- `test_ctrl_c_just_after_the_copys_rename_leaves_no_copy`, for both entry points;
- `test_a_second_ctrl_c_after_the_removal_rename_leaves_nothing`;
- `test_terminate_as_interrupt_takes_ctrl_c_only_from_its_default`.

## Fix round 8 (`66cffdb8`)

Copilot's review at `fb8a1cc4`, two threads. Each case failed first at `fb8a1cc4`:
- **`r4178133814`, two consoles on one port number.** The copy's name is per port, so a console on 127.0.0.1 and one on ::1, sharing a state directory, replaced each other's copy, and the first console's printed path opened the second. The writer now takes an exclusive `flock` on the file it wrote, before the rename, on its own descriptor (`_Reservation`, held in `PrivateCopy.reservation`), and keeps it until the copy is removed. A later publication on that port asks for the lock without waiting. A held lock is a running console's, refused by name (`… belongs to a console that is still running (pid N)`), and its copy is left as it was. A free lock is a stale copy's, and it is replaced. Cases:
  - `test_two_consoles_on_one_port_number_never_share_a_copy`, with real IPv4 and IPv6 planes (failed first: DID NOT RAISE);
  - `test_a_running_consoles_copy_is_never_replaced` (failed first: DID NOT RAISE);
  - `test_a_copy_whose_console_died_is_replaced`, a pin: a subprocess writes a copy and exits, and the kernel releases its lock.
- **`r4178133842`, a root retargeted after publication.** `/source` resolves a declared root again on every request, so a root re-pointed at the state directory after publication served the copy. Every read that serves a file to any caller without the console check now judges the file it OPENED, by `(st_dev, st_ino)`, against every name in the copies' directory (`console_access.is_private_file`). `/source` and `/snapshot.json` read through `serve.read_unless_private`. The static handler judges the file it would serve before the stdlib opens it, and judges what the stdlib opened; a file swapped in between is closed unsent and the connection closed. The identity also stops a hard link to the copy, which a path cannot tell apart. Cases, each answered 200 with the copy before the fix:
  - `test_a_source_root_retargeted_after_publication_never_serves_the_copy`, Copilot's layout;
  - `test_a_snapshot_retargeted_after_publication_never_serves_the_copy`;
  - `test_a_hard_link_to_the_copy_is_never_served`, for the static bundle and the served checkout;
  - `test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check`, which blinds the first check and reads the raw response: the copy's bytes are never sent.

## The adversarial review (fix round 9, `45958bf3`)

The holder had an independent adversarial review of `fb8a1cc4` and of round 8 run ahead of Copilot: 1 high, 3 medium and 6 low findings. It confirmed that round 8 answers both of Copilot's threads, and it found what follows. The reviewer's own cases are kept as written, under their ids (`test_b1_…`, `test_b2_…`, `test_b3_…`, `test_b6a_…` to `test_b6c_…`). The cases that pin B1, B2, B3, B4, B5, B8 and B9 failed first at the merged pre-fix tree.
- **B1 (high), a tokenless sibling plane.** A standalone plane that minted no token (no git identity, so no session verbs) wrote no copy, so it asked no boundary and marked no private root. A root of its that held the shared state directory served a sibling plane's copy, token and all, to any local caller. The delivery is now the plane's, token or not (`build_server`), and `publish` on every standalone plane asks the boundary and marks the copies' directory private (`console_access.guard_private_roots`). Only the writing still needs a token. Cases: the reviewer's two real planes, where the tokenless plane now refuses its start by name; the same refusal in the process; and a tokenless plane whose `--web-dir` links into the state directory and whose checkout holds a hard link to the sibling's copy, which answers 404 for the copy, the listing and `/source`.
- **B2, a platform without the POSIX primitives.** On Windows the standalone start ended in an `AttributeError` traceback. `console_access.unsupported_platform()` names what is missing, and the writer and the reader refuse by name before anything else, as `bundle.unsupported_platform()` does for #69's bundle (holder's ruling). Cases: the reviewer's child with `os.getuid`, `O_NOFOLLOW` and `O_DIRECTORY` removed, which now exits 1 with `serve refused: …`; and the writer and the reader without `O_NOFOLLOW`, and without calls relative to a directory's descriptor.
- **B3, a browser that cannot open the copy.** Ubuntu's default snap browser cannot read a file under a hidden directory such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. There was no way past it, because the token is never printed. RULED by Brett (2026-10-04, *"Hint line, accepted limit (Recommended)"*): beside the copy's path, the start prints ONE line with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again (`console_access.UNOPENABLE_HINT`). The case runs both entry points, finds the line once, right after the copy's path, and finds the token in no line printed. The README's side is openDox#17's (T076). See "Known limits" below.
- **B4, a second spelling.** The served-root overlap and the static handler's guard compared spellings. On a case-insensitive filesystem (macOS's default), `<root>/STATE` is `<root>/state` and `/state-alias/CONSOLE/` lists `console/`, while resolving a path keeps the case it was given. Both checks now also compare the directories' `(st_dev, st_ino)`, the copies' directory's own included (`within_private_roots`). Linux cannot spell one directory two ways without root, so the cases simulate a case-insensitive filesystem by telling `os.stat` and `os.listdir` that the second spelling is the first: the boundary for the state directory, a root inside it and a root holding it, and the static listing. The reviewer's macOS reading is derived from the code, not observed on a Mac, and the same holds here.
- **B5, a second stop.** A double Ctrl-C, or a SIGTERM and then a closing terminal's SIGHUP, could land its second signal after the first had unwound the serve loop and before the cleanup's hold. That second interrupt escaped the `finally` and left the copy. The first stop is now latched, and every later one is only recorded. Cases: the reviewer's child, which delivers the second stop at exactly that point and now exits 0 with no copy and no traceback; and the latch in the process.
- **B6, three rules no case pinned.** The walk's link-owner check, the reader's re-judging of the tree, and the `fchmod` under a umask that strips owner write each had a mutant the suite let live. The reviewer's three cases kill them (M46, M47, M48).
- **B7, browser history.** Accepted by the holder as a limit within the ruled design. See "Known limits" below.
- **B8, `--no-serve`.** It opened a copy for a server it then closed, and deleted the copy on return. It now publishes, opens and prints no copy. The page's URL is still printed and opened, as before T104, and carries no token. The cases that read a copy now serve once and stop at a Ctrl-C (`stopped_once_serving`), and each refusal case asserts that it never served.
- **B9, a copy left by a crash.** A SIGKILLed serve's copy stayed until a later serve took its port. A publication now sweeps, under the console directory's lock, every copy whose reservation is free, and every temporary or taken name that a dead writer or remover left. Nothing else is touched: not a running console's copy, not a loosened copy (which 12.4a refuses and never replaces), not a link, and no file of another name. Cases: the sweep's rules in the process, and the reviewer's SIGKILL across two real serves.
- **B10, the body.** This body was rebuilt from the evidence at the head: the suite, every mutant, the browser and the governed run.

Mutant run 16 at `fb8a1cc4` also left one mutant alive: M32, where the configured snapshot is not a served root. Every case named the snapshot at a copy that already existed, so the registered entry's own snapshot (M32b's line) refused it either way. `9fe57dff` adds a snapshot named at `<state>/console/<port>.html` before that copy exists, which only the configured snapshot's own served root refuses.

Two follow-ups after round 9. `c4e6c01b` adds B3's hint line, once Brett had ruled it. `0539f8c0` answers mutant run 18 at `45958bf3`, whose one survivor was M36c: the removal never closed the descriptor that reserved the copy, and nothing asked about it. `test_a_removal_releases_the_copys_reservation` now asks that, after a removal and where the directory is gone already, no descriptor of this process is the copy's file. That case's first failure printed the copy's `repr`, and with it the token. So `opened_url` is kept out of `PrivateCopy`'s `repr`, and `test_a_copys_repr_never_carries_its_token` pins it.

## Fix round 10 (`af2a2efb`)

Copilot's review at `0539f8c0`, two threads, both on round 9's tokenless guard. Each case failed first at `0539f8c0`:
- **`r4179091592`, one walk for the tokenless plane.** The boundary check and the marking each resolved the configured state path for themselves. A link on that path re-pointed between the two left the boundary judging the real state directory and the marking naming a decoy, and an outward static link then served a sibling plane's copy. `guard_private_roots` now walks the state directory once (`_walked`), judging every directory and link on the way as the writer does, and the boundary and the marking both use that walk's path. Cases:
  - `test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory`, Copilot's layout. The sibling's copy and the listing stay 404;
  - `test_a_tokenless_planes_unsafe_state_path_refuses_its_start`: a world-writable, non-sticky directory on the way refuses the tokenless start by name.
- **`r4179091624`, the platform first.** A tokenless plane skipped the platform check, started, and marked a private root that its handlers then judged with the missing `O_NONBLOCK`. `publish` and the guard now refuse an unsupported platform by name before anything else, token or not. Cases:
  - `test_a_tokenless_plane_refuses_a_platform_without_the_primitives`, in the process;
  - `test_a_tokenless_start_without_the_posix_primitives_refuses_by_name`, the no-identity variant of B2's child.

## Fix round 11 (`ed6e4769`)

Copilot's review at `af2a2efb`, three threads and one finding in the review's body. Each case failed first at `af2a2efb`:
- **`r4179239380`, another state directory.** Two standalone planes of one user can have different `OPENDOX_STATE_DIR` values. The guard knew only its own plane's copies, so if plane A's web root linked to plane B's state directory, A served B's copy. `is_private_file` now first judges the file it was given by what that file holds. A regular file whose head carries a console record is a copy, wherever it lies (`_carries_a_console_record`); the head is read with `pread` from the descriptor already open. Case: `test_another_state_directorys_copy_is_never_served`, through a static link and through a hard link under `/source`.
- **`r4179239411`, a removal mid-read.** A copy removed after a read opened it, and before the scan could stat its name, matched nothing in the directory. The open file still holds its record, so it is refused. Case: `test_a_copy_removed_during_the_scan_is_never_served`.
- **`r4179239424`, a check that cannot be made.** These used to let the file through, and each now denies it:
  - a private directory that exists but cannot be listed (`EMFILE`, `EACCES`);
  - a name in it whose status cannot be read, for any reason but its removal;
  - a regular file whose head cannot be read.

  A private directory that does not exist still holds no copy. Cases:
  - `test_a_private_directory_that_cannot_be_scanned_denies_the_read`, with `EMFILE` simulated, and with `EACCES`;
  - `test_a_name_whose_status_cannot_be_read_denies_the_read`;
  - `test_a_file_whose_head_cannot_be_read_is_denied`.
- **Previously missed, an entry's payload.** On a standalone plane, an entry with both an in-memory payload and a snapshot file served the file, and a 404 where the file was missing. That broke `SnapshotEntry.read_bytes`' payload-first contract. The payload comes first again, and only the file fallback is guarded. Case: `test_an_entrys_payload_comes_before_its_guarded_file`, with the file missing, present, and a private copy.

## Fix round 12 (`a2e36652`)

Copilot's review at `1e114a19`, `r4179793524`. Round 11 recognized a copy in another state directory only by its whole record. So another plane's temporary file, part-written with the token in its meta refresh and no record yet, passed the static handler, `/source` and `/snapshot.json`. A file that grew after it was judged passed too, because the stdlib copies a static file to its end as it is when read.
- **A marker first.** The writer now begins every copy with `COPY_MARKER`, an HTML comment, ahead of any byte of the token. A copy is written from its start, so any part of it that holds a byte of the token already holds the whole marker. `is_copy_bytes` recognizes a copy by that marker, or by its whole record.
- **What is read is judged.** `read_unless_private` reads first, then judges what it read, as well as the file by its descriptor.
- **What is sent is bounded and judged.** The static handler's `copyfile` sends at most the length the file had when it was judged. It reads the body's first bytes before sending anything and judges them, so a copy's bytes are never sent.

Cases, each failing first at `1e114a19`:
- `test_another_state_directorys_partial_copy_is_never_served`, Copilot's layout, through all three readers, GET and HEAD;
- `test_a_file_that_grows_after_its_static_check_never_sends_a_token`;
- `test_a_file_that_grows_after_its_read_check_never_returns_a_token`;
- `test_a_copy_starts_with_its_marker_before_any_token_byte`;
- `test_a_copy_replaced_after_its_read_is_never_returned`, which pins the read-first design.

The identity match against this plane's own `console/` remains as defence in depth. `1e114a19` had pinned it with a part-written copy (mutant run 23 left M38 alive); with the marker such a copy is known by its bytes, so `test_a_file_in_the_copies_directory_is_refused_by_its_place` now holds a token-bearing file there that the marker cannot recognize.

## Fix round 13 (`c5fcdfa4`)

Copilot's review at `a2e36652`, `r4180089809`. `urlsplit` reads `http://evil.example\@127.0.0.1:8080/index.html` as user information at `127.0.0.1`. A browser takes the backslash for a slash and navigates to `evil.example`, whose page could then read the token's fragment. The entry points build their own URLs, but `write_private_copy` and `opened_url` are public. `_refuse_page_url` now requires the authority to be exactly a loopback host, spelled as `serve.server_url` spells it, with an optional port of at most 65535. A backslash or a control character anywhere in the URL is refused. Cases:
- `test_a_page_url_a_browser_reads_as_another_host_is_refused`, eight URLs, Copilot's first. Seven failed first at `a2e36652`;
- `test_every_loopback_page_url_a_plane_announces_is_accepted`.

## No test server outlives its run (`d466c1d2`)

The holder found orphaned `python -m opendox.serve` children of these cases on the machine, hours old. Each was plane B of the B1 case, left by a mutant run: where a mutant made B serve instead of refusing, the case failed, and its `finally` stopped plane A only. Every server child the cases start now runs in its own session, and is reaped with its process group at teardown, pass or fail: SIGTERM, a bounded wait, then SIGKILL. On Linux it also gets SIGTERM from the kernel if the test process dies first (`PR_SET_PDEATHSIG`), since a killed run runs no teardown. Cases: `test_a_server_left_running_is_reaped_with_its_group` and `test_a_server_outlives_no_killed_run`. Re-run under mutant M44, the B1 case fails as it must, and no server from that run survives.

## Known limits, accepted for release 1

After a serve restart, a tab opened against the old serve holds a stale token in its `sessionStorage`. The fixed "reload the page" messages (`doxbench-chat.js`, `staging-workbench-model.js`, T102's area) then name the wrong remedy on a standalone plane: a reload keeps the stale token. The remedy is the new tab the restart opened, or the new console file. The holder ACCEPTED this for release 1 and passes the copy change to T102's writer.

**Some browsers cannot open the copy** (adversarial review B3). Ubuntu's default snap browser, and Flatpak browsers, are kept out of hidden directories such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. Brett ruled this an accepted limit for release 1, with a hint (*"Hint line, accepted limit (Recommended)"*, 2026-10-04): the start prints one line, with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again. The README's side is openDox#17's (T076).

**The browser's persistent history keeps the fragment** (adversarial review B7). `history.replaceState` strips the token from the address bar and from the tab's session history, but the browser's own history store (Chromium's `Default/History`) has already recorded the opened URL, fragment included. The holder ruled this a limit within the ruled design: the history file is this same user's data, as the 0600 copy is, and it is not served or sent anywhere.

## Tests

`tests/test_console_token_delivery.py` (new):
- a standalone plane carries no token on `/capabilities`, under no key and in no byte;
- a host's plane keeps it there and writes no copy;
- **a second OS user cannot obtain it.** First, by asking: 102 GET and HEAD requests (51 paths: the whole bundle of 42 files, `/`, `/capabilities`, the snapshots, the project register, `/source`, the guarded reads without the token), and 8 POST routes. No body and no header carries it. Second, by the copy: the file is 0600 and the directories 0700, all this user's. A copy owned by another uid is refused by the reader (simulated through `getuid`);
- the opened URL, the meta refresh and the link carry the token in the fragment only, with an empty query; a page URL that already has a query or a fragment, is not http, or is not loopback is refused;
- the record cannot break out of its `<script>`: a hostile `</script><img …>` value stays inside, and the record round-trips;
- `generate-and-open` hands the opener a `file://` path, prints the copy's path and never the token, and removes the copy; `--no-open` opens nothing and still prints the path. These cases serve once and stop at a Ctrl-C, since `--no-serve` writes no copy;
- an unsafe state directory refuses the run, naming the directory;
- the copy is born 0600 in a 0700 tree even under umask 0;
- these are refused: a planted link, a dangling link (nothing is created at its target), a hard link, a loosened copy (0644), a planted directory, a linked `console/`, a group- or world-writable state directory, and a non-sticky shared parent (a sticky one is accepted);
- a later copy replaces this user's earlier one, and removal is exact;
- every guarded route refuses without the token, or with a wrong one, and passes the console check with the copy's token;
- **the served-root boundary:** the state directory equal to the served root, under it, under a declared source root, through a link into it, and through `generate-and-open`. Each is refused by name, and nothing is written;
- **the reverse boundary:** a served root that is `<state>/console`, `<state>/postgres/run`, a deeper path under the state directory, or the state directory itself. Each is refused by name, and nothing is written. Separately, a link in a source root that points at the state directory gets 404 from `/source`;
- the plane reports its served roots: the checkout and each declared source root;
- **fix round 3:** a directory's index page linked to a copy is never served, for either index name; a FIFO at the copy is refused at once by the reader and by the writer;
- **12.4a:** the writer refuses a loosened own copy and another user's file, and leaves each as it was. Both entry points refuse their start by name for each of five planted kinds. A served root named through a link into the state directory is refused. A `console/` that is not 0700 is refused, and a setgid one is accepted;
- **the lifecycle:** an unwritable state directory and a full disk are refusals by name, and no partial file is left. A failed read-back leaves no copy. Another serve's copy survives a removal where hard links fail. SIGHUP removes the copy at all three entry points, and a `nohup` SIGHUP stays ignored. A SIGTERM while the browser opens removes the copy, hosted and local;
- **one walk:** a directory passed through by an intermediate link is judged, and a link swapped after the checks never redirects the write;
- **fix round 5:** a snapshot inside the state directory refuses the start, through a link too. The snapshot files are reported as served roots. A copy published during a rename-back is never overwritten. A stop during publication, at either entry point, or during removal leaves no copy;
- **fix round 6:** an overlong or unsearchable state path is a refusal by name, for the writer, the reader and both entry points;
- **fix round 7:** Ctrl-C right after the copy's rename, or right after a removal's take, leaves nothing behind. Ctrl-C is taken only from Python's own handler;
- **fix round 8:** a running console's copy is never replaced, by a second publication or by a real IPv6 plane on the same port number, and a dead console's copy is. A source root or a snapshot retargeted after publication, a hard link to the copy in the bundle or the checkout, and a link swapped after the static check never serve the copy;
- **fix round 9 (the adversarial review):** a tokenless standalone plane refuses a state directory inside its checkout, as a real second plane and in the process, and never serves a sibling's copy through a link or a hard link. A platform without the POSIX primitives is refused by name, through `serve` and by the writer and the reader. A second spelling of the state directory, of a root inside it, of a root holding it, or of `console/` is judged by its identity. Only the first stop is raised, so a second one never leaves a copy. The walk's link-owner rule, the reader's re-judging of the tree and the `fchmod` under umask 0o277 are pinned. `--no-serve` writes, opens and prints no copy. A dead console's copy, temporary file or taken name is swept, and nothing else is. A snapshot named at a copy not yet written refuses the start. Both entry points print the hint line once, right after the copy's path, and no line they print carries the token. A removal releases the copy's reservation, and a copy's `repr` never carries its token;
- **fix round 10:** a tokenless plane whose state link is re-pointed mid-guard marks the real directory, and the sibling's copy stays 404. A tokenless plane refuses an unsafe state path, and a platform without the POSIX primitives, by name, in the process and as a user starts it;
- **fix round 11:** another state directory's copy is never served, through a static link or a hard link. A copy removed mid-read is still refused. A private directory that cannot be listed, a name whose status cannot be read, and a head that cannot be read each deny. An entry's payload comes before its guarded file;
- **fix round 12:** a copy begins with its marker, before any token byte. Another state directory's part-written copy is refused by the static handler, `/source` and `/snapshot.json`. A file that grows after its checks, or is emptied after its read, never hands out a token.;
- **fix round 13:** a page URL whose authority a browser reads as another host (a backslash, user information, a bad port) is refused, and nothing is written; every loopback spelling a plane announces is accepted;
- **no test server outlives its run:** a server left running is reaped with its process group, and a child outlives no killed run;
- end to end, as a user runs it: `python -m opendox.cli generate-and-open --local --no-open` and `python -m opendox.serve`, each in a child process with neither sibling importable.

`tests/test_console_token_view.py` (new, node): fragment taken, kept and stripped; a host's token wins; the degraded probes; a reload keeps the token from storage; the query string is never read; a malformed token is stripped and not kept; blocked storage keeps the token in memory.

These standalone children read the token from the private copy (`standalone_child.Child.console_token`), because they used to read it from `/capabilities`:
- `test_capability_honesty.py`, its 4 standalone cases and the unknown-tile-kind thread read;
- `test_neutral_turn_scope.py`;
- `test_doxbench_defaults.py`;
- `test_chat_model_configuration.py`'s standalone fixture;
- T103's `test_loopback_host_gate.py` real local serve, which now asserts that no token is on `/capabilities` and that the copy exists exactly when a token is minted.

**Mutants, 90 of 90 killed** (each applied alone, both new test files run, in a throwaway worktree of `c5fcdfa4`):

| mutant | failed |
|---|---|
| M1 token back in /capabilities | 4 |
| M2 query string instead of fragment (server) | 6 |
| M2b page reads the query string instead of the fragment | 4 |
| M3 the file at 0644 (one constant) | 8 |
| M3b the file written 0644, reader unchanged | 89 |
| M4 no link check at all | 18 |
| M4b no planted-target check on write | 15 |
| M4c the read follows a link | 1 |
| M5 the page never strips the fragment | 3 |
| M6 no served-root boundary | 19 |
| M6b the boundary checks equality only | 8 |
| M6c the plane reports no served root | 9 |
| M7 removal puts no replacement back | 3 |
| M8 a plain kill is not read as Ctrl-C | its own SIGTERM ended the run (rc -15), after 5 failures |
| M9 the static bundle is not a served root | 3 |
| M9b the sessions container is not a served root | 2 |
| M11 the static handler serves a private copy | 4 |
| M12 no reverse check (a served root inside the state dir) | 9 |
| M13 /source follows a link out of its root | 1 |
| M14 a directory request serves its index page unjudged | 2 |
| M15 the read blocks on a FIFO | 1 |
| M16 the writer replaces a loosened own copy (12.4a) | 3 |
| M17 the console directory's mode is not judged (12.4a) | 3 |
| M17b the setgid bit counts as a loosened mode | 1 |
| M18 an operating-system refusal escapes raw | 8 |
| M19 a failed read-back leaves the copy | 1 |
| M20 no rename-back where a hard link fails | 2 |
| M21 a hangup is not read as Ctrl-C | 4 |
| M21b nohup's ignored hangup is overridden | 1 |
| M22 cli: the handler covers only the serve loop | 3 |
| M23 a partial temporary file is left | 1 |
| M24 a served root is not judged where its link leads | 2 |
| M25 the walk judges no directory it passes through | 2 |
| M26 the write is not anchored to the walk | 1 |
| M30 serve: the handler is installed only after publication | 5 |
| M31 a stop is never held | 1 |
| M32 the snapshot file is not a served root | 1 |
| M32b a registered entry's snapshot is not a served root | 1 |
| M32c the session snapshots' container is not a served root | 1 |
| M33 a removal takes no lock | 1 |
| M33b a publication takes no lock | 1 |
| M34 the walk runs outside the writer's conversion of OS errors | 6 |
| M34b the reader converts no OS error | 2 |
| M35 Ctrl-C is not held | 5 |
| M35b a host's own Ctrl-C handler is overridden | 1 |
| M36 a running console's copy is replaced | 2 |
| M36b the copy is never reserved | 3 |
| M36c removal keeps the reservation | 1 |
| M36d removal keeps the reservation where nothing is left to remove | 1 |
| M37 /source reads unguarded | 5 |
| M37b a registered snapshot reads unguarded | 3 |
| M37c the static handler judges no identity before it opens | 4 |
| M37d the static backstop sends what the stdlib opened | 1 |
| M38 is_private_file matches no identity | 1 |
| M39 the boundary ignores identity (B4) | 3 |
| M40 the static guard ignores identity (B4) | 1 |
| M41 the first stop is not latched (B5) | 2 |
| M41b a held stop is raised after the first (B5) | 1 |
| M42 no sweep (B9) | 2 |
| M42b the sweep ignores reservations (B9) | 1 |
| M42c the sweep removes what is not this user's own copy (B9) | 1 |
| M43 --no-serve publishes (B8) | 1 |
| M44 a tokenless plane guards nothing (B1) | 5 |
| M44b the delivery depends on the token (B1) | 7 |
| M44c a tokenless plane marks no private root (B1) | 2 |
| M44d a tokenless plane asks no boundary (B1) | 3 |
| M45 the writer refuses no platform (B2) | 1 |
| M45b the reader refuses no platform (B2) | 2 |
| M46 (reviewer m2) the walk never judges a link's owner (B6a) | 1 |
| M47 (reviewer MA) the reader never re-judges the tree (B6b) | 1 |
| M48 (reviewer m1) no fchmod (B6c) | 1 |
| M49 (reviewer MC) the page keeps the token in localStorage | 2 |
| M50 generate-and-open prints no hint line (B3) | 1 |
| M50b serve prints no hint line (B3) | 1 |
| M51 a copy's repr carries its token | 1 |
| M52 the tokenless guard resolves the state path again to mark it | 1 |
| M52b the tokenless guard does not walk | 1 |
| M53 a tokenless plane skips the platform check | 2 |
| M54 a copy is not known by what it holds | 3 |
| M54b a head that cannot be read is allowed | 1 |
| M55 a directory that cannot be listed allows | 2 |
| M55b a name whose status cannot be read allows | 1 |
| M56 an entry's file comes before its payload | 3 |
| M57 a copy is written without its marker first | 4 |
| M57b a copy's marker is not recognized | 4 |
| M58 the reader judges none of the bytes it read | 1 |
| M58b the reader judges the file before it reads it | 2 |
| M59 the static body is copied as the stdlib copies it | 1 |
| M61 the page URL's authority is not judged exactly | 4 |
| M61b a backslash or a control character passes | 2 |

M10 (the copy's own path not judged) is retired with the check it mutated: the reverse rule replaced that check, and M12 is the mutant that removes the reverse rule.

**The browser, end to end.** Chromium (Playwright 1.61.0, the T096 prep harness's driver) ran `opendox generate-and-open --local --no-open` from a fresh install. The first run was on a LOCAL, never-pushed integration of this branch with #77 and `main`. It was re-run at `60bace00`, at `182cac76` and at `c5fcdfa4`, which carry both. 18 of 18 checks passed every time:
- the private copy (0600) forwards to `/index.html`;
- the address bar and the tab's session-history entry carry no fragment. The browser's persistent history still records the opened URL; see "Known limits" above;
- `sessionStorage` holds the token, and `probeCapabilities` fills it;
- the page's own `/capabilities` has none;
- the catalog answers 200 with the token and `console_required` without it;
- a reload keeps the token, and a new tab at the bare URL has none;
- no request URL or `Referer` carries the token, and there was zero `pageerror`;
- nothing the server printed carries the token;
- the copy is gone after SIGTERM, and no bundled PostgreSQL is left.

**The whole suite, locally** (`tests` and `tests_runtime`, with `--basetemp` and `TMPDIR` outside the tree):

| commit | passed | skipped |
|---|---|---|
| `6db50b03` | 3415 | 177 |
| `cb899546` | 3422 | 177 |
| `c979747a` | 3424 | 177 |
| `60bace00` | 3774 | 177 |
| `d4b99436` | 3821 | 177 |
| `a13857ad` | 3851 | 177 |
| `182cac76` | 3858 | 177 |
| `14285fbb` | 3860 | 177 |
| `ddb26c34` | 3894 | 177 |
| `9f328892` | 3901 | 177 |
| `9dcc9bb5` | 3907 | 177 |
| `fb8a1cc4` | 3911 | 177 |
| `0539f8c0` | 4078 | 177 |
| `af2a2efb` | 4082 | 177 |
| `ed6e4769` | 4091 | 177 |
| `d466c1d2` | 4093 | 177 |
| `1e114a19` | 4094 | 177 |
| `a2e36652` | 4099 | 177 |
| `c5fcdfa4`, the head | 4111 | 177 |

At the head, 0 failed. The count grew with the merges: #80's final head carried main's landings since `d0f1efcb` (#63, #69, #73, #64, #72 and #77), and then #81, #85, #76 and #82 landed. CI's own reading at `c5fcdfa4` is `selected=4288 passed=4277 skipped=11`, against `validate.yml`'s floors 3977 / 3966 and its exact 11.

## The governed path

openxFactory's existing token-reading suites were run twice, locally only. First against the pinned openDox-code `047bb4fa`, then against `047bb4fa` with T104's commits applied. The last such run applied every server-side change through `c5fcdfa4`, at local `f18e3342`. That is the head's whole server side, rounds 8 to 13 included. `cli.py`'s hunks are left out there: the governed host serves through `opendox.serve`, and the pin's `cli.py` predates T084's refactor. The suites are `tests/ideation-dashboard/test_doxbench_routes.py`, `test_gate_routes.py`, `test_shared_identity.py` and `test_staging_seed.py`, and they read `caps["console_token"]` through `_console_token`. The result is identical: **239 passed, 1 failed at both**. The one failure is the same pre-existing case (`test_lens_add_as_cluster_lands_manifest_pending_and_record`, 409). Nothing in openxFactory changes, so there is nothing for T094.

**For T086 (openXdox-code), recorded with the holder.** openXdox-code `6a3b93b9`'s route harnesses (`tests/doxbench_routes_harness.py:290`, `tests/gate_routes_harness.py:139-149`) build the server with no host profile registered, so by this PR's rule their plane is standalone, and they read `caps["console_token"]`. Composed with openxFactory's `scripts/` for `doc_health`, 90 cases there fail with `KeyError: 'console_token'`. All 90 are in 6 files that its `tests/declared_exclusion.yaml` already excludes (`doc_health`), so its CI does not run them. When its openDox pin passes this PR, those harnesses read `httpd.console_token`, or register openXdox's profile as a host.

## Batch N, and T095

T007's batch N landed in openxFactory#1222 (`bdd0f586`): #1144 12.4a's amendment is the normative text this PR realizes (see "12.4a, clause by clause" above). F10.1, F13.1, F16.1 and F12.x are unaffected. Plan 034's quickstart § 3 and § 4 step 1, and AT-R1 step 4, read the private copy.

T095's harness (openDox-code#75, not edited here) reads the private copy at `<state_dir>/console/<port>.html` instead of `/capabilities`. The holder has its proposed helper and the three line changes. The helper never opens a copy that failed its checks, so a FIFO cannot block it.

🤖 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