Skip to content

T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) - #77

Merged
brettheap merged 99 commits into
mainfrom
build/034-p3r-t084-last-reaches
Oct 3, 2026
Merged

brettheap merged 99 commits into
mainfrom
build/034-p3r-t084-last-reaches

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:

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. 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 T081, 16.4: "no model configured" is a state, shown before any turn (plan 034) #74's writer's, and M3 is covered by the turn patch below.

Merges, done and ahead

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

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>
cli._report_non_conformance printed the validator's last 20 lines. So a
snapshot that broke one rule many times and a second rule once showed
copies of the first and never named the second. Now each rule id the
report names is printed once, on its own line,
`<count> × [<rule>] <where>: <detail>`, in the order found and with
where it is first broken. The next places that rule is broken follow
beneath it without the id, five in all, then "… and N more of this
rule". One rule can be broken in different ways, and a count beside
the first place alone would read as that place repeated. The
validator's own summary follows, and a report that names no rule id
prints its own last lines as before.

RULED openxFactory#656 5920216845, item 3 ("Show every rule, grouped
(Recommended)"). No #1144 line moves: F7.2 asserts the fixture's rule
id is printed, which stays true.

The new module tests/test_rejection_report.py sits clear of
tests/test_post_render_validator.py, which is T085's. Before the
change: 3 failed, 1 passed. After: 6 passed, with
test_post_render_validator.py still 50 passed.
tests/test_projection_seams.py's envelope-keys case now asserts the
grouped line, that the id appears once, and that the `documents` key is
still named. The always-1 and never-groups mutants each fail 5 cases,
and the drops-places mutant fails 3.

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 (#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>
@brettheap brettheap changed the title DRAFT (phase 3, after T063): T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) Oct 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

A symlink alias can bypass the new protection that keeps model settings documents out of editable scope.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Low severity Rename inaccurate .gitkeep exclusion and UNSHIPPED statement

tests/​test_static_content_types.py:46

.gitkeep is now shipped by pyproject.toml via web/**/.*, so UNSHIPPED and the accompanying statement are inaccurate. Rename this exclusion to describe why the shipped, extensionless package marker is outside the content-type checks; otherwise the tests misleadingly claim to enumerate every shipped bundle file.

Comment thread src/opendox/default_columns.py
…ings document; the typeless package marker is set aside by its reason (Copilot review)

- r4173903232 (holder: ACCEPT): `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, compared unequal to every
  SETTINGS_DOCUMENTS path, and stayed owned and editable. That bypassed
  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, with
  the file alias and the directory alias.
  Before (1fb81cb): 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).
- "Previously missed" (holder: ACCEPT): tests/test_static_content_types.py's
  UNSHIPPED claimed the wheel leaves `.gitkeep` out. Since T075 it ships
  (`web/**/.*`). Renamed TYPELESS_MARKERS: the shipped, empty,
  extensionless package marker has no content type to pin. The new
  test_the_set_aside_markers_are_empty_and_extensionless holds that
  reason. Mutant killed: the set widened to index.html (2 failed).

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 3, 2026 18:24
@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

🔵 Needs a closer look

It changes cross-leg composition, authorization-adjacent scope behavior, routing, and server lifecycle across 28 files, requiring final human validation of host integration.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@brettheap
brettheap marked this pull request as ready for review October 3, 2026 18:31
@brettheap

Copy link
Copy Markdown
Contributor Author

READY at 213344a — phase 3 is open (T063 landed, #1218 → a883bbf6); T073 landed (#72 → 90ac703). The holder checked: validate, SonarCloud and Copilot's check succeeded; Copilot's latest review at exactly 213344a is "Needs a closer look" with Findings: None, asking for a human check of the host integration; 0 unresolved threads; no closing keywords in the body or any commit.

The holder's host-integration judgement: landing T084 on openDox-code main changes no consumer, because openXdox and openxFactory each move their openDox pin only in their own tasks. Retiring the last deferred reaches breaks what those consumers bind (openXdox-code's route handlers, a RouteBindingError, and openxFactory's host wiring) only at those pin moves. T086 (openXdox-code#37, DRAFT-CLEAN against this stack: GateRoutes and ProjectionRoutes under HANDLER_CONTRIBUTIONS, column_contributions at T084's four seams) and T094 (prep done; 65 simulation failures, all owned) carry it. Fix rounds 4–5 close the id-versus-path lookup, the loopback wording, the symlink alias of a settings document, and the TYPELESS_MARKERS reason.

#77 contains #72's final pre-squash head 93f77dc and main 90ac703 (merge-tree --merge-base 93f77dc); its diff against main is T084's own files. Dependents #80 (T103) and #81 (T102) are retargeted to main and are not READY. 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, your pull request is larger than the review limit of 150,000 diff characters

@brettheap
brettheap merged commit e49b17c 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-03T18:33:06Z, PR #77 → e49b17c (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
…sh head

#77 landed as e49b17c, a squash on main. Its final pre-squash head was 213344a,
which adds fix rounds 4 and 5 over ebe0a35: separate id and path lookups, the
loopback-default wording in serve.py's help, the symlink alias of a settings
document, and TYPELESS_MARKERS. Its one serve.py hunk (SERVE_DESCRIPTION) does
not meet T103's.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 3, 2026
#77 squashed onto main as e49b17c, whose tree is 213344a's, the head merged
just before this one. So the merge is computed with `git merge-tree
--merge-base 213344a`, and both parents are recorded. It changes nothing
here: the tree is this branch's own, and #80's diff against main is T103's two
files.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 3, 2026
#77 moved from d556c3f to 213344a, its last head before it landed as
squash e49b17c on main. The move brings T084's fix rounds 4 and 5:
- a group's edges are looked up as ids and a selection's files as paths,
  apart;
- an in-root symlink alias of a settings document is moved into the
  unowned settings section.

It touches no file under src/opendox/web/ and not the census, so the
merge is clean. The browser's scope mirror follows round 4 in the next
commit. 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
…ection's files are looked up as paths

#77's final head 213344a keeps default_columns' two namespaces apart:
- `ids` answers an id first, and then a path, for an edge written as one;
- `paths` answers a path only.

A group's edges and a candidate's claimed members are looked up in
`ids`, and a selection's files in `paths`, so a selection's file `x`
never resolves to the document whose ID is `x`. tileOwnScope now builds
the same two maps in the same order, and looks a selection's files up
in `paths` alone.

Fix round 5 (an in-root symlink alias of a settings document is moved
into the unowned settings section) is not mirrored: it is a fact about
the file a row resolves to, which the browser cannot see. It joins
`resolve_within` as a stated limit. Such an alias is offered here, and
the server refuses it by its own scope rule.

tests/test_workbench_edit_by_scope.py: the fixture gains two documents.
`real-p.md` has the id `p.md`, and another document has the path
`p.md`. Group g6 names `p.md` as an edge (so `real-p.md`), and selection
s3 names `p.md` and `notes/soil-test` as files: the first is the path
`p.md`, and the second is only an id, so it resolves to nothing. The
parity table is now 17 tiles.

The census row for staging-workbench-model.js is re-measured.

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 landed on main as squash e49b17c. Its tree equals #77's final
head 213344a, which this branch merged in af4adc1. So this merge was
computed with `git merge-tree --merge-base 213344a`, and it records
main as the second parent without changing a file: the tree is the one
before the merge. #81's diff against main is now T102's own files.

Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 3, 2026
…#80)

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

Arc: neutral-product-standalone-operability

Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3:
- **T103, every loopback route checks the Host.** Its plan entry is being added to openxFactory#1220.
- **From adversarial review 2 (2026-10-03), at openDox-code#77 `b1db1965`:**
  - **M4 (pre-existing).** DNS rebinding can read the whole corpus. `serve.py`'s `_serve_source`, `/snapshot.json` and the static bundle applied no Host check. Only `/capabilities` and the console routes checked it.
  - **L2.** The `/capabilities` Host check ran only when a console token had been minted. With no git identity, local mode mints no token, so the install block (`data_dir`, `socket_dir`, `pid`, which expose the OS username) went to any `Host`.
- **After:** T084 (#77, landed as `e49b17c3`). `serve.py`'s single-writer order is T055 → T073 → T084 → T103, and T104 follows (RULED `5963851934`).
- **Based on `main` since #77 landed** (`e49b17c3`, a squash). The branch was built on #77's `3387293e` and took each #77 head as a merge, never a rebase. The last was #77's final pre-squash head `213344ad`, at `514d9ade`. Then main `e49b17c3` was merged at `b9025b6c` with `git merge-tree --merge-base 213344a`, both parents recorded. `e49b17c3`'s tree is `213344ad`'s, so that merge changes nothing, and this PR's diff against `main` is T103's two files alone.
- **Fix round 1** (`978f2646`, Copilot `r4171161548`): an IPv6 loopback bind now works. See "Fix round 1" below.
- **Fix round 2** (`77020042`, Copilot `r4173481146`): an IPv6 wildcard bind is announced at `[::1]`. See "Fix round 2" below.

Claimed on openxFactory#656 in [`5963901937`](opensoft/openxFactory#656 (comment)). **DRAFT.** The holder posts READY and the landers merge.

## The gate

There is one check, in one place: `DashboardHandler.parse_request`. `BaseHTTPRequestHandler.handle_one_request` runs it on every request before it looks for a `do_<METHOD>`. On a loopback plane, every request is refused unless it carries exactly **one** `Host` line naming one of the plane's own loopback authorities at the **bound** port. That holds whatever the route or method:
- the static bundle;
- `/source/*` and `/snapshot.json`;
- `/capabilities`, whatever the token state;
- `/workbench/*` and every `/actions/*` route;
- a route a host contributes;
- HEAD, OPTIONS and any other method.

**Accepted:**
- `127.0.0.1:<port>`;
- `localhost:<port>`, in any case;
- `[::1]:<port>`, only where the socket is bound to `::1`.

The match is exact, after trimming the optional whitespace around the field value. It reuses `loopback_authorities`, which gains an optional `bound_host`. Its one-argument form answers what it always did.

**Refused:** a suffix or prefix match, another port, a missing or empty `Host`, and a second `Host` line.

**The answer is one fixed status and body, and it never echoes the `Host`:** `403` with `{"ok": false, "error": "invalid_host", "message": "the request Host does not name this loopback server"}` (`serve.FOREIGN_HOST_BODY`). `invalid_host` is the code `/capabilities` already used. Any declared body is drained first (bounded, like every other refusal here), the connection closes, and the server log gets one fixed line.

**A hosted (non-loopback) plane is unchanged.** `parse_request` reads `self.loopback` first and does nothing more when it is false. Hosted mode's own rules (`hosted_ref_refused`, the gateway identity) are untouched.

**The boundary: which plane is gated** (the holder's ruling on Copilot `r4171161531`, declined).
- **T103 gates the loopback plane**, which is exactly `LOOPBACK_HOSTS`: `127.0.0.1`, `::1` and `localhost`. `serve.py` and `runtime/config.py`'s `LOCAL_BIND_HOSTS` hold that set equal (`config.py:1594-1603`).
- **Any other bind is the hosted plane**, like `0.0.0.0`. That includes `127.0.0.2`, `127.1` and `LOCALHOST`. Its `Host` boundary is its deployment's, because the proxy in front of it names the public `Host`. It also mints no console token and opens no session.
- **A `--local` install fails closed** on those spellings: it refuses them before it opens a socket (`tests_runtime/test_install_mode.py:256-267`). Reproduced at the base `3387293e`: `generate-and-open refused: --host '127.0.0.2' is not a loopback address ...`, and the same for `LOCALHOST`.

**Also changed:**
- `_trusted_console_host` and the console's Origin test now read the bound socket's authorities, through the same predicate. The console test now also refuses a duplicated `Host`.
- The `/capabilities` arm's own token-conditional Host test is removed. It is dead now: the gate refuses those requests first, and a hosted plane never mints a token.

## Evidence

**A real `generate-and-open --local` serve**, run with a clean environment (no `GIT_*`, no `XF_*`) and a private state dir, with no git identity (L2's configuration). Raw requests show status/body bytes per Host. Before, at `3387293e`:

```
token minted: False install: {'mode': 'local', 'database_bundle': {'data_dir': '…/odx-t103/postgres/data', 'socket_dir': '…/odx-t103/postgres/run', 'pid': 2891371}}
route                                     good LOCALHOST      evil    suffix   lh.evil otherport    noport     empty   missing       dup        v6  v6mapped
GET /index.html                       200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556  200/7556
GET /snapshot.json                    200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290  200/7290
GET /capabilities                     200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093  200/4093
GET /source/notes-rain-barrel-leak.md  200/447   200/447   200/447   200/447   200/447   200/447   200/447   200/447   200/447   200/447   200/447   200/447
```

After, at this head (every other route class reads the same, HEAD with `403/0`):

```
GET /index.html                       200/7556  200/7556   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104
GET /snapshot.json                    200/7290  200/7290   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104
GET /capabilities                     200/4093  200/4093   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104
GET /source/notes-rain-barrel-leak.md  200/447   200/447   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104   403/104
OPTIONS /source/…                      501/360   501/360   403/104   …
POST /actions/workbench/chat-turn      403/103   403/103   403/104   …
```

The same serve was also run with an identity, so a token was minted, and every refused cell reads `403/104` there too.

**In a real browser** (headless Chromium 149). `--host-resolver-rules=MAP evil.example 127.0.0.1` simulates the rebinding. The page at `http://evil.example:<port>/` then fetches same-origin:

| request | before (`3387293e`) | after |
|---|---|---|
| `GET /` | 200 | 403 |
| `fetch('/source/notes-rain-barrel-leak.md')` | **200, 447 bytes of the document** | 403 `invalid_host` |
| `fetch('/snapshot.json')` | **200, 7290 bytes** | 403 `invalid_host` |
| `fetch('/capabilities')` (token minted) | 403 | 403 |

**The browser path still works.** The page loads at `http://127.0.0.1:<port>/` and at `http://localhost:<port>/` with 45 responses and no page errors, before and after alike. Both before and after, three answers are ≥ 400. All three are 404s that predate this PR: `views/intent-feed.js` (not owed, T075), `/snapshot-index.json` and `/project-register.json`, neither of which a standalone install has.

**The mutants, run against `serve.py` itself.** Each one made the new file red:

| mutant | failed cases |
|---|---|
| `/source` exempted from the gate | 5 (both in-process tables, both real-serve tables, the HTTP/1.0 case) |
| the port ignored | 12 |
| a suffix match | 7 |

`tests/test_loopback_host_gate.py` §5 also keeps these mutants in the suite, against a live server:
- nine route classes exempted, one at a time;
- the port ignored;
- suffix and prefix matches;
- only the first `Host` line read;
- a missing `Host` trusted.

Each one has to produce a table violation.

## Fix round 1 (`978f2646`): an IPv6 loopback bind

Copilot `r4171161548` (accepted). The gate accepts `[::1]:<port>` only where the socket is bound to `::1`, but no socket ever was:
- `build_server` used `http.server.ThreadingHTTPServer`, which is `AF_INET` only. So `host="::1"` (named by `LOOPBACK_HOSTS` and by `LOCAL_BIND_HOSTS`) failed at the bind, and at the base `3387293e` `generate-and-open --local --host ::1` ended in `socket.gaierror: [Errno -9] Address family for hostname not supported`.
- `server_url` would have printed `http://::1:<port>/`.

The fix:
- An IPv6 literal now binds with `_IPv6ThreadingHTTPServer` (`AF_INET6`), and every other host with the standard class, as before (`_server_class_for`).
- `server_url` brackets IPv6: `http://[::1]:<port>/index.html`.
- Three live `::1` cases were added (below).

## Fix round 2 (`77020042`): an IPv6 wildcard bind is announced at `::1`

Copilot `r4173481146` (accepted). Since fix round 1, a `::` bind opens an `AF_INET6` socket, but `server_url` still announced every wildcard at `127.0.0.1`. An `AF_INET6` socket is IPv6-only on some platforms, so the printed URL could name nothing that answers.

The fix: `server_url` announces `::` at `::1`, bracketed as `http://[::1]:<port>/`. `0.0.0.0` and `""` stay at `127.0.0.1`. `::` is still a hosted plane, not one of `LOOPBACK_HOSTS`, so the loopback gate does not apply to it, as before. Six cases were added (below).

**Copilot's other note.** At `77020042` Copilot listed a "previously missed" HTTP/0.9 note, with no thread and "Findings: None": a two-token `GET /path` gets the fixed refusal body with no status line. That is HTTP/0.9's own shape, since that protocol has no status line or headers. The client still gets the fixed `invalid_host` body, never the document. Measured at `d0f1efcb`: `GET /source/notes-rain-barrel-leak.md` over HTTP/0.9 answers exactly `FOREIGN_HOST_BODY`. It is left as it is. Copilot's review at `d0f1efcb` recommends approval.

## Tests: `tests/test_loopback_host_gate.py` (new, 67 cases)

1. **The table on the pure predicate** (`serve.host_names_this_loopback_serve`): 5 accepted rows and 25 refused rows on an IPv4 bind, plus the IPv6-bind and port-80 cases. The refused rows include `evil.example:<port>`, `127.0.0.1.evil.example`, `localhost.evil`, `evil.localhost`, `127.0.0.1:<other port>`, no port, an empty Host, a missing Host, duplicates in three orders, `[::1]` on an IPv4 bind, `[::ffff:127.0.0.1]`, the long IPv6 form, unbracketed `::1`, and `localhost.`. `LOCALHOST:<port>` is accepted.
2. **The table across every route class of an in-process server**, with a host's contributed GET, prefix GET and POST beside the core routes. It runs with no console token (L2) and with one. Two more cases: a refused 1 MiB body still gets the whole refusal, and an HTTP/1.0 request with no `Host` is refused.
3. **The table across every route class of a real standalone `python -m opendox.cli generate-and-open --local` child**, with no identity and with one. The child has neither sibling importable, its own state dir, and a bundled PostgreSQL that stops with it.
   - **The IPv6 loopback bind** (fix round 1), in process and as a real `--local --host ::1` child. It binds and prints `http://[::1]:<port>/`. It accepts `[::1]:<port>` and refuses `[::1]:<other port>` on every route class.
   - **`server_url`** (fix round 2) announces each bind at its own family's loopback, over five bind spellings. A live `::` bind answers at the URL it announces.
4. **The browser path.** Every file the bundle ships, `/`, `/capabilities`, `/snapshot.json`, `/source/*`, and a console request that carries the token and the page's own `Origin`. They run under `127.0.0.1` and `localhost` on an IPv4 bind, and under `[::1]` on an IPv6 bind.
5. **The mutants**, as listed above.
6. **A hosted plane (`0.0.0.0`)** still serves a `Host` the loopback gate refuses.

Local run, with the environment cleaned (and, from `b9025b6c`, pytest's basetemp and `TMPDIR` under `~/.local/state`, outside the workspace):
- `tests/`: `2668 passed, 11 skipped` at `56aae213`, `2690 passed, 11 skipped` at `978f2646`, `2749 passed, 11 skipped` at `fb0393df`, `2756 passed, 11 skipped` at `d0f1efcb`, and `3093 passed, 11 skipped` at `b9025b6c`.
- `tests_runtime/`, with no database: `632 passed, 166 skipped, 0 failed` at `b9025b6c`. The one red from earlier local runs, `test_local_git_adapter.py::test_a_refusal_raised_while_binding_takes_the_operations_own_kind`, also failed at the base `3387293e`. It was an artifact of a temp dir inside the workspace's git tree, and it passes with the temp dir outside it.

**The triple pin.** No new case skips, so `EXPECT_SKIPPED` stays 11. The floors are not re-pinned. #77's CI read `selected=3399 passed=3388`, a margin of 923, and the phase-3 PRs leave the floors as T037 set them.

## Cross-repository consequences, for the pin moves past T103

- **openXdox-code `tests/test_edit_action.py:170`** asserts that `POST /actions/edit` with `Host: rebound.example` answers `(403, "agent_invocation")`. Under T103 that request is refused earlier, by the gate, as `(403, "invalid_host")`. The status is unchanged and the code moves. Its `hostile_caps["error"] == "invalid_host"` assertion still holds.
- **openxFactory `tests/ideation-dashboard/test_extension_point_parity.py`'s `ROUTE_ARMS`** pins the `/capabilities` arm as reaching `_send_json`, `_serve_bytes`, `_session_repository` and `_trusted_console_host`. After T103 the arm reaches `_serve_bytes`, `_session_repository` and `install_report`. That pin was already stale from T073's `install_report` (#72).

🤖 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
… T084 markers

T084 landed, so the five 16.5 cases that ran as strict xfails naming it
now pass, and their markers come out in this merge:

- the session reads (project register, thread);
- the session controls, hidden standalone;
- model approval, refused alike and writing nothing;
- the document abstract, refused alike.

In the same merge:

- `_Answer.comparable()` also sets aside the values of `/capabilities`'
  `install.database_bundle` (plan 034 T073): each `--local` child runs its
  own bundled server under its own state directory. The block's shape is
  still compared.
- The intake surface's reason is T084's `column_seams.GATE_RECORDS_REFUSAL`,
  and model approval asserts it too, with `approval_refused`.
- `import dataclasses` is restored. #77 dropped it from the named test with
  the scope stand-in, and T082's section 6 uses it.
- `EXPECT_SKIPPED` moves back from 16 to 11: T082 adds no skip.

The scope stand-in is main's, unchanged.

Local, LANG=C.UTF-8, no PostgreSQL service: the named file 83 passed;
the whole suite 3692 passed, 177 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>
brettheap added a commit that referenced this pull request Oct 3, 2026
Brings in #77 (T084) and #80 (T103). 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
Merged, not rebased. It merges cleanly.

#77 is on the base, so the served-turn case runs and passes: its strict
xfail is dropped, and .github/workflows/validate.yml's EXPECT_SKIPPED steps
back from 12 to 11, with its reason. That is main's own pin, since all three
drafts T100 waited on are now in.

#77 also refuses the console intake standalone when no gate-record writer is
registered (5961364221, item 1), so the intake cases' stand-in host in
tests/test_model_binding_trust.py now registers a host gate at
opendox.column_seams.gate, as #77's own tests do, and unregisters it after.

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
…sh head

serve.py's single-writer order is T084 (#77) -> T103 (#80) -> T104,
and this is a merge, never a rebase. #80's last head carries #77's final
pre-squash head 213344a and main e49b17c (T084, #77, landed as a
squash). Main's squash of #80, 390e2c2, has the same tree as b9025b6.

Clean: the one file both sides touch, serve.py, changed apart (the
SERVE_DESCRIPTION rewrite that #77's final head brought, Copilot
r4173844338, against T104's build_server and serve() paths). No
test the merge brings in reads a standalone child's token from
/capabilities (grep console_token over tests/ and tests_runtime/).
tests_runtime/test_served_bundle.py reads its URL from the first line
that starts with http://, which the console line does not.

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 4, 2026
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)

Arc: neutral-product-standalone-operability

Plan 034's **T082** (`specs/034-opendox-standalone-operation/tasks.md`) realizes #1144's **16.5**: *"Every other surface works with no model. Documents, generation, the views, sessions and saving answer exactly as they do with a model configured."* Its falsifier is `tests/test_chat_model_configuration.py`. Its **After** line is T081, T084 and T085, and all three have landed.

**Base: `main`.** This PR is no longer stacked. The predecessors landed as:

- #71 (T085) as `2680eb5e`;
- #74 (T081) as `9a490405`;
- #77 (T084) as `e49b17c3`;
- #80 (T103) as `390e2c28`;
- #81 (T102) as `0116293a`;
- #85 (the T102 follow-on) as `c4b55cc4`.

The branch takes `main` with merge commits only (`7332be52`, `c002fac0`, `87a753a8`, `a25606bb`, `9948dc2b`), never by rebasing.

It was drafted under Brett Heap's word of 2026-10-02 (`openxFactory#656` comment `5960162524`, *"Draft all of them now (Recommended)"*) and claimed in `#656` comment `5962404984` (P3-N: T082, with T083 as a local check only). The holder posts READY, and the landers merge.

## Rulings

- R1Q10 (a), `5850003126`: each consumer mechanism gets openDox's own neutral default.
- `5961364221` item 1: standalone, model approval refuses by name, and the intake surface answers `offered: false` with the reason.
- `5961651355`: the standalone scope marks a tile's own documents editable. Saving still needs a live session, and sessions are reached only through the host's gate verbs.
- The holder's shape for this PR:
  - send the same standalone request twice, once with no model configured and once with a binding declared through `model-binding add`;
  - assert that each answer is an answer, never a dropped connection, and that the two are equal;
  - mark the cases that need T084 `xfail(strict=True)` until it lands. It has landed, and the markers are gone.
- **The holder's ruling on this writer's RULING NEEDED, 2026-10-02: option (a).**
  - openDox's standalone corpus default leaves out openDox's own settings documents.
  - The list is declared once, beside the path constants, by importing them, not by copying the strings.
  - A host's own adapter decides for itself, and 16.5's text gets no exception.
  - The holder asked that the exclusion live outside the single-writer files where possible, and that the tests compare the full corpus.
  - The holder named two mutants: one that drops the exclusion, and one that widens it to the directory.

## The test: one request, two postures

The new section 6 of `tests/test_chat_model_configuration.py` starts two standalone `generate-and-open --local` children over the **same commit**, with sibling imports refused (`tests/standalone_child.py`):

- **"no model"**: a fresh fixture repository, no binding, and no `omp` on the PATH.
- **"a binding"**: a byte copy of that checkout, `.git` included, after `python -m opendox.cli model-binding add --repo-root <copy> ... -- a-broker` declared a binding (exit 0, nothing refused). That is how a standalone user configures a model.

Each request goes to both children:

- Each answer must be an HTTP response.
- No sibling import may be refused while the request is made (`Child.refused()` is read before and after).
- The two answers must be equal in status, content type and body.
- A JSON body is compared as data.
- Only `/capabilities` has values set aside, because they are per-process by design: its `console_token`, and the values of its `install.database_bundle` (`data_dir`, `socket_dir`, `pid`; plan 034 T073). Each `--local` child runs its own bundled server under its own state directory. The block's shape is still compared: the same keys, and a value on both sides or on neither.
- Every other JSON surface is compared whole, so a key that differs between the postures there fails the case (`c6689c55`).

| surface | requests | result |
|---|---|---|
| documents | `GET /snapshot.json`; `GET /source/<doc>`; the keyed `GET /source/fixture@main/<doc>`; the bare `GET /source` (refused alike); `POST /actions/edit` (select-to-edit, with `EDITOR=true`) | equal |
| generation | `python -m opendox.cli generate` over each checkout, as a lone openDox, with the same no-`omp` PATH the servers started with. The two outputs must be byte-equal, and equal to what each posture serves | equal |
| the views | `/index.html`, `/app.js`, `/styles.css`, `views/display.js`, `wheel.js`, `wheel-model.js`, `doc-wheel.js`, `lens.js`, `lens-model.js`, `/capabilities`; plus `buildWheelModel`, `buildLensModel` and `docSummaries`, run in node over each posture's served snapshot | equal |
| sessions | `POST /actions/gate/share-session`, `abandon-session`, `open-pr`: refused alike, and neither checkout's HEAD or status changes | equal |
| session reads (T084) | `GET /project-register.json` and `GET /workbench/thread?...` answer through openDox's seams | equal |
| session controls (T084) | `/capabilities` reads `actions.gate` and `actions.refresh` false standalone (`5920216845` item 1) | equal |
| saving | `POST /actions/gate/first-edit`, `edit-document`, `create-document`: refused alike, and nothing written (`5961651355`) | equal |
| model settings (T084) | `GET /workbench/model-intake`: `offered: false`, with T084's `column_seams.GATE_RECORDS_REFUSAL`. `POST /actions/workbench/model-approval`: `approval_refused` with that reason, and nothing written. `POST /actions/workbench/document-abstract`: `model_capability_unavailable` (`5961364221` item 1) | equal |

**The five cases that waited for T084.** Until #77 landed, five cases ran as `xfail(strict=True)` naming T084, because the request dropped the connection standalone or the capability still read true:

- the two session reads;
- the session controls;
- model approval;
- the document abstract.

The merge of `e49b17c3` (`c002fac0`) removed the markers, and all five pass. The same merge:

- set aside the `install.database_bundle` values above;
- moved the intake reason to `GATE_RECORDS_REFUSAL`;
- restored `import dataclasses`, which #77's hand-applied scope stand-in dropped from the file and which section 6 uses. A clean textual merge would have left a NameError.

The scope stand-in is `main`'s, unchanged.

**What these cases do not assert.** The gate verbs answer `404 unknown_action` in both postures. The sessions and saving cases assert only that each verb is refused alike and writes nothing. They do not assert a particular refusal code: T084 keeps the gate column a host's contribution, and its standalone answer is T084's to decide.

## The fix the cases needed: openDox's settings documents are not the user's documents

**Measured at #74's head `9061b22a`.** `opendox model-binding add` writes its bindings document into the checkout, at `ideation/dashboard/model-provider-bindings.yaml`, where its operator can read and commit it. openDox's standalone corpus reads the working tree (`WorkingTreeCorpus`, ruled *"Working tree (Recommended)"*), so that document joined the corpus as a `source` document:

- `GET /snapshot.json` had sha `52a09ac1` with no model and `45d01a78` with a binding.
- The `generate` verb gave the same two digests.
- The wheel and the lens built from the two snapshots differed with them.
- Committing the file, as its own docstring intends, would list it all the same.

**RULED (a), and placed outside the single-writer files:**

- **The declaration.** `doxbench_intake.SETTINGS_DOCUMENTS` declares the two paths, in `doxbench_intake.py` beside `DEFAULT_DECLARATIONS_RELPATH`. It reads `binding_mod.DEFAULT_BINDINGS_RELPATH` and `DEFAULT_DECLARATIONS_RELPATH`, and copies neither string.
- **The listing.** `WorkingTreeCorpus` (`runtime/local_git_adapter.py`) takes `excluded`, the exact keys its listing leaves out, and its default is that tuple.
  - It is applied after both listing branches, so tracked files, untracked files and a pinned revision all leave out the same paths.
  - A whole-corpus `check()` leaves them out too (Copilot r4170556938). A subject the caller names is still answered. That is all the filter does. Every other finding the parent reports stands as before, including a tracked path deleted from the working tree, which the listing omits and the parent's diff still reports.
  - `excluded=()` lists everything.
- **One declaration, two layers (`7af4c999`).** T084's `default_columns.SETTINGS_DOCUMENTS`, which keeps the same documents out of every owned scope section, now builds its set from `doxbench_intake.SETTINGS_DOCUMENTS` instead of a second literal. It is a separate commit so that it can be reverted alone: it touches T084's `default_columns.py`.
- **Every caller of `WorkingTreeCorpus` is openDox's standalone default.** In `src/`, the class is constructed only by the twin `_default_home_factory` in `cli.py` and `serve.py`. A GitHub code search of `opensoft` found it in no other repository's code, but that search covers default branches only. So the class default is that default's rule.
- **Cost.** `local_git_adapter` still costs the standard library alone to import. It now also names `opendox.doxbench_intake`, itself stdlib-only.
- **The source route.** A file left out of the listing is still a file, and `/source/<path>` still serves it by name. The rule is about what the corpus lists.
- **The comments in `cli.py` and `serve.py` (Copilot r4174646638).** These are documentation-only changes in two single-writer files:
  - the twin `_default_home_factory` docstrings no longer say `check` is inherited unchanged;
  - `cli.py`'s import comment now names `opendox.doxbench_intake`.

**The settings cases, over the full listing as written, with nothing subtracted:**

- `test_openDoxs_settings_documents_are_declared_once` checks:
  - the two constants;
  - that `WorkingTreeCorpus`'s default is that very tuple (`is`);
  - that `default_columns.SETTINGS_DOCUMENTS` equals it as a set.
- `test_the_standalone_corpus_lists_neither_settings_document`, through each entry point's own `_default_home_factory`:
  - the bindings document and the declarations document are not listed: untracked, then **committed**, then at that commit as a pinned revision;
  - a user's own `ideation/dashboard/notes.md` **is** listed;
  - so is an `ideation/dashboard/archive/model-provider-bindings.yaml` that has the bindings document's file name.
- `test_a_corpus_told_to_leave_out_nothing_lists_every_file`.
- `test_a_whole_corpus_check_names_only_what_the_corpus_lists`.

## Falsifier, failing before and passing after

**Before.** The branch's test file was run over #74's head `9061b22a` sources. The result was `7 failed, 68 passed, 5 xfailed`. The failures were the four settings cases plus the snapshot, generation, and wheel-and-lens cases. The other surfaces already answered alike there, so their cases keep that property named.

**After**, at `87a753a8`, at `a25606bb` and at the head, the named file has `83 passed`, with no xfails.

## Mutants

Mutants were applied one at a time by a harness that restores each file and checks its digest. Each run used only the cases meant to kill it. **At the head `9948dc2b`, all 20 were killed in one run** (`tools/mutants-t082-full.json`), and each by the assertion it targets:

- **K01**: `/capabilities` discloses whether a model is configured.
- **K02b**: the intake surface offers intake where a binding is declared. K02 was re-anchored on T084's line.
- **K03**: select-to-edit refuses where no model is configured.
- **K04**: a document's source is served differently where a model is configured.
- **K05**: an action no route claims answers 200 where a model is configured.
- **K08**: the working-tree corpus ignores `excluded`.
- **K09**: a pinned revision lists the settings documents.
- **K10**: the declarations document is missing from the list.
- **K11**: the exclusion drops the whole corpus.
- **K13**: the standalone default leaves out nothing. This is the holder's first mutant: drop the exclusion.
- **K14**: the exclusion is widened to the settings documents' directory. This is the holder's second mutant.
- **K15**: the exclusion matches by file name.
- **K16**: K13 again, run against the 16.5 HTTP cases alone.
- **K17**: a whole-corpus check reports an excluded path.
- **K18**: the check also leaves out a path the caller names.
- **K19**: the scope default's settings set drifts from the one declaration.
- **K20**: the intake surface's reason is the broker notice, not the gate seam's.
- **K21**: model approval refuses under the intake code.
- **K22**: `actions.gate` reads true standalone, so the session controls are shown.
- **K23**: the intake surface carries a `console_token` key that differs by posture. It survived the earlier comparison, which dropped `console_token` from every JSON answer. It is killed by the `/capabilities`-only comparison (`c6689c55`).

## CI pins

- **`EXPECT_SKIPPED` stays 11.** While the five T084 cases were strict xfails it read 16, because JUnit writes an xfail as a skip. The merge that removed them moved it back.
- **The floors are re-pinned** by the workflow's own rule: three below the lower of two greens of one tree.
  - They were re-pinned last in `0b0f038e`, over the tree `a25606bb`, which is T082 with `main` at `0116293a`.
  - Run `37152269851` read `triple: selected=3980 passed=3969 skipped=11 failures=0 errors=0` on attempt 1 (job `111288409713`) and on its re-run (job `111296677742`).
  - So `MIN_SELECTED` is now 3977 and `MIN_PASSED` 3966. The re-pin before that, `c6703779` over `87a753a8`, read 3936/3925.
  - The floors had not moved since T037, so the declared three-test margin had grown to several hundred (Copilot r4170556888, r4174447721).
  - T082's later commits add no case. CI at `0b0f038e` read 3980/3969/11, a margin of 3.
  - At the head `9948dc2b`, CI (run `37159199209`, job `111308882309`) read `triple: selected=3987 passed=3976 skipped=11 failures=0 errors=0`. The margin of 10 is the 7 cases #85 brought in with `main`. A PR that lands later re-reads the floors by the same rule.

## The whole suite

All local runs were in the foreground, with `LANG=C.UTF-8`, no `GIT_*` or `XF_*` variables, and no PostgreSQL service, so the runtime cases skip.

- `87a753a8`: `3759 passed, 177 skipped`, which is 3936 selected, as CI reads.
- `a25606bb`: `3803 passed, 177 skipped`, which is 3980 selected, as CI reads.
- The head `9948dc2b`: `3810 passed, 177 skipped`, which is 3987 selected, as CI reads.

## Review rounds

- Copilot at `947411fd`: two findings, both fixed (`92275fcd`, `dcdb7554`), answered and resolved.
- Copilot at `dcdb7554` and `7332be52`: no open findings.
- Copilot at `7af4c999`: r4174447721, floors not re-pinned after T084. Fixed in `c6703779`.
- Copilot at `87a753a8`: r4174646638, the twin factory docstrings still listed `check` as inherited unchanged. Fixed in `7a022068`.
- Copilot at `0b0f038e`: one open finding and two "previously missed" notes.
  - r4174697909: this description still described the stacked phase (xfails, `EXPECT_SKIPPED` 16, `NO_BROKER_NOTICE`). It is rewritten here.
  - The generation case ran its `generate` children on the ambient PATH, because the `postures` fixture restores PATH once its servers start. It now uses the same no-`omp` PATH (`47e8bb95`). This machine has no `omp`, so the output did not change here.
  - `check()`'s docstring said a whole-corpus check covers exactly what `list_documents` lists. That was broader than the code. The filter leaves out only the excluded settings documents. A tracked file deleted from the working tree is omitted from the listing, and the parent still reports it as a divergence. That reporting dates from before T082. `47e8bb95` narrows the docstring to what the code does, and does not change that behavior: dropping a real uncommitted deletion from the verdict would be a different rule from 16.5's.
- Copilot at `47e8bb95`: Findings: None. It made one "previously missed" note: `comparable()` dropped a top-level `console_token` from every JSON answer. Fixed in `c6689c55`, which compares every surface but `/capabilities` whole (mutant K23).
- Copilot at the head `9948dc2b`: "Needs a closer look", Findings: None. Its one note was that this description was stale, and this revision answers it. No review thread is open.

## Not in this PR

- T083 (16.6, then F16.1 whole), which runs at landing. This writer's local check of it was green and was reported separately.
- The holder's bookkeeping: the plan box for T082.

🤖 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 4, 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 4, 2026
…plan 034) (#82)

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

Arc: neutral-product-standalone-operability

**Plan 034 T100. #1144 box 16.3a: a served repository's bindings are trusted per machine.** RULED by Brett Heap on 2026-10-02 in openxFactory#656 comment `5962785556`, item 2: *"Trust per machine (Recommended)"*. The text this conforms to is T007 batch M (openxFactory#1219, merged as `cc775fea`): box 16.3a and F16.1's batch M block. What openxFactory registers when it hosts openDox was RULED on `5970369724`; that registration is T094's (below).

Phase 3. The base is `main`. T100's After line (T080, openDox-code#64 and T084) is met: #64 landed as `8e377823`, and #77 (T084) as `e49b17c3`.
- This branch merged #64's final pre-squash head `04bcb68e`.
- It then merged `main` `8e377823` with `git merge-tree --merge-base 04bcb68`, both parents recorded.
- It then merged `main` `e49b17c3` (#77), `390e2c28` (#80), `0116293a` (#81), `c4b55cc4` (#85) and `ca9e1bd5` (#76) with ordinary merge commits. The branch held none of #81's, #85's or #76's commits.

Nothing was rebased. The diff against `main` is T100's 12 files only.

## The defect

The entry points read the bindings document of the repository they SERVE (`declared_model_port_factory` over `bindings_path(checkout_root)`). A binding written into that file by hand, or arriving with a clone, needed no approval. The adversarial review of 2026-10-02 showed two things:

- A committed `broker_argv` of `["/bin/sh", "-c", "id > $PWD/pwned"]` ran on the first chat turn.
- A committed `env:` reference sent an unrelated secret of the operator's to the file's endpoint as a bearer token (`repo_binding_exfil.py`).

The first two cases of `tests/test_model_binding_trust.py` are those two findings, run as the review ran them. On `main` `8e377823` without this change, they fail for the defect's own reasons:

```
AssertionError: the endpoint was contacted, and was sent ['Bearer cloud-secret-NOT-A-MODEL-KEY-7d41e9a2c0b85f36']
AssertionError: the repository's program ran: uid=1000(brett) gid=1000(brett) groups=1000(brett),1001(docker-host)
```

## The rule, and where it is enforced

A binding read from the served repository runs a broker, resolves a credential reference (`env:`, `keyring:` or a broker's), or contacts its endpoint ONLY after the operator has trusted THAT EXACT binding on THIS machine. The auth kind `none` is included, because it still sends chat content to the endpoint the file chose.

- **`src/opendox/doxbench_trust.py` (new, standard library only).**
  - **The key** is (the repository root's resolved path, the binding id, `sha256` of the binding's canonical full record). The canonical record is `as_record()`, every field, sorted, compact, ASCII. So any edit untrusts the binding, and so does the same file under another root.
  - **The store** is one private file, `model-binding-trust.json`, in openDox's state directory (`OPENDOX_STATE_DIR`, through #69's `config.state_dir`). It holds no credential and is written owner-only, through an exclusive, no-follow temporary file and a rename.
  - **Its checks.** It is read only once it passes the checks #69's bundle makes of its own tree: no symbolic link, owned by this user, writable by no one else, and every directory above it this user's or root's, sticky where another user can write it. A store that fails a check trusts nothing, and the refusal names it. The store and its lock file are opened without waiting (`O_NONBLOCK`), so a FIFO in either place is refused by the type check on the opened descriptor rather than waited on.
  - **Its size.** The store writes nothing larger than it reads (`MAX_TRUST_STORE_BYTES`). A record that would outgrow it is refused before anything is replaced, so every trust already held stays held.
  - **Its lock.** Every record holds an exclusive lock on `model-binding-trust.lock`, beside the store, across its read, its change and its replace. So two processes recording at once keep both trusts, and neither restores a form the other replaced. The lock file gets the same link and permission checks as the store, and is then set to exactly 0600, whatever the umask. Where no lock can be taken, the record is refused by name and nothing is written. Readers take no lock: the replace is atomic.
  - A state directory equal to the served root, or nested under it, is refused naming `OPENDOX_STATE_DIR`, before anything is written. So is one that cannot be resolved, such as a link loop.
  - **A platform without the primitives the store needs** has nothing trusted, with a refusal that names the platform and the gaps (`unsupported_platform()`, following #69's form). The primitives are `os.getuid`, `O_DIRECTORY`, `O_NOFOLLOW`, `O_NONBLOCK`, `fchmod`, `mkdir` with `dir_fd`, and `fcntl.flock`. So every verdict reads untrusted rather than raising.
  - **A link to nothing** on the way to the store, whoever owns it, is refused by name. So is any `OSError` the store's tree raises that no check named: it becomes a `TrustStoreRefused` naming the store and the system's short word for it, in `record` and in `verdict`.
- **A binding the model catalog refuses is never trusted** (`unservable_because`). Its id or its label is one the catalog cannot list, so no turn could use it. The check builds the same catalog the factory declares (`brokered_catalog`), and it runs BEFORE any policy is asked:
  - `verdict_for` refuses such a binding, even under a host policy that trusts every binding or with a store entry recorded earlier. The start declares the refusing port over an empty catalog and never fails.
  - `recorded_for` refuses such a binding, so `add`, `edit` and `trust` record nothing and write nothing.
  - Its refusals print no trust command, because trust cannot repair it. They name the actual remedy instead (`REMEDY_UNSERVABLE`): correct the binding with `opendox model-binding edit` (to keep the id) or `remove` then `add` (to change the id). Each of those records trust for the binding it writes.
- **Every answer a policy gives is held to the binding AND the root asked about.**
  - `verdict_for` passes on a verdict for this root that admits exactly this binding, or an untrusted one for exactly this record at this root. Anything else becomes an untrusted verdict for THIS binding at THIS root:
    - a verdict for another binding, trusted or not;
    - another form of this one;
    - a verdict minted for another repository root;
    - a non-verdict;
    - a policy that raised.

    So every refusal names the right binding, root and command, and the per-repository key holds against a host policy too.
  - `recorded_for` refuses by name (`TrustNotRecorded`) a policy that declines to record, records another binding or root, or raises. It names what a policy raised, never its words. Only openDox's own store's `TrustStoreRefused` passes through as written, and only from exactly `MachineTrust`, not a host's subclass. A host policy's refusal of any class, `BindingRefused` included, is named by its class alone.
- **`doxbench_install`.** The factory asks `verdict_for` about the first approved binding (`trust_gated_model_port_factory`).
  - Trusted: the brokered port, handed the verdict.
  - Untrusted: `UntrustedBindingPort`. Its catalog lists the binding `available: false`, every dispatch is refused by name, and the factory writes a notice on stderr naming the id and the command that trusts it.
  - A checkout with no bindings never asks the policy, so it never touches the state directory.
- **In depth, `doxbench_provider`.** These all refuse a binding that no verdict covers:
  - every broker operation (`mint`, `hand_off_credential`, `revoke`, `list_references`), in `_broker_operation`, before the argv is built and before either runner's branch;
  - the built-in resolver, after its two route assertions and before its first read;
  - the port's `dispatch` and `catalog`.

  A verdict covers only the id and digest it was given for.
- **`cli_model_binding`.**
  - `add` and `edit` record trust for the binding they write. They record it FIRST, so a store or a policy that refuses leaves nothing written and nothing printed as "trusted".
  - `trust <id>` (positional, no `--yes`) prints what it trusts, then records trust for exactly that record: the broker argv it would run, the endpoint, the auth kind and the credential REFERENCE, never the credential. It resolves no reference, spawns nothing and contacts nothing.
  - **The command every refusal, notice and `list` prints** is `opendox model-binding trust --repo-root ROOT [--bindings DOCUMENT] ID`. It is printed only from operands a POSIX shell reads back exactly (`trust_command`):
    - an id the catalog accepts: ASCII letters, digits, `.`, `_` and `-`, beginning with a letter or a digit, so no shell expands it and no option parser reads it as an option;
    - paths quoted by `shlex.quote`, and only where every character is printable.

    A root that is not printable is never printed: the command names it `.`, to be run from that repository's root. `--bindings` appears where the binding was read from a document given by one (`list --bindings`, or the factory's `bindings_path`). Run as printed, the command trusts exactly the binding it names.
  - `set-credential` is refused before its broker spawns when the binding is untrusted, and it leaves the binding untrusted. It re-trusts a TRUSTED binding after rewriting its reference.
  - `list` reads the bindings document ONCE, and derives everything it prints from that reading. It adds two lines per binding:
    - **trust:** trusted, or why not and the command that trusts it;
    - **console:** the one binding a console serving this repository declares, by the factory's own rule. A pending declaration is passed over, an unreadable declarations document declares nothing pending, and the console declares the first of the rest. Where `list` was given another document, the line says the console does not read it.
  - Every value a repository wrote is printed in a JSON string's form, by `list`, `trust`, the refusals and the notice. A newline or `\x1b[2J` in a field cannot forge or hide a line.
- **`serve_workbench`.**
  - A turn on an untrusted binding is refused `model_unavailable` with a FIXED sentence (`UNTRUSTED_TURN_MESSAGE`) saying how to trust it. The catalog's shape is closed, so the reason travels in the refusal, the notice and `list`. Where the catalog cannot list the binding, the sentence is `UNSERVABLE_TURN_MESSAGE` instead, which names the remedy and no command that trusts (`turn_message_for`). Both fit within the released failure envelope's 500-character `message` bound.
  - **The console's model approval** answers `APPROVAL_NOTICE` ("becomes an available catalog entry") only where the registered trust policy admits the binding it approved. That is a governed host's approval, or a binding this machine trusts. Otherwise it answers `APPROVED_UNTRUSTED_NOTICE`, a fixed sentence saying the binding is not yet trusted and how to trust it. A binding the catalog cannot list is checked first, under any policy and without reading a store, and answers `APPROVED_UNSERVABLE_NOTICE`, which names the remedy and no command that trusts. Approval asks only a policy that is already registered, so where nothing is registered it registers nothing and reads no store. It reads the registration once, under the seam's lock (`registered_verdict_for`), so a host that unregisters meanwhile never has the default installed in its place.
  - **The console intake follows batch M.** Its hand-off runs a broker the served repository's `ideation/dashboard/model-declarations.yaml` names, and that broker belongs to no binding. So it asks the registered policy its OWN question, `intake_verdict_for`, which no binding's trust can answer.
    - openDox's strict default (`MachineTrust.intake_verdict`) always says no, refused by name (`intake_refused`, reason `INTAKE_BROKER_UNTRUSTED`, a fixed sentence). That happens before any byte of the body is read, and the body is drained unread.
    - A host admits the intake only through its own `intake_verdict`, an optional third callable on the seam. A policy without one admits no intake.
    - So a repository that declares a binding with the intake's exact fields, and gets it trusted, gains nothing. No command trusts an intake declaration's broker.
- **The chat rail (`web/views/doxbench-chat.js`).** It has its own visible line, `UNTRUSTED_BINDING_REMEDY` (Python twin `doxbench_trust.UNTRUSTED_BINDING_REMEDY`), beside #74's no-model line.
  - It shows when the catalog has answered, is not empty, and offers nothing available, and it is announced once.
  - It names `"opendox model-binding list --repo-root <repository>"`, which shows whether each binding is trusted, and `"opendox model-binding trust --repo-root <repository> <id>"`. It also says that a binding already trusted, which a provider's refusal also leaves unavailable, is unavailable for the reason the console printed when its provider refused.
  - **`--repo-root` is in every quoted command.** `--repo-root` is required by every `model-binding` verb. Each command a fixed sentence quotes (the refused turn's, the rail's and the approval's) is parsed by the real parser in a test, with its placeholders filled in.
  - #74's no-model line stays hidden, because a model IS configured.
  - The sentence says "where it would connect" rather than naming a credential, because `test_doxbench_privacy.py` bans that word from both chat modules.
- **Bindings stay committable.** The bindings document is unchanged, and no trust is ever read from it.

## Whose rule: the neutral default, and the seam

`doxbench_trust` is a policy seam (`register` / `register_default` / `current` / `policy` / `unregister`), with the same window rule as `projection_seams`: the default is replaceable until it is read, a host over a host is refused, and the same registration again is a no-op. A policy carries `verdict` and `record`, and optionally `intake_verdict`. openDox's strict per-machine store is the NEUTRAL default.

**Why the default is registered lazily, which departs from R1Q10 (a)'s entry-point registration** (accepted by the holder): the CONSUMERS register it, the first time one asks and only where nothing is registered yet. Those consumers are `declared_model_port_factory`, the `model-binding` verbs and the intake hand-off. The console's approval is not one of them: it asks only a policy that is already registered. So this PR adds no hunk to `cli.py` or `serve.py`, which stay out of the phase-3 single-writer order. It also fails closed: a bare process is held to the strict default too. A host's registration at process start wins.

**How this relates to 4.2's seam tests.** Each seam module's tests enumerate and test that module's own seams: `tests/test_projection_seams.py`, `tests/test_doxbench_seams.py` (the doxBench validators and rail), and #77's `tests/test_column_seams.py` (`SEAMS = (cs.gate, cs.scope, cs.kickoff, cs.register)`). No test enumerates every seam of the package, so none needs this one added. `doxbench_trust.current()` refuses by naming its own seam and the registration call (`TrustPolicyNotRegistered`), as 4.2's discipline asks, and `tests/test_model_binding_trust.py` holds that. `tests/test_consumer_reach.py` derives its record over the whole package and passes with the new module, which imports no sibling.

## What T094 must register

RULED by Brett Heap on openxFactory#656 comment `5970369724`, *"Governance approval (Recommended)"*: openxFactory registers its own policy, `GovernedBindingTrust`, as a sixth `seams()` entry with an undo. Under it:

1. A binding whose declaration the governance flow APPROVED is trusted.
2. A binding a repository declared that is still PENDING is refused.
3. A binding with NO declaration is trusted: the operator's own, or the console intake's new binding while its broker runs.
4. Where the declarations document cannot be read, nothing is admitted.
5. `record()` writes nothing.
6. **`intake_verdict` must answer too**, as the policy answers for a binding with no declaration. The console intake asks that question and no other, so without it the governed host's intake would be refused. With it, the intake stays as it is today, which the ruling keeps.

`_GovernedHostPolicy` in `tests/test_model_binding_trust.py` is that policy as a test-local stand-in. The tests that compose it in process:
- `test_a_governed_host_policy_keeps_the_governed_flow[approved|undeclared]`: the factory resolves the brokered port, and a turn reaches the listener, exactly as before this change.
- `test_a_governed_host_policy_keeps_the_console_intake`: the governed intake runs its broker.
- `test_a_governed_host_policy_refuses_a_pending_declaration`: the pending and unreadable cases, and `recorded_for` refusing a policy that declines.

In the same checkout, the strict default refuses each of these until `trust`, and always refuses the intake.

## Evidence

| Run | Tree | Result |
|---|---|---|
| Red: the final test file with no other change | `main` `8e377823` | 14 failed, 1 passed (`test_the_edits_cover_every_field_of_the_record`), 1 xfailed, 56 errors (`ImportError: cannot import name 'doxbench_trust'`). The two defect cases fail as quoted above. |
| Red: each Copilot round's new cases, before their fixes | the code before each fix | Round 1: 8 failed. Round 2: 7 failed. Round 3: 3 failed. Round 4: the 4 new cases failed. One example: the two-process case kept only `first-binding`. |
| Red: round 8's cases, before their fix | `42c98f9d` | 2 failed, 1 passed. The store's verdict waited on a FIFO, and the platform check did not name `O_NONBLOCK`. The lock file's FIFO case passes there too, because Linux opens a FIFO read-write without waiting. |
| Red: round 7's cases, before their fix | `735d0c14` | 2 failed. Under umask 0777 the second record was refused ("the lock file cannot be opened"). The approval installed `MachineTrust` after a host's teardown and answered `APPROVAL_NOTICE` from its store. |
| Red: round 6's cases, before their fix | `e05c475c` | 7 failed, 9 passed. Both sites the thread named told the operator to trust a binding past the catalog's bounds: the approval's availability, and a served turn's message. |
| Red: round 5's cases and the self-pass's, before their fixes | `7012cda3` | 32 failed, 5 passed. Three hostile repository names made `CANARY` when `sh` ran their printed command: a newline, a terminal escape and a non-ASCII character, each followed by `$(touch CANARY)`. The factory raised `InvalidCatalogEntryError` on a trusted binding past the catalog's bounds, and `record` raised `FileNotFoundError` through a link to nothing. |
| Green: the trust module, with the census, chat-configuration and deploy-shape modules | `735d0c14` (the merge's tree, run just before it was committed); the trust module alone again at `adb19f1e` | 336 passed, nothing skipped or xfailed; 135 passed at `adb19f1e` |
| Whole suite (`tests` + `tests_runtime`), in a venv installed by CI's own command (`pip install --only-binary :all: -c constraints-cpython312-linux.txt -e ".[runtime,test]"`) | head `adb19f1e` | **3945 passed, 177 skipped, 0 failed.** The 177 are the database-backed runtime cases, which need CI's PostgreSQL service (`OPENDOX_TEST_DATABASE_URL`). #76 reads the same skips on its own local run. |
| The 10 local reds of earlier rounds | `a6c2a844` (the 10 alone), then the head (in the whole suite above) | They were this machine's first venv: it was installed without the `test` extra, so it lacked the `local` extra's `pixeltable-pgserver`, and generate-and-open with `--local` refused ("the local install's PostgreSQL server is not installed"). In the CI-command venv, all 10 pass, with a short TMPDIR (`~/.local/state/t1`) and with the long one (`~/.local/state/t100-tmp`) alike. The socket-path length was not the cause here. |
| Mutants (`mutate_t100.py`, one textual edit each) | head `adb19f1e` | **94 of 94 killed**, in one run (`mutants-run-17`). |
| openxFactory's existing tests (`tests/ideation-dashboard -m "not postgres"`, in a clone named `openxFactory`, TMPDIR holding the basetemp) | openxFactory `main` `1f670bc3`, openDox code leg at `main` `8e377823`, then at T100 `7a04590d` | 4 failed, 1477 passed both times; the failure sets diff IDENTICAL (all 4 pre-existing). openxFactory injects its own `model_port_factory` and commits no bindings document, so its governed flow never reaches the gate before T094. The later rounds change only the store, how verdicts are held, and the rail's sentence. |

The mutants killed:
- **The digest:** each of the ten fields, each by its own hand-edit case.
- **The root key:** ignored, or not resolved.
- **The factory gate:** by-passed, or accepting a verdict for another binding.
- **The in-depth refusals:** the broker operations, the resolver, `dispatch` and `catalog`; and a verdict admitting by id alone.
- **The store:**
  - accepting a writable file;
  - following a link to its file;
  - skipping the link check or the tree check;
  - being written with mode 0666;
  - following a temporary-file link planted in the race window.
- **The CLI:**
  - `add` and `edit` recording no trust, or writing before recording;
  - `set-credential` skipping the gate, or not re-trusting what it rewrote;
  - `trust` recording nothing or printing nothing;
  - `list`, the disclosure and the refusals printing raw values;
  - `list` saying nothing of trust.
- **The intake and the state directory:** the intake gate; the state-directory check, skipped or blind to nesting.
- **The default and the notice:** the lazy default never registered; a silent factory notice.
- **The rail line:** shown beside an available model, for an empty catalog, or where a host offers intake; or never announced.
- **Copilot round 1:**
  - recording without the cross-process lock, or never taking it;
  - the lock file opened through a link, or its owner and mode unchecked;
  - a declined record taken as trust;
  - a policy's failure to record escaping;
  - an untrusted verdict for another binding passed through;
  - the intake asking the binding question;
  - the per-machine store admitting the intake.
- **Copilot rounds 2 and 3:**
  - the platform check skipped, or forgetting the file lock;
  - an unresolvable state directory escaping;
  - the rail line still claiming `list` says why;
  - a verdict for another root passed through;
  - a host policy's refusal text passing through, of either class;
  - openDox's own store's refusal sanitized too.
- **Copilot round 4:**
  - a host's `MachineTrust` subclass passing its refusal text;
  - the writer outgrowing the read bound.
  - Round 4's "a dashed id printed where an option is read" is RETIRED with the `--` it mutated: no id the catalog accepts begins with `-`, and no command is printed for one that does.
- **Copilot round 5 and the self-pass:**
  - a printed path not shell-quoted;
  - an unprintable path printed in a command;
  - an id the catalog refuses printed in a command;
  - a binding trust cannot repair told to trust;
  - a verdict trusting, or a record recording, a binding the catalog refuses;
  - servability judged by the id alone;
  - `list` judging a second reading;
  - `list`'s command, the factory's notice and the refusing port each omitting the document read;
  - a link to nothing gone through;
  - a system refusal escaping `record` or `verdict` raw;
  - the console line ignoring pending declarations or the document listed, or naming the last approved binding;
  - an approval always saying available, or registering and reading the default;
  - the turn message's `list` command or the rail's `trust` command dropping `--repo-root`.
- **Copilot round 6:**
  - an approval of a binding the catalog refuses saying to trust it;
  - a turn on such a binding saying to trust it;
  - a refused turn naming no cause.
- **Copilot round 7:**
  - the lock file keeping the umask's mode;
  - an approval reading the seam twice;
  - the registered verdict registering the default.
- **Copilot round 8:**
  - the store waiting on a FIFO in its place;
  - the platform check forgetting the nonblocking open.

The F16.1 batch M cases each serve a fresh `git init` with a fresh `OPENDOX_STATE_DIR`. They run over three bindings in turn:
- a broker that writes a marker file;
- an `env:` reference whose environment records every name read;
- a `keyring:` reference whose stand-in backend records every lookup.

The listener records every request. One test per case of the block.

**`set-credential` on an `env:` or `keyring:` binding** is refused by the refusal it already had, "names no broker", which also comes before any read or spawn. Naming `trust` there would point at a command that cannot make the verb work.

## Review

Every Copilot finding was accepted and fixed with a case that failed first and its mutant, then answered on its thread and resolved:
- **Round 1, at `1ef4c71d`, fixed in `7a04590d`:**
  - `r4173513738`: a policy that declines to record;
  - `r4173513761`: the lock across processes;
  - `r4173513782`: the intake's own question;
  - `r4173513795`: a verdict for another binding.
- **Round 2, at `7a04590d`, fixed in `e4b145d1`:**
  - `r4173876800`: a platform without the store's primitives;
  - `r4173876823`: an unresolvable state directory;
  - `r4173876849`: what the rail line says `list` shows.
- **Round 3, at `8270dffc`, fixed in `e4b145d1`:**
  - `r4174310794`: a verdict for another root;
  - review `5402101086`'s "previously missed" item: a host policy's refusal text.
- **Round 4, at `cb691b18`, fixed in `24a1c25e`:**
  - `r4174632006`: only the exact `MachineTrust` passes its refusal;
  - `r4174632060`: the printed trust command runs as printed;
  - `r4174632086`: the write bound.
- **Round 5, at `7012cda3`, fixed in `db77c4ea`:**
  - `r4174783197`: a printed command a shell reads back exactly;
  - `r4174783250`: `list` reads its bindings once;
  - `r4174783280`: a binding the catalog refuses is never trusted, and never fails the start;
  - `r4174783301`: a link to nothing, and any unnamed `OSError`, refused by name.
- **Round 6, at `e05c475c`, fixed in `a6c2a844`:**
  - `r4175203889`: a binding the catalog cannot list is told its remedy, at the approval and in a refused turn, and never to trust it.
- **Round 7, at `735d0c14`, fixed in `42c98f9d`:**
  - `r4177946237`: the lock file is 0600 whatever the umask;
  - `r4177946288`: the approval reads the trust seam once.
- **Round 8, at `42c98f9d`, fixed in `adb19f1e`:**
  - `r4178064601`: the store and its lock file are opened without waiting on a FIFO.

**The adversarial self-pass, in the same commit.** It covered two things:
- **Every command printed for a human to paste.** That is each refusal, the notice and `list`, plus each command a fixed sentence quotes. Hostile values in every interpolated operand were run through `sh`, `bash` and `zsh`.
- **The trust-state machine,** for whether what is printed, what is stored and what is enforced agree: pending, approved, undeclared, unreadable declarations, and a stale record.

It found three gaps, each now fixed with its case and mutant:
- `list` did not say which binding a console declares;
- an approval said "available" for a binding the strict default still refuses;
- the fixed sentences quoted `model-binding` commands without the required `--repo-root`.

**SonarCloud.** The quality gate's one failure was `python:S5332`: the trust disclosure spelled a plain-HTTP URL scheme, and now says "plain HTTP". The two functions over the cognitive-complexity bound (`_unsafe_because`, `_refuse_an_unsafe_tree`) are split into named parts, with the same rules. The gate passes from `7a04590d` on.

## CI's triple

`EXPECT_SKIPPED` is 11, `main`'s own value. It moved to 14 for three strict-xfail cases, each waiting on a draft and naming it:
- the store's default home (#69);
- the rail's trust line (#74);
- a served turn that reaches its model step standalone (#77).

It stepped back by one, with its reason in the workflow, as each draft reached this branch (`1ef4c71d`, `32646871`, `78d1e904`). All three cases now run and pass. The floors are not moved.

**The web census's class-A total is re-derived from the merged tree at every merge of `main`.** It is 18526 at the head: `main`'s 18486 plus this change's 40 lines in `views/doxbench-chat.js` (1890 to 1930). Round 5 changed that file within one line, so its count holds, and #76 touched no web file. **The floors** are #76's re-pin (3977 / 3966, T082). T100 moves neither, since it only adds cases. The merge kept both reasons for `EXPECT_SKIPPED` holding at 11, T082's and T100's. #84 (T104) also moves `EXPECT_SKIPPED` and class A. Whichever of the two lands second re-derives both values from its own merged tree.

## #77, and other notes

- **#77 refuses the console intake standalone** when no gate-record writer is registered (`5961364221` item 1). So this file's intake cases use a stand-in host that registers a host gate at `opendox.column_seams.gate`, as #77's own tests do.
- **#77's host fixture** runs `model-binding add` in a CHILD with a private `OPENDOX_STATE_DIR`. The parent's factory reads its own store, so a turn there still reads the binding untrusted until that store trusts it.
- **#69's tree checks are DUPLICATED here, not imported.** `runtime/bundle.py`'s helpers take a different signature, raise `BundleRefused` with the bundle's wording, and the module imports heavier modules. They are kept in step by rule, and the tests hold both.

## Gates

- `pyflakes` is clean over the changed modules.
- `tests/test_provider_boundary.py` passes. No module outside `doxbench_provider` spells its banned needles.
- No closing keyword appears in any commit message on this branch or in this body.

🤖 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