T071, 13.2 and 13.3: load_settings refuses a non-PostgreSQL DSN and a collapsed DSN pair (plan 034) - #60
Conversation
…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>
Reviewer's GuideThe PR implements plan 034 T071 by requiring a separate migration DSN, restricting URI DSNs to PostgreSQL, and rejecting exact credential reuse between application and migration settings. It updates all affected runtime test environments with distinct same-database DSNs and adds focused coverage for validation order, accepted syntax, error attribution, and secret redaction; the PR remains draft pending T063. Flow diagram for load_settings DSN validation orderflowchart TD
A[load_settings] --> B[Require database and migration DSNs]
B --> C[_refuse_non_postgresql_dsn]
C --> D[_refuse_two_dsns_that_select_different_schemas]
D --> E[_refuse_the_same_dsn_in_both_settings]
E --> F[Return RuntimeSettings]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical served-workload compatibility and malformed-DSN handling issues remain unresolved, with minor test coverage gaps.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
This PR strengthens runtime database configuration by requiring a distinct PostgreSQL migration DSN and updating affected tests.
Changes:
- Rejects unsupported dialects and identical application/migration DSNs.
- Requires
OPENDOX_MIGRATION_DATABASE_URL. - Updates fixtures, test environments, and validation coverage.
| File | Summary |
|---|---|
src/opendox/runtime/config.py |
Adds DSN validation and required migration configuration; unresolved malformed-URI handling and served-workload compatibility issues remain. |
tests_runtime/conftest.py |
Adds a distinct migration DSN fixture. |
tests_runtime/test_api_endpoints.py |
Supplies migration DSNs to endpoint tests. |
tests_runtime/test_migrations_apply.py |
Updates migration test environments with distinct DSNs. |
tests_runtime/test_runtime_cli.py |
Updates CLI environments and adds validation tests; dialect-name assertions are incomplete. |
tests_runtime/test_runtime_surface.py |
Updates runtime configuration test inputs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…(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>
…ult_registry.resolve_source) (#70) ## What this is A small follow-up to T055 (#59, landed as `fa140875`). It takes the one finding Copilot's review of #59 at `0c946f4e` raised as "previously missed", after the last push that could take it. ### The finding, quoted Copilot review overview on #59 at `0c946f4e` (review `5368674478`), medium, "Avoid unstable second lookup during source path confinement", `src/opendox/default_registry.py:444`: > `resolve_source()` performs a second lookup by `(repository, ref)` after the caller has already resolved an entry. A concurrent refresh can replace that key between the two operations, so the returned path can be confined under a different entry's `source_root` than the entry whose metadata/listed paths the caller is using (for example, `serve_project._resolved_listed_edit_entry`). Expose an entry-based confinement operation or make the caller pass the resolved entry so lookup, validation, and path confinement use one stable entry. It is real. #59 already closed the same pattern in `serve.py`'s `/source` arm (Copilot r4136585695 and r4136863569): that arm resolves the entry once and confines to that entry's own root with `resolve_source_path(Path(root), rest)`. `serve_project._resolved_listed_edit_entry` was the one caller left. ## What changed - `src/opendox/serve_project.py`: `_resolved_listed_edit_entry` resolves the entry ONCE and confines the path to THAT entry's own root through the registry seam's declared `resolve_within` (read as `projection_seams.registry.current()` inside the function). The listed-path check already read the entry in hand, and the editor is launched over `entry.source_root`, so the lookup, the validation and the confinement are now one entry. An entry with no root serves nothing, as before. - The caller needs no method the seam does not declare. `resolve_source` is on no seam's list (`REGISTRY_CALLABLES`), so a contributed registry is never asked for one. That is why the fix confines the entry in hand rather than adding an entry-based method to openDox's own registry: a host's registry would not carry it. - `src/opendox/default_registry.py`: `SnapshotRegistry.resolve_source` keeps its behaviour (one lookup, confined to that entry). Its docstring now says it is for a caller that holds only a pair, and why a caller that already holds an entry must not ask again by its pair. - `tests/test_edit_action_one_entry.py` (new, 8 cases). No module-level proxy is bound in `serve_project.py`: `tests/test_projection_seams.py::test_no_proxy_over_a_seam_is_read_at_import_time` pins the exact set of modules that bind one (`serve.py`, `serve_workbench.py`), and this PR does not edit that file. ## Every caller of the two-step path `git grep resolve_source -- src` at `fa140875` finds the definition and exactly one production caller, `serve_project.py:111`. The other registry lookups in `src/` are one resolution each: `serve.py` `_serve_snapshot` and `_serve_source` (already one entry), `serve_workbench.py` (`resolve` then `resolve_within(entry.source_root, ...)`, three sites), and `branch_session.py` (stamping an entry after a register, no confinement). `_keyed_source`'s `registry.get(*parsed)` is a parse-time existence check whose result is a key, and `_serve_source` then resolves that key once. The new test `test_no_module_asks_a_registry_for_a_path_by_a_pair` holds that set empty, so a future caller has to be argued for. ## Evidence **Red at main, green after.** The new module against `fa140875`'s sources (`serve_project.py` and `default_registry.py` as on main): ``` FAILED test_the_edit_arm_asks_the_registry_once_and_never_for_a_path_by_a_pair FAILED test_no_module_asks_a_registry_for_a_path_by_a_pair FAILED test_a_host_registry_needs_only_what_the_seam_declares FAILED test_a_refresh_that_replaces_the_key_cannot_take_the_file_the_route_refuses FAILED test_a_refresh_that_replaces_the_key_cannot_take_the_file_the_route_accepts 5 failed, 3 passed ``` With this PR: `8 passed`. The three that pass at main are the control (a listed file opens with no refresh), confinement kept, and "no root serves nothing", which pin what must NOT change. The race is tested at the ROUTE, both ways, with a real server, a real `POST /actions/edit` and the console token. A registry whose key is replaced right after the route's first `resolve` (as a refresh on another thread would) lands the replacement between the resolution and the confinement (asserted): - Entry's root has NO file, the replacement's root has it. At main the route took the file from the replacement's root, read the first entry's listing, and started the editor over the first entry's root: `200` and an editor over a file that is not there. Now: `404 document_unavailable`, no editor. - Entry's root has the file, the replacement's root has none. At main the route refused a file its own entry holds (`404`). Now: `200`, and the editor is started over that entry's root. **Mutants of the fix, all killed** (the new module only, `serve_project.py` restored after each): | mutant | killed by | |---|---| | M1 the second lookup again (`registry.resolve_source(repository, ref, path)`, i.e. main) | one-lookup count, the no-module-asks scan, the host registry, both race cases | | M2 confine by `Path(root) / path`, no containment rule | `test_the_entrys_own_root_still_confines_what_the_route_accepts` | | M3 the no-root check dropped | host-registry and no-root cases | | M4 the listed-path check dropped | the confinement case (an unlisted file inside the root) | | M5 the listing read through a second lookup | the one-lookup count (the race cases cannot see it, as both entries share a snapshot) | **T056's module against this fix.** Fetched #66's head `38761c76` read-only into a scratch worktree, merged main (`fa140875`; the two add/add conflicts, `default_registry.py` and `tests/test_projection_seams.py`, resolved to main's blobs, as T056's own diff does not touch either), cherry-picked this commit on top, and ran `tests/test_standalone_generate_path.py` (its children run from that worktree's `src` via `PYTHONPATH`): ``` tests/test_standalone_generate_path.py + tests/test_edit_action_one_entry.py: 14 passed control, this commit reverted: tests/test_standalone_generate_path.py: 6 passed ``` That module's requests are `GET /source/notes-toolshed-inventory.md` (200, byte-equal) and `GET /source/.git/config` (404). They go through `serve.py`'s `/source` arm (`resolve_source_path`), which #59 already moved off `resolve_source`, not through `serve_project`, so this PR leaves them as they were. The module passes identically with and without this commit. **Whole suite** at `5666505b`, `LANG=C.UTF-8`, run in the foreground with a throwaway `postgres:16` and `OPENDOX_TEST_DATABASE_URL` set as CI sets it: ``` python -m pytest -q tests 2379 passed, 11 skipped python -m pytest -q tests_runtime 607 passed total 2986 passed, 11 skipped, 0 failed ``` #59's CI at `0c946f4e` was `selected=2989 passed=2978 skipped=11`. This PR adds 8 cases: 2997 selected, 2986 passed, 11 skipped. ## Overlap with open PRs None. `gh pr diff --name-only` on #60 to #69, read against each PR's own merge-base (#66 and #68 carry #59's commits, which lists `default_registry.py` spuriously): their own diffs touch neither `serve_project.py` nor `default_registry.py`. #66's own `serve.py` change is one flushed `print` near line 2216, outside the `/source` arm. ## Review rounds - **Copilot at `0471f8c7`**: "Approval recommended", no findings, 0 threads. The SonarCloud quality gate failed there on 4.9% duplication on new code (required at most 3%): the new test module carried a copy of `test_projection_seams.py`'s autouse isolation fixture and `git` helper. - **`5666505b`**: imports both instead of copying them (the `tests/test_doxbench_*.py` precedent), a test-only change. The autouse fixture still applies to all eight cases (eight SETUPs under `--setup-show`), the eight cases pass, and M1 to M5 are still killed. - **Copilot at `5666505b`**: "Approval recommended", no findings, 0 threads. `validate` and SonarCloud ("Quality Gate passed") green at `5666505b`. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
…ed (Copilot review) Copilot's review at the merge-from-main head (adeb6fe) noted that `postgresql:foo` is still accepted, although the supported URI forms need `://`. Measured, it is worse than that. urlsplit reads `postgresql:` with no `//`, and any capitalized `PostgreSQL://` or `POSTGRES://`, as the PostgreSQL scheme, so the dialect gate passed all of them. libpq reads none of them as a URI. It recognizes only the exact, lower-case `postgresql://` and `postgres://`, parses the rest as keyword/value, and refuses them with a message that repeats the whole value (psycopg 3.3.6: `missing "=" after "postgresql:svc:hunter2@db/x" in connection info string`). That is the un-named failure at the driver that 13.2 exists to stop, and it carries the password. _refuse_non_postgresql_dsn now refuses a PostgreSQL scheme in any spelling other than libpq's two. The refusal names the setting and the two spellings, and does not repeat the value. Both loaders ask it. The new case covers four spellings for each of the two settings. All 8 fail against adeb6fe's config.py and pass here. The two accepted spellings and the keyword/value form still load. Five mutants are killed: the check dropped, case-insensitive matching, a check of only the `//`, every PostgreSQL DSN refused, and the value repeated. Full suite: 3069 selected, 3058 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's review at Measured, with psycopg 3.3.6:
So a mistyped scheme reached the driver unnamed and carried its password into the error. That is the failure 13.2 exists to stop. The fix: Evidence:
Lane: openxfactory-4 (openXfactory-4-openDox_extraction) |
|
Brings in #60's c39d960 (a PostgreSQL scheme libpq would not read as a URI is refused) and #67's cdf7382 (the --local callers inherit none of the runner's runtime settings). Two conflicts, both resolved by keeping both sides: - tests/standalone_child.py's docstring keeps both bullets, the settings scrub first and the per-child state directory as the one setting given back. The code merged cleanly in that order: the environment drops every SETTING_NAMES entry, then OPENDOX_STATE_DIR is set to the child's own. - tests/test_projection_seams.py's empty-option case scrubs the settings and keeps T072's bundled-server tripwire. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
READY at c39d960 — phase 3 is open (T063 landed, openxFactory#1218 → a883bbf6); Brett: draft phase 3 ahead, land in plan order when green. The holder checked: validate and SonarCloud green at this head, 0 unresolved threads, Copilot's latest review has no findings; its dependent is retargeted to main and is not READY. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) |
There was a problem hiding this comment.
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.
You can request another review in 21 hours and 49 minutes by commenting @sourcery-ai review. Upgrade to get a review now.
…swers them (R1Q19 (a)) (plan 034) (#65) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability **T063 (the phase-2 checkpoint) has landed** (openxFactory#1218 to `a883bbf6`), so the condition this draft was authored under is met. It was authored ahead under Brett's phase-3 draft-ahead word, "Install chain + lens (Recommended)" (openxFactory#656 comment [5901112350](opensoft/openxFactory#656 (comment)), 2026-09-29). Claim: [5901192386](opensoft/openxFactory#656 (comment)). The holder posts READY; this PR does not. ## T088 (plan 034, slice P3-L): the lens's two seed actions The task, from `specs/034-opendox-standalone-operation/tasks.md` at openxFactory `main` `91e4685f`: > **The lens's two seed actions** are offered only where a binding answers them (R1Q19 (a)). Standalone, no binding answers `/actions/dtn-seed` or `/actions/staging-seed`, so neither control is offered. `lens.js` stays the web census's one declared `?` row. Moving the two controls into a view extension that openxFactory contributes, which retires the row, is R1Q19's (b), for later and outside release 1. - **Realizes**: none of the 69; this is the precondition for AT-R1 step 6. - **Falsifier**: AT-R1 step 6 (`spec.md`): "`#tab-lens` renders the bullseye with the corpus's documents as dots, and not the text 'nothing on the radar'. Neither of its two openxFactory seed actions is offered, since no binding answers them (R1Q19 (a))." - **Ruled**: R1Q22 (a), `5817152735`; R1Q19 (a), `5850003126`. No #1144 line changes (R1Q19 amends none), so there is no batch amendment to carry. - **After**: T063, T069. Both are done: T069 earlier, and T063 as openxFactory#1218 to `a883bbf6`. ## What changed `src/opendox/web/views/lens.js`, and the census's own bookkeeping for that file. Nothing else in `src/`. - **The capability that says a binding answers is not new server code.** `/capabilities` already carries `views.contributed_routes`, which `serve.build_server()` builds from the very `route_bindings` table its POST dispatch consults (`route_extension.match(self.route_bindings, "POST", path)`). The lens reads its answer off the payload the shell has already fetched, so there is no second fetch and no new field, and nothing that can drift from the dispatcher. - **`bindingAnswers(capabilities, method, path)`** (exported) mirrors `RouteBinding.matches`: method, then path, exact or under a prefix, a GET binding answering HEAD too. It fails closed on anything it cannot read (no payload, the probe's fallback, no `views`), and on the WHOLE manifest, as `manifestRoutes()` does: every entry is first held to what `RouteBinding.__post_init__` accepts (a known method, a pattern rooted at a slash with no query or fragment, a boolean `is_prefix`, a prefix ending in a slash), and one malformed entry leaves every route unanswered (Copilot's round-1 finding, taken in d716ded). Unlike `manifestRoutes()` it never throws, because it gates a control. - **The register seed** (`draft seed`, a drill row on a set two or more repositories share): `ctx.onSeed` is null unless the DTN route is answered. - **The staging seed** (`draft staging seed`, the pick bar): `ctx.pickDoc` is null unless the staging route is answered. Every selection site (the matrix's checkbox column and select-all, the clickable dots, the `picked` mark, the pick bar) was already guarded by `ctx.pickDoc`, and `test_the_matrix_selection_is_the_seeds_only_input` says the column "exists to feed ONE action". So the selection goes with the button, and a standalone lens never says "tick documents to draft from them". The matrix then draws no empty gutter (and its empty-row `colspan` follows), and the drill note stops naming a seed that is not there. - Both controls carry a `data-seed-action` hook (`dtn-seed`, `staging-seed`) for the browser half (T096) to assert absence by, without matching on a label that changes (`re-draft`). - **Census**: `views/lens.js` `loc` 1495 to 1577 and the `?` class total. The row stays `?` (the two route literals and their `route_ownership_exceptions` entry are untouched). The two `until` lines named "a future ruling"; that ruling has now been made in part, so they name R1Q19 (b). - `tests/test_display_facet.py`'s lens render test drove the lens with no capability payload and read the pick bar's words. It now asks the lens as a host that answers both routes (5 added lines plus 1 changed). The standalone lens is the new file's. ## Round 2: phase 2 has landed (main `047bb4fa`) - **Main merged into this branch** as a merge commit (`239ebf8e`, no rebase, no force-push), with no conflict. `lens.js`, the census rows and the `test_display_facet.py` respell were not touched by main, so there was nothing to resolve in them. `bindingAnswers` and `lens.js` are unchanged since `d716dedf`. - **The standalone half now reads the real thing.** A lone openDox can build and serve since T055, so the new `test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action` generates and serves the `plain-documents` fixture (AT-R1 step 3 (a)) in a fresh process with nothing registered, over HTTP, and hands the lens that serve's own `/capabilities` and `/snapshot.json`. The radar draws one dot per document that declares the checked keyword, and no seed control, pick bar, checkbox, clickable dot or word "seed" is on the page. - **Batch L (openxFactory#1212) and `actions.gate`.** T084 will turn `actions.gate` false on a standalone serve where no gate route answers (measured at `047bb4fa`: a standalone serve with a git identity says `gate: true` while every gate POST answers 404). This lens reads the routes a binding answers (`views.contributed_routes`), not `actions.gate`, so the answer does not move with T084. The falsifier now holds that, rather than assuming it: the standalone cases run with `actions.gate` ON and OFF (hand-built payloads), the real-serve case runs with and without a git identity (which is what sets the serve's own gate verdict today, so both answers are real), and the bound cases run with it ON and OFF too (a read-only project view has no gate and still offers the seeds). Two new mutants make the lens follow `actions.gate` instead of the binding, one per control, and both are killed. - The child process drops `GIT_*` and `XF_*` from its environment. The suite exports `XF_GATE_PRINCIPALS` (a roster of several names, which is ambiguous with no claim), and that alone resolved no actor whatever the repository's own identity said. ## The falsifier, before and after `tests/test_lens_seed_actions.py` (new, 13 cases) drives the REAL `views/lens.js` under node. Payloads are built with the real `route_extension.collect_bindings` and `view_extension.view_manifest`, or, in the real-serve case, read from a real serve. Both vocabularies are read, because the two controls live in different ones. **Before** (the new file against `main` `047bb4fa`'s `lens.js`): 12 failed, 1 passed. The AT-R1 step 6 cases fail on the behaviour, not on a missing symbol, including the real-serve ones: ``` AssertionError: ('keywords', 'a seed action is offered where no binding answers it') AssertionError: ('real serve', 'a seed action is offered where no binding answers it') FAILED ...::test_a_standalone_lens_draws_its_radar_and_offers_neither_seed_action[gate-on] FAILED ...::test_a_standalone_lens_draws_its_radar_and_offers_neither_seed_action[gate-off] FAILED ...::test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action[actor] FAILED ...::test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action[no-actor] FAILED ...::test_the_same_lens_offers_both_where_a_host_contributes_both_routes[gate-on] FAILED ...::test_the_same_lens_offers_both_where_a_host_contributes_both_routes[gate-off] FAILED ...::test_each_seed_action_is_offered_on_its_own_route_alone[contributed0-True-False] FAILED ...::test_each_seed_action_is_offered_on_its_own_route_alone[contributed1-False-True] FAILED ...::test_a_payload_that_cannot_say_a_binding_answers_reads_as_none_answering FAILED ...::test_a_manifest_entry_is_trusted_only_if_the_server_would_have_accepted_it FAILED ...::test_a_binding_answers_only_the_method_and_the_path_it_declared FAILED ...::test_the_lens_answers_exactly_what_the_servers_dispatcher_answers 12 failed, 1 passed ``` (The one that passes is the source ratchet that the manifest and the dispatcher name the same table.) **After** (this branch, `c0a97648`): `13 passed`. What the cases assert: 1. Standalone, with `actions.gate` on and off: the radar draws its documents as dots, "nothing on the radar" is absent, no seed control is offered by label OR by hook, and no pick bar, checkbox, clickable dot, pick column or word "seed" survives, in both vocabularies. 2. The same, over a real standalone serve of the `plain-documents` fixture, with and without a git identity. 3. A host contributing both routes gets both controls (register seed only on the shared row), with `actions.gate` on and off, so "never offer them" cannot pass. 4. Each control is offered on its own route alone. 5. Fail closed on every unreadable payload shape, including one bad entry beside a good one (either order). 6. Method and path exactness, and prefixes. 7. `bindingAnswers` over the published manifest equals `route_extension.match()` across GET, HEAD and POST and exact and prefix routes. 8. The lens's notion of a well-formed manifest entry is `RouteBinding`'s own: over 21 candidate entries, on both sides of every clause and including JSON shapes that were never constructed, a list holding one beside a good seed route answers yes exactly when `RouteBinding` accepts it. 9. `serve.py` names the same table for the manifest and the POST arm (read as text; the real serve in case 2 now exercises it end to end). **Mutants killed** (each applied to `lens.js` alone, the new file run, then restored): staging gate removed; register gate removed; fail open on a payload with no manifest; method ignored; prefix and exact inverted; register seed asking the staging route; GET no longer answering HEAD; pick column drawn with no selection; drill note always naming the seed; `is_prefix` boolean check dropped; gate reading the wrong caps object; one bad entry no longer poisoning the list; each of the rooted-pattern, no-query-or-fragment and prefix-ends-in-slash clauses dropped; the methods table widened; the staging seed following `actions.gate`; the register seed following `actions.gate`. 18 of 18 killed, 0 survivors. ## Measured `LANG=C.UTF-8`, the whole suite, in the foreground: `main` `047bb4fa` 2878 passed, 177 skipped; this branch (`c0a97648`) 2891 passed, 177 skipped. The skip sets are identical node for node. The 177 are the database-backed cases that CI runs against its PostgreSQL service. Neighbours (`test_web_boundary.py`, `test_display_facet.py`, `test_bullseye_widget.py`, `test_lens_labels_at_scale.py`, `test_view_registry.py`) pass unchanged apart from the one respell above. ## Overlap, and what this deliberately does not touch - Re-checked `gh pr diff --name-only` against every open openDox-code draft (#60 to #64, #67, #69) at this round: none touches `views/lens.js`, the census fixture, `tests/test_display_facet.py` or any file under `web/views/`. #57, #58 and #59 have landed, and the merge was clean. No `serve.py` edit. - `.github/workflows/validate.yml` is untouched: its pinned floors are `>=` and permit the rise of +13 collected, and re-pinning them is a cross-draft hotspot better done once, with the phase's bookkeeping. - T096 (the browser half that observes the absence), T087 and T084 are not this PR. - **Expected follow-on, not a defect:** when T084 (openDox-code#77) merges main after this lands, it changes `test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action`'s `capabilities["actions"]["gate"] is actor` assertion to `is False`, because of batch L's capability honesty (a standalone serve says `gate` false even with a git identity). The lens reads `views.contributed_routes`, not `actions.gate`, so the rest of the case, the dots and the absent seed controls, holds either way, and the gate-on and gate-off standalone cases already run both ways. 🤖 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>
main 7ff434d carries three landings: T085 (#71, squashed as 2680eb5), T088 (#65) and T071 (#60). The merge is clean. tests/fixtures/ web_boundary_census.yaml merged automatically, with T088's lens.js row and this branch's doxbench-chat.js row in separate lines, and its census cases pass. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(T085) src/opendox/runtime/config.py conflicts in four hunks. Main's side of each is #60's squash, whose config.py is byte-identical to c39d960, which this branch already carries (git diff c39d960 origin/main on that file is empty). So every hunk resolves to this branch's side, which is T071 plus T070's install mode. Everything else merges cleanly. One edit inside the merge, because of T070: #71's tests/test_doxbench_defaults.py runs `python -m opendox.cli generate-and-open` as a child. Since T070 an unflagged generate-and-open is HOSTED, and with no issuer it refuses (13.5): "generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED". So test_the_served_catalog_route_answers_from_generate_and_open now passes --local, the single-user install the case means, with a comment saying why. #65's real-serve lens test (tests/test_lens_seed_actions.py) runs `cli.main(["generate", …])` and serve.build_server, never generate-and-open, so T070 does not reach it and it needs no flag. It passes unchanged. #74's tests/test_chat_model_configuration.py is not on main yet. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T078 landed on openDox-code main as #61, squashed to 8a98e31, and main also carries T085 (#71), T088 (#65) and T071 (#60). This branch carried T078's own commits, so the files T078 changed (doxbench_binding.py, doxbench_provider.py, tests/test_model_provider_broker.py) met T078's squash on main. Main's copy of each equals T078's head d07b937, and two of them conflicted only where this branch changed T078's lines. Each is resolved to this branch's side, so the merge adds exactly what main gained beyond T078 (git diff d07b937 8a98e31), and nothing else. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plan 034) (#67) 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` `91e4685f`, after T007 batch H landed as openxFactory#1206 → `f99a2097`), phase-3 slice **P3-I, install mode and the bundle**: - **T070** (#1144's 13.4, 13.5 and 13.6): `OPENDOX_INSTALL_MODE`, and the `--local` flag. - **Falsifier:** F13.1's refusals, plus a test of the disagreeing pair. - **After:** T071 (openDox-code#60, landed as `7ff434d9`) and T007 batch H (landed). It was stacked on #60's branch; the holder has retargeted it to `main`, and `25262459` merges main `7ff434d9`, so this diff is T070 alone. **Ruled:** - R1Q22 (a), `5817152735`; - R1Q15 (b), `5850003126` (as batch H's 13.4 addendum reads); - the phase-3 draft-ahead widening, `5901112350` (*"Install chain + lens (Recommended)"*). Claimed on openxFactory#656 in [`5901575394`](opensoft/openxFactory#656 (comment)). **Authored ahead as a DRAFT**, which did not go READY before T063 landed and the holder said so. **T063 has landed** (openxFactory#1218 → `a883bbf6`, 2026-10-02 23:37:43Z), which closes phase 2, so phase 3 is open, and #60 ahead of it has landed. ## What it does - **The selector** (13.4): `OPENDOX_INSTALL_MODE`, values `local` and `hosted`, defaulting to `hosted`. It is a `SETTINGS` entry read in `runtime/config.py`, placed immediately beside `OPENDOX_OIDC_ISSUER`. A blank value reads as unset, which means hosted: *"It is UNSET, not `local`, that must be safe."* - **The flag** (R1Q15 (b)): `generate-and-open --local` selects local exactly as `OPENDOX_INSTALL_MODE=local` does. With neither, the install is hosted (13.5). The flag belongs to the verb and follows it (10.1). - **A flag and a setting that disagree are refused, naming both.** Example: `--local` beside `OPENDOX_INSTALL_MODE=hosted`. No explicit selection is silently overridden. **This is plan 034's fail-closed reading (Principle VII), not #1144's text.** No answer rules the pair, batch H does not write it into #1144, and `evidence/analyze-round-2.md` (U2-1, V2-6) records it for Brett. - **Local needs no broker.** `RuntimeSettings.oidc_issuer` and `oidc_audience` are empty, and `oidc_jwks_url` is `None`. `jwks_url()` and `discovery_url()` return `""` rather than a path glued onto nothing. - **Local binds loopback only, with no opt-in** (13.4). A non-loopback `--host` on `generate-and-open`, or `OPENDOX_BIND_HOST` for the runtime's own listener, is refused, naming the loopback rule. - The set is `serve.py`'s own `LOOPBACK_HOSTS` (`127.0.0.1`, `::1`, `localhost`). 13.4 asks for *"the same judgement at the mode's own boundary"*. - `config` cannot import `serve`, so it spells the set, and a test holds the two equal. `127.0.0.2` is therefore refused, because the document server does not treat it as loopback either. - **Hosted, or unset, with no issuer refuses, naming `OPENDOX_OIDC_ISSUER`** (13.5). - `generate-and-open` asks for the issuer first (`require_the_hosted_issuer`), and then loads the whole runtime configuration. The serving process is the one whose settings are the install's (13.4a; R1Q16 (i)). - So a hosted run with nothing configured names the issuer and `--local`, not the `OPENDOX_DATABASE_URL` that `load_settings` happens to ask for first (plan 034's requirement-13 scenario 2). - `load_settings` keeps its own order for every verb that relies on it. - **Hosted is otherwise unchanged** (13.6): same broker, same pinned issuer, same order of refusals, and a hosted bind may still be `0.0.0.0`. - **The shape is resolved first.** `generate-and-open` resolves it before it scans, mints or binds anything, so each refusal exits 1 at once. That covers F13.1's `test "$rc" -ne 124`. ### Holder readings (coordinator, 2026-09-30, on openxFactory#656; Brett may overrule) 1. **`opendox-runtime runtime serve` refuses under `local`** (`refusal: local-mode-has-no-broker`). Every `/api/v1` route verifies a broker-signed token, and a local install is served by `generate-and-open --local`. In release 1 its document surface reads nothing from the store (R1Q16 (ii)). 2. **`runtime status` under `local`** reports `broker_keys: "not configured (local mode)"` and `broker_discovery: null`. It never builds a verifier, and its exit code is the database's verdict alone, so F13.1's `set -e` survives. 3. **A broker setting beside `local` is refused, naming each one**: `OPENDOX_OIDC_ISSUER`, `OPENDOX_OIDC_AUDIENCE` and `OPENDOX_OIDC_JWKS_URL`. The values are never repeated. T072 adds the two DSNs to the list. 4. **An unrecognised value** (`Local`, `single-user` …) **is refused, naming `local` and `hosted`**, and matching is case-sensitive. ### Deliberately NOT here - **T070 is the identity half; T072 is the datastore half.** Under `local`, `load_settings` still takes the DSNs from the environment, and `generate-and-open --local` loads no database setting. T072 supplies both DSNs from the bundled server (13.1) and refuses operator DSNs beside `local`. - T073's `install` block is not here. `serve.py` and `pyproject.toml` are untouched. ### Outside `src/` and `tests/` - **`deploy/` — one line, and the task requires it.** `deploy/compose/.env.example` gains `OPENDOX_INSTALL_MODE=hosted` with its comment. `config.py`'s header makes `.env.example` the place every setting is named, and `tests_runtime/test_deploy_shape.py::test_every_runtime_setting_is_documented_in_env_example` is parametrized over `SETTINGS`, so a new setting without the line is red. Neither the compose file nor the Kubernetes base changes: the default is already hosted. - **`docs/`: untouched.** 10.3's README line belongs to T076, in the openDox root. - **One existing test changes, `tests/test_doxbench_entrypoint.py`'s fixture.** It drives `cmd_generate_and_open` with no settings, and the unset default is now hosted, which refuses without an issuer. So it passes `--local` and scrubs the runtime settings first. ## The falsifier **F13.1's refusal probes**, verbatim from `# LOCAL mode REFUSES a non-loopback bind` to the end of the block, plus T070's own disagreeing pair. Two deviations are forced by the base, and neither weakens a check: - the install is the working venv's `-e ".[runtime,test]"`; - the corpus is a two-file stand-in, because `tests/fixtures/plain-documents` arrives with T050 (#53), which this stack's base (`main` `2d116415`) predates. Each probe on its own. BEFORE is #60's head `f097fd8`; AFTER is this branch: ``` === BEFORE (f097fd8) FAIL local-refuses-non-loopback-bind (rc=1): Traceback (most recent call last): File ".../src/opendox/consumer_reach.py" … FAIL hosted-no-issuer-names-it (rc=1): Traceback (most recent call last): … FAIL unset-default-refuses-identically (rc=1): Traceback (most recent call last): … FAIL disagreeing-flag-and-setting-names-both (rc=2): usage: ideation-dashboard [-h] … (argparse: no --local) === AFTER (this branch) PASS local-refuses-non-loopback-bind (rc=1) PASS hosted-no-issuer-names-it (rc=1) PASS unset-default-refuses-identically (rc=1) PASS disagreeing-flag-and-setting-names-both (rc=1) ``` The whole sequence under `set -euo pipefail` (F13.1's own form), AFTER: ``` probe 1: local refuses a non-loopback bind rc=1; stderr: generate-and-open refused: --host '0.0.0.0' is not a loopback address, and a LOCAL install binds LOOPBACK ONLY (127.0.0.1, ::1, localhost). … There is no opt-in: … probe 2: load_settings (T071's block, unchanged) probe 3: hosted with no issuer refuses naming it rc=1; stderr: generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED … probe 4: the unset default refuses identically rc=1; stderr: generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED (OPENDOX_INSTALL_MODE is unset, and unset means hosted) … probe 5 (T070's own, not F13.1's): a disagreeing flag and setting are refused naming both rc=1; stderr: generate-and-open refused: --local selects the LOCAL install and OPENDOX_INSTALL_MODE=hosted selects the HOSTED one. … F13.1 REFUSALS + T070 PAIR: ALL PASSED ``` In the suite, the same probes run as bounded child processes of `python -m opendox.cli` (`timeout=30`, and a `TimeoutExpired` fails as "a server that STARTED") in `tests/test_install_mode_entrypoint.py`. The loader-level cases are in `tests_runtime/test_install_mode.py`. ## A mutant of each new refusal, killed Each mutant was applied alone and the three T070 test files run with `-x`, with a 240 s bound so a hang could not pass for a kill: | mutant | killed by | |---|---| | M1 the disagreeing pair accepted (flag wins) | `test_a_flag_and_a_setting_that_disagree_are_refused_naming_both` | | M2 an unknown value read as hosted | `test_an_unrecognised_value_is_refused_naming_the_two[Local]` | | M3 unknown values matched case-insensitively | same, `[Local]` | | M4 the default flipped to local (the safety) | `test_the_selector_has_two_values_and_its_default_is_hosted` | | M5 the hosted issuer-first check a no-op | `test_a_hosted_install_with_no_issuer_refuses_naming_it[None]` | | M6 the local loopback refusal a no-op | `test_a_local_install_refuses_a_non_loopback_bind_naming_the_rule[0.0.0.0]` | | M7 broker settings beside local accepted | `test_a_broker_setting_beside_the_local_mode_is_refused_by_name[OPENDOX_OIDC_ISSUER]` | | M8 the local bind set widened (`127.0.0.2`) | `test_a_local_install_refuses_a_non_loopback_bind_naming_the_rule[127.0.0.2]` | | M9 local still requires the issuer | `test_a_local_install_needs_no_broker[setting]` | | M10 `runtime serve` serves under local | `test_runtime_serve_refuses_under_the_local_mode` | | M11 `runtime status` probes a broker under local | `test_runtime_status_under_the_local_mode_probes_no_broker` | | M12 `generate-and-open` ignores `--local` | `test_the_flag_refuses_a_non_loopback_bind_exactly_as_the_setting_does` | | M13 `generate-and-open` skips the install shape | `test_local_mode_refuses_a_non_loopback_bind_naming_the_rule` | M10 at first HUNG rather than failed: the un-stubbed case started a real uvicorn listener. That case now stubs uvicorn and the app, so the mutant fails at once. The table above is the re-run. ## Fix round: the fixture's repository ignores the user's git config (`32683e8`) Copilot's review at `b50e3b1` ([finding](#67 (comment))) was real. `tests/test_install_mode_entrypoint.py`'s `corpus` fixture ran `git commit` under the caller's global git configuration, so a global `commit.gpgsign=true` failed the setup before any probe ran. It now sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, as `tests/test_checkout_head.py` does. Measured with a hostile global config (`commit.gpgsign = true`, `gpg.program = /bin/false`): - at `b50e3b1`: `7 passed, 7 errors`; - at `32683e8`: `14 passed`. [Replied](#67 (comment)). The probes are unchanged. ## Fix round 2: `runtime migrate` and `reset` refuse what a local install cannot be (`525f61c`) Copilot's second review, at `32683e8`, raised two points in its overview, with no inline thread. Both are [answered on the PR](#67 (comment)). 1. **Real, and fixed.** `load_migration_settings` recorded `local` but never asked `refuse_what_a_local_install_cannot_be`. So `runtime migrate` and a confirmed `runtime reset` accepted a broker setting, or a non-loopback `OPENDOX_BIND_HOST`, beside `OPENDOX_INSTALL_MODE=local`. They now refuse at configuration, before any database is reached. - Seven new cases in `tests_runtime/test_install_mode.py`: the three broker settings × {`migrate`, `reset --confirm …`}, plus the bind. - Against `32683e8`'s config they give `7 failed`; here they pass. 2. **Pre-existing, and not changed here: `::1` passes the loopback rule, but the server cannot bind it.** `serve.build_server` is IPv4-only (`ThreadingHTTPServer`), while `serve.LOOPBACK_HOSTS` lists `::1`. - Measured at #60's head `f097fd8`, before this PR: `127.0.0.1` binds, and `::1` fails with `gaierror [Errno -9] Address family for hostname not supported`. - This PR's rule is `serve.LOOPBACK_HOSTS` itself (13.4's *"same judgement"*). So `::1` is classified exactly as the document server already classifies it, and then fails at the bind exactly as it does in hosted mode today. - The fix is a `serve.py` change (address family, and `server_url`'s brackets), outside T070's file set. It is listed below for the holder. ## Fix round 3: `status` reports a local install's broker on its early return too (`02dadc5`) Copilot's review at `525f61c` raised one point in its overview, with no inline thread. It is real, and it is [answered on the PR](#67 (comment)). - With the runtime extra absent, `runtime status` returns early, and that return said `broker_keys: "not probed"` for every install. - Local now gets the full report's answer there: `"not configured (local mode)"`, with `broker_discovery: null`. Both returns write it through one helper. - Hosted still says `"not probed"` (13.6). - The new case runs with `opendox.runtime.db` absent from `sys.modules`. `[local]` fails against `525f61c`'s `runtime/cli.py` and passes here; `[hosted]` passes in both. - Four mutants of the fix are killed. ## Fix round 4: a healthy local `status` is proven to exit 0; `RuntimeSettings`' invariants are scoped (`859b37b6`) Copilot's review at `02dadc55` opened two threads, both real. Both are answered and resolved. - [r4139922962](#67 (comment)): both local `status` cases forced a database fault, so nothing proved exit 0. - A DB-backed case now runs local `status` against a migrated schema on the suite's server and asserts `ok`, exit 0, and a broker that is not configured and never probed. - With the local return mutated to `ok=False`, the new case fails and the module's other 40 pass. Before this commit, that mutant survived. - [r4139922999](#67 (comment)): the docstring's broker statements hold for `load_settings` only, so they are now scoped to their loader. `load_migration_settings` carries the migration sentinels in either shape. This is documentation only. ## The repo's own suite Full `python -m pytest -q`, `LANG=C.UTF-8`, `CI=true`, against a `postgres:16` like `validate.yml`'s: | | selected | passed | skipped | failed | errors | |---|---|---|---|---|---| | #60's head `f097fd8` | 2485 | 2474 | 11 | 0 | 0 | | `b50e3b1` (T070) | 2531 | 2520 | 11 | 0 | 0 | | `525f61c` (fix round 2) | 2538 | 2527 | 11 | 0 | 0 | | `02dadc5` (fix round 3) | 2540 | 2529 | 11 | 0 | 0 | | `859b37b6` (fix round 4) | 2541 | 2530 | 11 | 0 | 0 | | `3185e7d` (merges #60's `adeb6fed`, which carries main `047bb4fa`), before the callers' edit | 3117 | 3102 | 11 | **4** | 0 | | `c8fac05e` (the four callers say `--local`) | 3117 | 3106 | 11 | 0 | 0 | | `cdf7382b` (merges #60's `c39d960e`; the callers inherit no runtime setting) | 3126 | 3115 | 11 | 0 | 0 | | `d1de1fd9` (the healthy-local status case sets its schema with `make_conninfo`) | 3126 | 3115 | 11 | 0 | 0 | | `105f2f12` (no exported runtime setting reaches a `tests_runtime` case), clean and with `OPENDOX_INSTALL_MODE=local` exported | 3126 | 3115 | 11 | 0 | 0 | | this branch, `25262459` (merges main `7ff434d9`: #60, #65 and #71; #71's generate-and-open child gains `--local`) | 3185 | 3174 | 11 | 0 | 0 | `026f00ea` (fix round 5) is a docstring change. The install-mode module now names its one DB-backed case ([r4146171212](#67 (comment)), resolved), and the module runs 41 passed. That is +56 cases: this PR's two new files, plus the `.env.example` census's new parameter (+46 at `b50e3b1`), fix round 2's seven, fix round 3's two, and fix round 4's one. `EXPECT_SKIPPED=11` holds exactly, and the floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`) allow the rise unchanged. ## Merge-from-main round, and what T070 owed once T056 landed (`3185e7d`, `c8fac05e`; 2026-10-02) Phase 2 has landed. #60 (T071) merged main `047bb4fa` as `adeb6fed`, and `3185e7d` merges that head here. - **The merge itself.** Git auto-merges `src/opendox/cli.py` and `tests/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`. - **The edits T070 owed.** Since this PR, an unflagged `generate-and-open` is HOSTED and refuses without its broker's issuer. Four cases that landed with phase 2 relied on the old default, and all four failed on the merged tree with that refusal. Each runs the single-user install, so each now passes `--local` (`c8fac05e`). None means hosted, so none takes a hosted fixture. 1. **`tests/test_standalone_generate_path.py`** (T056), case 3, `test_generate_and_open_starts_a_server_that_answers_with_no_sibling`. Its module docstring now names the change and moves F10.1's plain-install run to T077. 2. **`tests/test_post_render_validator.py`** (T058), `test_generate_and_open_gives_the_same_verdicts`, both fixtures. 3. **`tests/test_projection_seams.py`** (T055), `test_generate_and_open_refuses_an_empty_source_option_before_its_run_dir`. - **The stand-ins in `tests_runtime/local_entrypoint_driver.py`** go too, but that driver is T072's file and does not exist on this branch. Its edit is named in #69's merge round. - **Mutants** of the local path, each failing all four cases: `--local` ignored; local refusing its own loopback default; local also asking for the hosted issuer. - **Full suite:** `3117 selected, 3106 passed, 11 skipped, 0 failed`. ## Fix round: the `--local` callers inherit none of the runner's runtime settings (`9ae5e72`, `cdf7382b`) `9ae5e72` merges #60's fix round `c39d960e` (a PostgreSQL scheme libpq would not read as a URI is refused), cleanly. Copilot's review at `c8fac05e` opened three threads, all real and all now answered and resolved. A `--local` caller took the runner's environment along, so an exported `OPENDOX_INSTALL_MODE=hosted` or broker issuer made it refuse before it reached what it tests. - **`tests/standalone_child.py`:** every child's environment drops each name in `opendox.runtime.config.SETTING_NAMES`. - **`tests/test_projection_seams.py`:** the in-process empty-option case scrubs them first, as the doxBench entrypoint fixture does. - **A harness case** exports a hosted install's four settings and asserts that a child sees none of them. Evidence: - Under exported hosted settings, all 5 cases fail against `9ae5e72` and pass here. - Both mutants are killed. - Full suite: `3126 selected, 3115 passed, 11 skipped, 0 failed`. ## Fix round: the healthy-local status case reads either DSN form (`d1de1fd9`) Copilot's review at `cdf7382b` opened one thread, which is real and is now answered and resolved. `OPENDOX_TEST_DATABASE_URL` may be libpq's keyword/value form as well as a URI. `test_runtime_status_of_a_healthy_local_install_exits_zero` appended `?options=…` to it. On the keyword/value form that suffix becomes part of `dbname`, so the case failed while the fixtures connected (measured). The case now sets `options` with `psycopg.conninfo.make_conninfo`, which works on either form. The runtime's `schema_selected_by` reads the schema back out of the result on both forms. Evidence: - **Both forms:** `tests_runtime/test_install_mode.py` passes whole under each. - **Mutant:** dropping the `search_path` option is killed on both forms. - **Full suite:** `3126 selected, 3115 passed, 11 skipped, 0 failed`. The same suffix spelling already exists in main's `tests_runtime/test_migrations_apply.py`. It is not this PR's code, so it is left out of scope. ## Fix round: no runtime setting the runner exports reaches a `tests_runtime` case (`105f2f12`) Copilot's review at `d1de1fd9` opened one thread, which is real and is now answered and resolved. Since this PR the runtime reads `OPENDOX_INSTALL_MODE`. A runner that exported `local` made every hosted case that leaves it unset a configuration refusal: 20 cases, measured. `tests_runtime/conftest.py` gains an autouse fixture that clears every `SETTING_NAMES` entry before each case; `OPENDOX_TEST_DATABASE_URL` is kept, and the production refusal is unchanged. Evidence: - **Full suite**, with a local mode and a broker issuer and audience exported and in a clean environment alike: `3126 selected, 3115 passed, 11 skipped, 0 failed`. - **Mutant:** a scrub that clears nothing is killed. ## Merge of main after #60 landed (`25262459`; 2026-10-02) #60 (T071) landed as `7ff434d9`, after #65 (T088) and #71 (T085). `25262459` merges that main. - **`src/opendox/runtime/config.py`'s four conflicts.** Main's side of each is #60's squash, whose `config.py` is byte-identical to `c39d960e`, which this branch already carried (`git diff c39d960 origin/main` on that file is empty). So each resolves to this branch's side: T071 plus T070's install mode. - **One edit inside the merge commit, because of T070.** #71's `tests/test_doxbench_defaults.py::test_the_served_catalog_route_answers_from_generate_and_open` runs `python -m opendox.cli generate-and-open` as a child. Unflagged, that is now HOSTED, and with no issuer it refused: `generate-and-open refused: OPENDOX_OIDC_ISSUER is required and is not set, and this install is HOSTED`. It now passes `--local`, the single-user install the case means, with a comment saying why. - **#65's real-serve lens test needs nothing.** `tests/test_lens_seed_actions.py` runs `cli.main(["generate", …])` and `serve.build_server`, never `generate-and-open`, so T070 does not reach it. It passes unchanged. - **#74 is not on main yet.** Its `tests/test_chat_model_configuration.py` fixtures will need the same `--local` when it lands after this PR. - **Full suite:** `3185 selected, 3174 passed, 11 skipped, 0 failed`. ## Downstream, for the holder - **`serve.py` cannot bind `::1`** (pre-existing, measured at `f097fd8`; fix round 2, item 2). `LOOPBACK_HOSTS` lists `::1`, so `--local --host ::1` passes the loopback rule and then fails at the bind with `gaierror`, as `--host ::1` already does in hosted mode. The fix makes the serve path IPv6-aware, which is a follow-up in `serve.py`'s single-writer order. Until then, `::1` could instead be dropped from `LOOPBACK_HOSTS`. That is the holder's call; this PR does neither. - **The help-tree golden in openxFactory changes at the phase-3 pin.** `tests/ideation-dashboard/fixtures/cli-help-tree.golden.txt` (read by `test_extension_point_parity.py`) snapshots `generate-and-open`'s options, and `--local` is a new one. T094's consumer-pin PR regenerates it. openXdox-code's help-tree case (T042/T008) may need the same. - **T056 landed before T070**, as the holder ordered. Its edits are done and named in the merge round above. - **Overlap** (`gh pr diff -R opensoft/openDox-code`): `src/opendox/cli.py` is also rewritten by #59 (T055, +260/−112), in `cmd_generate_and_open` and `build_parser`. `cli.py`'s single-writer order is T055 → T058 → T070. #57, #58 and #59 have landed, and this stack took its merge-from-main round after them. #57 and #61–#64 do not touch these files. 🤖 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>
openDox-code main landed: - T085 (#71, 2680eb5); - T088 (#65, d0d3ceed); - T071 (#60, 7ff434d); - T078 (#61, 8a98e31). Merged on the holder's word, so T084, stacked on this branch, takes main through it. None of the four touches a file of T073's own: src/opendox/serve.py's install arm, src/opendox/cli.py's `_install_report`, tests/test_served_install_block.py, and tests_runtime/test_bundled_postgres.py's T073 case. ONE CONFLICT, in src/opendox/runtime/config.py, stack A's file. It is resolved to this branch's side, because main's config.py is byte-identical to #60's head c39d960, which this branch already holds through #67 and #69. Main's version is a subset of this side's. ONE SEMANTIC CONFLICT, in T085's tests/test_doxbench_defaults.py. `test_the_served_catalog_route_answers_from_generate_and_open` runs `generate-and-open` with neither `--local` nor `OPENDOX_INSTALL_MODE`. Since T070 (#67) that is a HOSTED install, which refuses without its broker's settings, and standalone_child strips every OPENDOX_* setting. The case now passes `--local`, as tests/test_standalone_generate_path.py's local case does. #67's own merge of main meets the same line. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T073 merged two things. #69's round 13 (28e195b): server_binaries finds pixeltable-pgserver as the installed distribution's own files. And main 8a98e31: T085 (#71), T088 (#65), T071 (#60) and T078 (#61). T084 takes main through T073, so its own diff against #72 stays T084's. CONFLICTS, as measured before (the trial merge at 897029d): - src/opendox/cli.py and src/opendox/serve.py, at the four `register_defaults()` call sites and one import, where T085's lines and T084's sit side by side. Both are kept, T085's `doxbench_defaults` first and T084's `column_seams` after it. cli.build_parser's own conflict also kept T084's neutral parser (`prog=PROG`). T084'S OWN TESTS AFTER T085, folded in here. T082's writer prepared the patch (t084-turns-after-t085.patch). Checked, not taken blind: without it the two standalone turn cases fail with 400 `invalid_turn_request`. - The cases' turns carry `last_assistant_turn_id`, which the released schema requires, and `_structured()` accepts the released `workbench-chat-turn-v2-failure` envelope (error, no `ok`). - Now that the standalone plane has openDox's own validators, the two standalone turns are narrowed as promised (holder-accepted). tests/test_neutral_turn_scope.py case 4: a turn over the tile's own document reaches the model step, `model_unavailable`. tests/test_capability_honesty.py's turn with a binding configured reaches the scope step, `turn_scope_refused`. T088 (#65), THE RULED ONE-LINE EDIT. tests/test_lens_seed_actions.py's standalone serve now asserts `actions.gate is False` in both runs, and the identity on the resolved `actor` field. Since this task the gate flag needs a contributed gate route. The whole suite: 3222 passed, 177 skipped. F4.1 prints "no deferred reach names the consumer or the publisher". Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034, phase-3 slice **P3-B**, task **T079**. The plan was read at openxFactory `main` `e369cb25`, from `specs/034-opendox-standalone-operation/tasks.md`, and read again at `main` `2140f5a7` and, after T063 landed, at `main` `a883bbf6`: T079's entry is unchanged at both. The slice is claimed on openxFactory#656, comment `5875729625`. **This was drafted ahead, and T063 has now landed.** Brett's phase-3 word, as the holder recorded it (2026-09-28, ~18:00Z), was *"Only the independent ones (Recommended)"*. T079 is `After: T078`, and every phase-3 task comes after T063. T063 landed as openxFactory#1218 → `a883bbf6`, which closes phase 2, and T078 landed as #61 → `8a98e317`. So this PR may go READY. Its READY line is the holder's to post. **One PR per task, stacked.** T078 (#61) has landed, and the holder retargeted this PR to `main`. T080 is based on this one (#63 (`build/034-p3b-t080-raw-key-refusal`)). Land this, then T080. Retarget #63 to `main` before this branch is deleted, because a merge with `--delete-branch` closes the PR stacked on it. ## What changes - The binding record gains **`model`**: the model name the provider receives as the request's `model`, in either dialect. It sits after `dialect`, with the route it belongs to, so `BINDING_FIELDS` grows from nine fields to ten. Still no field can hold a secret. - `model-binding add|edit --model` sets it, and `model-binding list` shows it. - The catalog handle stays the binding's `id`, so a chosen menu entry still resolves back to its binding. `model` is not an argv placeholder, and `ARGV_PLACEHOLDERS` is unchanged. Files: `src/opendox/doxbench_binding.py`, `src/opendox/doxbench_provider.py`, `src/opendox/cli_model_binding.py` and `tests/test_model_provider_broker.py`, all inside P3-B's row. One docstring outside the row, `src/opendox/doxbench_intake.py`'s, is corrected and flagged below. **Realizes**: 16.2. **Ruled**: R1Q22 (a), `5817152735`. ## One choice the plan's text leaves open, which Brett let stand Brett's word, relayed by the holder on 2026-09-28: `model` stays optional. **`model` is keyword-only, and it defaults to None, meaning "none declared".** A binding that declares none keeps the meaning it had: the request names the catalog handle, the binding's `id`. Tests pin it: the prompt grammar's request bytes (T078's test, unchanged here), and the handle as the model in both grammars. A stored record may leave `model` out (`OPTIONAL_BINDING_FIELDS`), and every other field stays required. Three things decided it: 1. `serve_workbench.py`'s console intake route builds a `ModelProviderBinding` by keyword, with no model. A required field would break it, and that file belongs to T055 (openDox-code#59, since landed) and T084, not to P3-B. It keeps working unedited. Every existing test in this file builds its binding the same way, by keyword with no model, and all of them pass. 2. Every stored nine-field document still reads, and a test holds it. 3. openXdox-code's `tests/test_doxchat_model_intake.py:1234` builds a nine-field binding by keyword. It keeps working at T086's pin. A **required** model would have had to move `serve_workbench.py`'s intake route, and a field in its view, in the same act. That would have needed a plan note, not a quiet widening of this slice. **A consequence, flagged for the owner of `serve_workbench.py`**: the console's intake flow cannot set a model yet, because its closed `INTAKE_QUERY_FIELDS` has no `model`. So an `openai-chat-v1` binding enrolled through the console names its id as the model until that flow learns the field. `model-binding add --model` is the door that sets it today. ## The falsifier: F16.1's `BINDING_FIELDS` assertion This is F16.1's record block, verbatim from #1144 (the same extract as T078's PR, sha256 `8af3001b5f5fb885…`, unchanged at openxFactory `main` `2140f5a7`): ```python assert "openai-chat-v1" in b.DIALECTS, f"no OpenAI-compatible dialect: {b.DIALECTS}" assert "model" in b.BINDING_FIELDS, f"the record names no model: {b.BINDING_FIELDS}" rec = {f: "stand-in" for f in b.BINDING_FIELDS} ... b.ModelProviderBinding.from_record(rec) # the control: a clean record is accepted ``` At T078's head `d07b9371` it stops at the `BINDING_FIELDS` assertion (`the record names no model: (… 'dialect', 'broker_argv')`). At this head `e7f3a7b3` both assertions pass, and so does the control record (ten fields, `model: "stand-in"`). The block then stops at its first keyed URL, which is T080's to refuse: ``` FAIL: a raw key was accepted in ['endpoint'] ``` The named test `test_f16_1_the_record_names_a_model` asserts it as #1144 writes it, and pins the ten-field order. ## The repository's own suite Local runs of the whole suite, as CI runs it (`python -m pytest -q`), use a PostgreSQL 16 service for `tests_runtime` and `LANG=C.UTF-8`, as on the runner: | tree | passed | skipped | failed | |---|---|---|---| | `main` `8a98e317` (T078 landed) | 3138 | 11 | 0 | | this head `e7f3a7b3` | 3156 | 11 | 0 | `main` `8a98e317`'s row was run on a trial merge of T078's head `d07b9371` into `main` `7ff434d9`, and that merge's tree is `8a98e317`'s, byte for byte. Before this round, `main` `047bb4fa`, T078's `d07b9371` and this branch's `b04a3a95` read 3044, 3065 and 3083. Before phase 2 landed, the same three read 2468 (`2d116415`), 2489 (`6e1b8942`) and 2507 (`053e207a`). CI's `validate` at this head (run `37081644588`) reads `selected=3224 passed=3213 skipped=11 failures=0 errors=0`, against the pins 2476, 2465 and exactly 11. That run checked out GitHub's merge of this head into `main` `66ff7257`. T070 (#67) landed there after this round's merge, so the run counts T070's cases too. At the merge `cb059d5d` (run `37080549094`) it read `selected=3167 passed=3156 skipped=11 failures=0 errors=0`. - The +18 cases are in `tests/test_model_provider_broker.py`. Skipped stays at the pinned 11. - `tests/test_provider_boundary.py`: 24 passed. - `validate.yml` is outside this slice, and its floors permit the rise. ## Merging `main` (`cb059d5d`) `cb059d5d` merges openDox-code `main` `8a98e317`. That is T078's squash (#61), on top of T085 (#71), T088 (#65) and T071 (#60), which landed just before it. This branch carried T078's own commits, so the three files T078 changed met its squash on `main`: - `main`'s copy of each equals T078's head `d07b9371`. - Two of them, `src/opendox/doxbench_provider.py` and `tests/test_model_provider_broker.py`, conflicted only where this branch changes T078's lines. Each is resolved to this branch's side. - So the merge adds exactly what `main` gained beyond T078. `git diff b04a3a9 cb059d5` is `git diff d07b937 8a98e31`: 17 files, none of them this PR's. The suite above is this merge's, with `e7f3a7b3`'s docstring fix on top. Since then, `main` has moved to `66ff7257` (T070, #67). This head merges into it cleanly, and CI's run above is that merge. Before this round, `b04a3a95` merged T078's head `d07b9371`, which merged `main` `047bb4fa` (phase 2's nine landings), with no conflict. ## Review remarks, answered at `b04a3a95` - **Copilot's overview** (review `5343385726`, at `7c83c2cd`) noted that *"intake documentation still contradicts the expanded binding contract"*. It did. `doxbench_intake.py`'s module docstring called the binding a *"closed nine-field record"* and its declaration *"not a tenth field on it"*. `053e207a` names each count by its tuple, records that the binding grew once, by `model`, and reads *"not a field on it"*. It is a docstring only. - **Flagged**: `src/opendox/doxbench_intake.py` is outside P3-B's row. No plan-034 task names the file. When it was checked, no open PR touched it: #53, #54 and #56 to #60 then, and #60, #65, #67 and #69 again at `b04a3a95`, and phase 2's landings did not touch it either. At `cb059d5d`, `main`'s later landings do not touch it, and two open PRs do: #63, stacked on this one, and T082's #76, which adds 14 lines to it. A trial merge of this head with #76's head `dcdb7554` is clean. If the holder wants that edit made elsewhere, reverting its two commits, `053e207a` and `e7f3a7b3`, changes no behaviour. - **SonarCloud** reported no findings on this PR. - **Copilot's re-review run at `053e207a`** (Actions run `36473415338`) finished without posting a review, and its log records no stored comment. - `a63dcb8b` merges T078's SonarCloud test fix into this branch, so the stack stays one line. - **Copilot at `b04a3a95`**, the merge of `main`: two reviews, both *Needs a closer look* with `Findings: None` and no thread. - The first came with the push (review `5396324583`). - The second was asked for through the reviewer API (review `5396377423`). - Both summaries ask for a final human review of a change across persistence, provider behaviour and the CLI. The second also names the stacked dependencies, which are the draft flag at the top. - **SonarCloud at `b04a3a95`**: 0 issues, quality gate OK. - **Copilot at `cb059d5d`**, the merge of `main` `8a98e317` (review `5398043432`, asked for through the reviewer API): *Needs a closer look*, one low-severity finding. The docstring's *"the catalog's grew"* and *"the binding's grew"* read as possessives used as verbs. Each stood for the shape it named, and the review shows that a reader can miss that, so `e7f3a7b3` names the noun: *"the catalog entry's shape"* and *"the binding's record"*. It is a docstring only. The thread is answered (`4170880327`) and resolved. - **Copilot at `e7f3a7b3`** (review `5398096928`, asked for through the reviewer API): *Needs a closer look*, `Findings: None`, and no thread. Its summary names the change's reach across persistence, provider behaviour, the CLI and the stacked dependencies. - **SonarCloud at `e7f3a7b3`**: 0 issues, quality gate OK. ## For the holder - **Downstream, not edited here**: openxFactory's `cli-help-tree.golden.txt` gains `[--model MODEL]` in the `model-binding add|edit` usage at T094's pin. 🤖 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>
T079's branch now carries openDox-code main 8a98e31: T078's squash (#61), with T085 (#71), T088 (#65) and T071 (#60) before it. None of those landings changes a file this branch changes, and the merge has no conflict. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plan 034) (#74) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability **Phase 3. T063 has landed** (openxFactory#1218 → `a883bbf6`), so phase 2 is closed. This PR was drafted ahead of that under Brett Heap's word of 2026-10-02 (`openxFactory#656` comment `5960162524`, *"Draft all of them now (Recommended)"*). The holder posts READY, and the landers merge. Claimed in `#656` comment `5960438561` (P3-D: T085, then T081). **T085 has landed**: openDox-code#71 → `2680eb5e`. This PR was stacked on it because the served catalog route answers standalone only with T085's validators. The holder retargeted it to `main`. `9551f20d` merges `main` at `7ff434d9`, which carries #71, T088 (#65) and T071 (#60). The merge was clean, and against `main` this PR's diff is T081's seven files alone. Plan 034's T081 (`specs/034-opendox-standalone-operation/tasks.md`, read at openxFactory `main` `2140f5a7`) realizes #1144's **16.4**: *"No model configured" is a STATE, shown before any turn.* ## Rulings - R1Q22 (a), `5817152735`. - R1Q10 (a) and R1Q12 (a), `5850003126`. - The holder's answers to this writer, 2026-10-02: - **Q2 (the tick rule).** T085's falsifier holds whole only here: the served catalog route answers standalone with no available entry. **Plan 034's T085 box is ticked at this PR's landing**, with T081's. - **Q3.** `doxbench_install` answers openDox's no-model port when there is no approved binding and no harness. `_workbench_model_port()` answers that port as absent. - **Model approval.** It is not on the standalone path. T084 makes the approval route refuse by name standalone. This PR does NOT rely on the console's approval route. See "Binding states" below. - **The hoist, RULED option (a).** Commit `0a12dc58` stays. The holder's three conditions are met below: the console verdict stays first (file:line under "The turn route"), there is one ordering test, and the remaining window is named. - **`tests/test_model_provider_broker.py`.** The `harness_present=` probe is accepted as written. Whichever of this PR and #61–#64 lands second merges `main`. - **Landing plan, once T063 is in.** #71 and this PR land back to back. Before #71 lands, the holder retargets this PR to `main`, because the lander's `--delete-branch` would close a PR stacked on #71's branch. ## What changes - **`src/opendox/doxbench_model.py`**: - `NoModelConfigured`, with one instance, `NO_MODEL_CONFIGURED`. It sits beside `EMPTY_CATALOG` and the `WorkbenchModelPort` protocol it implements. Its `catalog()` is `EMPTY_CATALOG`, and `dispatch()` raises `NoModelConfiguredError` without spawning or contacting anything. - `NO_MODEL_CONFIGURED_REMEDY` says how to configure a model. - **`src/opendox/doxbench_install.py`**: - `harness_installed()`: is `doxbench_bridge.HARNESS_COMMAND` (`omp`) on the PATH, the precondition F16.1 itself checks with `command -v omp`. It spawns nothing. - `no_model_port_factory()`. - `declared_model_port_factory(..., harness_present=None)`. With no approved binding, it answers the harness bridge where the harness is installed (**the harness route stays**), and the no-model port where it is not. The probe defaults to `harness_installed`. - The module docstring's "the unconfigured posture is unchanged byte for byte" is now false, so it states the new rule. The unreadable-document notice no longer claims a harness fallback. - **`src/opendox/serve_workbench.py`**: - `_workbench_model_port()` answers `NO_MODEL_CONFIGURED`, by identity, as no port. So `GET /workbench/model-catalog` serves the editor-only posture, and a turn or an abstract that reaches its model step is refused `model_capability_unavailable`. This is the holder's Q3 edit. - The hoist in `0a12dc58`, described below. - **`src/opendox/web/views/doxbench-chat.js`**: a new VISIBLE line, `doxchat-no-model`, whose text is `NO_MODEL_CONFIGURED_REMEDY`: > No model configured. To configure one, declare a model binding with "opendox model-binding add" ("--help" lists its fields), or put the local harness "omp" on PATH, then restart this console. It shows only once the catalog has ANSWERED **empty**, with no catalog failure recorded and no intake option rendered. - **Empty, not "nothing available"** (Copilot, round 1). A configured model can be unavailable: after a broker refusal, `BrokeredProviderPort.catalog()` keeps the binding with `available: false`. That operator has a model configured, so the line stays hidden, and the send button's configured-none sentence still states the posture. The server's no-model port serves exactly the empty catalog. - **Announced once** (Copilot, round 1). The catalog settles asynchronously with no focus change, so `render()` also writes the remedy to the rail's polite `announce` region. It does so only when the text changes, behind the same guard the context and retry notes keep, so a keystroke does not re-announce it. - **Disclosed limit.** Where the shell's intake-surface read settles AFTER the catalog, the line shows and is announced once, and is then hidden when the offer arrives. The two reads are independent fetches, and the rail cannot know whether a shell will ever push an offer. Where the offer arrives first, nothing is shown or announced. - **The send button's configured-none sentence is unchanged, byte for byte.** The ratified ideation-dashboard requirement keeps it: add-doxchat-model-intake §1, *"the rail's existing sentence continues to state that no approved model is configured"*, whose scenario reads *"the existing no-approved-model sentence unchanged"*. - What that sentence never named is HOW to configure a model (plan 034's research R15). So this is a separate line, not a rewrite. - Where the intake flow is offered, the selector's first option is the remedy's home, and the line stays hidden, since the intake requirement refuses a "second, weaker statement". - **Its stated limit** (RULED by the holder, 2026-10-03, option (a), on Copilot's r4170956940): - The remedy names openDox's own ways to configure a model. Every openDox entry point declares `doxbench_install.declared_model_port_factory`, whose empty catalog means exactly no binding and no harness. - An embedder that injects its own port supplies its own intake offer, and where intake is offered the line is hidden. - The server cannot carry a model posture within the released contract. `xfactory-workbench-model-catalog` is closed (`additionalProperties: false`), and #1144's 16.5 (T082) holds `/capabilities` and the intake surface equal with and without a model. - The comment at the check in `noModelConfiguredRemedy` says so. - The web census moves `views/doxbench-chat.js` loc from 1828 to 1890, and the class-A total from 18073 to 18135. - **`tests/test_chat_model_configuration.py`** (new, 49 cases): - F16.1's catalog block, word for word; - each binding state; - the harness route, and the default probe; - the port and the accessor; - the served catalog standalone, with no available entry; - the standalone turn, refused `console_required` without the console token, then `model_capability_unavailable` with it; - **the turn route's order**, 30 cases (10 defects by 3 postures), described under "The turn route"; - the rail, run over node, in each state, plus the pure verdict, and the announce region's writes; - the remedy's two spellings, held together. - **`tests/test_model_provider_broker.py`**: three cases pinned the harness fallback. Each now passes `harness_present=lambda: True` and keeps its assertion: - the yaml-missing case (`:322-386`); - `test_a_checkout_with_no_bindings_resolves_exactly_the_harness_declaration` (`:1159`); - `test_an_unreadable_bindings_document_falls_back_and_says_so` (`:1187`). - **This file is also edited by #61–#64.** Their hunks sit around the fixed-diagnostics test (`:1111`) and the helpers, not at these lines. The holder accepted the probe as written: whichever of T081 and #61–#64 lands second merges `main`. `doxbench_binding.py` and `doxbench_provider.py` are untouched here. ## Binding states the port reads (as the holder asked, stated and not changed) `declared_model_port_factory`, at `doxbench_install.py:324-327` at `047bb4fa`, reads the bindings document and the intake declarations document: - **No declaration, from the CLI or a hand edit: present.** `opendox model-binding add` writes the bindings document alone, and never the declarations document, so such a binding is never `pending`. That is how a standalone user configures a model with no console approval route. - **A declaration `approved`: present.** It is unreachable standalone, because approval goes through the console route, which T084 makes refuse by name standalone. - **A declaration `pending`: suppressed.** It contributes no available entry, and stderr says so. - **An unreadable bindings document: read as declaring none.** - With no binding present: the harness bridge if `omp` is on the PATH, else the no-model port. This is what changes here. - **Pre-existing, and not changed here:** - the port uses only the FIRST approved binding (`approved[0]`, `:327`); - a declaration's `expires_at` is not enforced; - every binding record still needs `broker_argv` until T080's auth kind `none` and the built-in resolver. ## The turn route (`0a12dc58`, RULED option (a)) Measured with a schema-valid v2 turn (openDox-spec's `workbench-chat-turn-v2-loaded-set` example), from a standalone `generate-and-open` child, siblings refused, console token presented: - **`main` `047bb4fa`**: `403 model_capability_unavailable`. That is an accident: no validators were registered, so the validators refusal, hoisted above the kind check, answered. - **T085's head (#71 at `e0298cf4`)**: the connection drops. T085's validators answer, so the turn reaches step 5's `from openxdox import doxbench_scope` (`serve_workbench.py:1669` at #71's head, T084's `:1665` reach at `main`), and the child logs `ModuleNotFoundError: No module named 'openxdox'`. - **This PR's first commit alone** does not change that. Step 7's model check comes after step 5. - **`0a12dc58`** runs step 7's no-port refusal (the same `_refuse_turn` call, code and envelope) just above step 5. A plane with no model configured, or no model factory, refuses a well-formed turn before anything is spawned or contacted. Idempotency (step 8) stays after the model verdict, as it was. **What still comes first, at `9061b22a` (`serve_workbench.py`, `_handle_workbench_chat_turn` at `:1531`).** Everything that authenticates the request precedes the hoisted refusal, so an unauthenticated caller learns nothing about model availability: 1. `:1536`, the plane: loopback, the `session` capability, and a resolved actor. This is pre-existing. It answers `model_capability_unavailable` in the fixed pre-identity shape whatever the model posture, so it reveals nothing about the posture. 2. `:1542`, the console verdict, `_not_the_human_console()`: the console token, the Host, the JSON or token-bearing submission, and the origin. It answers `console_required`. 3. `:1550`, the body bound, then the not-an-object refusal. 4. `:1607`, the validators; `:1623`, the kind; `:1630`, the schema; `:1643`, the parse. 5. `:1690`, **the hoisted model verdict**. 6. `:1698`, step 5's scope import, then step 6 (`:1791`), step 7 (`:1855`) and step 8 (`:1940`). **The ordering test** is `test_the_turn_routes_order_with_and_without_a_port`. It drives `_handle_workbench_chat_turn` itself, over openDox's real validators (T085) and real identity checks. Only the HTTP plumbing (the console verdict, the bounded body read, the reply) and openxdox's scope module, which openDox's suite does not install, are stood in. It has 10 defects across 3 postures (a port; no model configured; no factory): - A console, body-bound, not-an-object, kind, schema or parse defect answers first in every posture. - With no port, the no-model refusal answers before a scope, identity or limits defect. The limits defect is a document of 400 001 bytes, within the schema's 1 MiB `maxLength`. The scope registry is never read. - With a port, every defect answers what it answered before the hoist: `turn_scope_refused`, `content_identity_mismatch`, `request_limit_exceeded`. A well-formed turn reaches step 7 and is refused `model_unavailable` by a port whose catalog offers nothing. **The remaining window.** With a binding configured (or `omp` on the PATH), a standalone turn still drops at step 5's import, `serve_workbench.py:1698` at `9061b22a` (`:1669` at #71's head `83213eb2`), until T084 routes it (`:1665` at `main`). The holder has told T084's writer to cover the turn route in its test. The hoist sits just above that import, so whichever of this PR and T084 lands second merges `main`. ## Falsifier, failing before and passing after T081's falsifier is F16.1's catalog block and `tests/test_chat_model_configuration.py`. **F16.1's 16.4 block, in a fresh venv** (`python3 -m venv --clear "$W/v16"`, then `pip install ".[test]"`). It first asserts that neither sibling imports and that no `omp` is on the PATH, and then runs the block verbatim: - at T085's head `83213eb2`: `AssertionError: no model is configured, yet the catalog offers ['omp-local']`; - at `9061b22a`: `no model configured: the catalog offers nothing`. The named test was then run in the same venv: `47 passed` at `9061b22a` (`16 passed` at round 1's `38a4bba9`). **`tests/test_chat_model_configuration.py`, run over T085's head `83213eb2`** (worktree, `PYTHONPATH=<worktree>/src`, `LANG=C.UTF-8`): `13 failed, 4 errors in 1.38s`. Among them: ``` E AssertionError: no model is configured, yet the catalog offers ['omp-local'] E AssertionError: assert [{'available'... model', ...}] == [] (the served catalog) E http.client.RemoteDisconnected: Remote end closed connection without response (the turn) E AssertionError: ['openxdox'] (the turn's refused import) ``` **The ordering test over `ef0c5546`** (this PR's first commit, before the hoist; same worktree method): 8 of its 30 cases fail, exactly the no-port postures for the scope, identity, limits and well-formed turns. The standalone turn case fails too: ``` FAILED ...test_the_turn_routes_order_with_and_without_a_port[no model configured-identity] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no model configured-limits] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no model configured-none] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no model configured-scope] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no factory-identity] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no factory-limits] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no factory-none] FAILED ...test_the_turn_routes_order_with_and_without_a_port[no factory-scope] FAILED ...test_a_turn_is_refused_model_capability_unavailable 9 failed, 22 passed, 1 error in 1.04s ``` **The round-2 rail cases over `38a4bba9`'s rail** (the file reverted, then restored with its digest checked): `3 failed, 14 passed`. The unavailable-only rail showed the remedy, the region was never written (`assert 0 == 1`), and the pure verdict answered the remedy for `[OFF]`. **At `9061b22a`: `47 passed`.** ## Mutants Applied one at a time by a harness that restores each file and checks its digest. **24 of 24 are killed** (`runs/mutants-t081-r4.txt`), plus P01 to P04 of the test helper (`runs/mutants-t081-r3.txt`): - **N01**: the declaration ignores the harness probe. - **N02, N03**: `harness_installed()` always answers True, or always False. - **N04**: the accessor hands the no-model port back. - **N05**: the accessor treats any `NoModelConfigured` as absence (imitation). - **N06**: the no-model catalog offers the harness entry. - **N07**: `dispatch` answers instead of refusing. - **N08, N09, N10, N16**: the rail shows the remedy where intake is offered, on a recorded catalog failure, while loading, or with a non-empty catalog. - **N11**: the rail never shows it. - **N12**: the JS spelling drifts from the Python twin. - **N13**: the rail replaces the configured-none sentence. - **N14, N20**: the turn's model verdict is not hoisted. N20 is killed by the ordering test alone. - **N15**: a pending declaration is not suppressed. - **N17**: the remedy shows for a catalog of unavailable models (round 1's check). - **N18**: the remedy is not announced. - **N19**: the remedy is announced on every render, with no change guard. - **N21**: the hoist moves above the kind, schema and parse checks. - **N22**: the hoist moves above the console verdict. - **N23**: the hoist refuses with a port configured too. - **N24**: step 7 resolves the model port a second time. - **P01 to P04**: the test PATH helper drops a directory holding `omp` whole, keeps `omp` in the mirror, moves the mirror out of the directory's place, or links to relative targets. N09 first SURVIVED the mounted cases alone, because a mount that fails its catalog never adopts one. `38a4bba9` adds a probe of the exported verdict over a failure recorded beside an adopted empty catalog, and that kills it. ## The whole suite All runs were local, in the foreground, with `LANG=C.UTF-8` and no PostgreSQL service, so the runtime cases skip here. - T085's head: `2924 passed, 177 skipped`. - Round 1, `38a4bba9`: `2940 passed, 177 skipped`. Its inventory differs from T085's only by the 16 new cases. - `9061b22a`: `2971 passed, 177 skipped`. Against round 1, 32 cases are added (the 30 ordering cases, the unavailable-is-not-no-model case and the announce case) and 1 is removed (the round-1 unavailable case, renamed and flipped). No other case changes status. The skip sets are equal node for node. ## Review rounds - **Copilot at `38a4bba9`**, two findings, both fixed in `9061b22a`, answered, and resolved: - r4169715519: limit the remedy to an empty catalog; - r4169715599: announce it through the polite region, behind a change guard. Copilot was re-requested. - **CI at `38a4bba9`**: `validate` green, `triple: selected=3117 passed=3106 skipped=11`. SonarCloud passed. - **Copilot at `9061b22a`**, one finding (r4169897199). The description described round 1: 16 cases and the round-1 census. Its review began before this description was refreshed. Every count is now labelled with the revision it was measured at. - **CI at `9061b22a`** (run `37065083051`): `validate` green, `triple: selected=3148 passed=3137 skipped=11 failures=0 errors=0`. That is 31 more selected than `38a4bba9`, the net of this round's cases. SonarCloud passed. - **`9551f20d`, the merge of `main` `7ff434d9`** after #71 landed: no conflict, and the whole suite, run locally, gives `2998 passed, 177 skipped`. CI (run `37079669644`): `validate` green, `triple: selected=3175 passed=3164 skipped=11 failures=0 errors=0`. - **Copilot at `9551f20d`**, one finding (r4170794383), fixed in `4ef7575a`, answered and resolved. The tests' `_no_omp_path()` dropped a whole PATH directory that held `omp`, and with it any `git` beside the harness. It now mirrors that directory without `omp`, in place. `test_a_path_without_omp_keeps_every_other_command` holds that, and three mutants of it are killed. CI at `4ef7575a` (run `37080287448`): `selected=3197 passed=3186 skipped=11`. - **Merges of `main` as the stack landed**, each clean: - `f6e777ef` merges T078 (#61). Its CI run, `37080863326`, ran against a `main` that already carried T070 (#67), so its two served-route cases errored on the hosted-mode refusal. This was fixed in the next commit. - `8104fa6e` merges T070 (#67). The `standalone` fixture now runs `generate-and-open --local`, as #71's case on `main` does. CI run `37081223225`: `selected=3254 passed=3243 skipped=11`. - `fd7cccd7` merges T079 (#62). CI run `37082713077`: `selected=3273 passed=3262 skipped=11`. - Locally, the whole suite on `fd7cccd7` gives `3095 passed, 178 skipped`. - **Copilot at `8104fa6e`**, one finding (r4170839174), fixed in `b930af49`: the PATH mirror now links to absolute targets, so a relative PATH entry keeps its commands. `test_a_relative_path_entry_without_omp_keeps_its_commands` holds this, and mutant P04 is killed. CI run `37081588647`: green. - **Copilot at `8104fa6e`**, a second finding (r4170882125), fixed in `7b1ecbe2`: the model port is resolved once per turn. The hoisted verdict binds `port`, and step 7 reads it. The ordering test counts the factory's calls in all 30 cases: once where the turn reaches the model verdict, and never otherwise. Mutant N24, step 7 resolving the port again, is killed, so 24 of 24 are killed. CI run `37082406902`: green. - **Copilot at `fd7cccd7`**, one finding (r4170956940): an empty catalog is not unique to openDox's no-model port. **RULED (a) by the holder** on 2026-10-03, so the scope is stated, not changed. `407f6ecd` adds the comment and the census move, the thread is answered with the closed-envelope facts and resolved, and Copilot was re-requested. Locally, the whole suite gives `3095 passed, 178 skipped`. CI at `407f6ecd` (run `37084541090`): `validate` green, `triple: selected=3273 passed=3262 skipped=11 failures=0 errors=0`. **Copilot at `407f6ecd`**: "Approval recommended", with 0 unresolved threads. ## Not in this PR - 16.5, every other surface working with no model: T082. - The configured turn over a stand-in OpenAI-compatible server: T078–T080, with F16.1 run whole at T083. - The standalone scope and approval reaches: T084. 🤖 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>
…d (plan 034) (#63) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034, phase-3 slice **P3-B**, task **T080**. The plan was read at openxFactory `main` `e369cb25`, from `specs/034-opendox-standalone-operation/tasks.md`, and read again at `main` `2140f5a7` and, after T063 landed, at `main` `a883bbf6` (see the box below). The slice is claimed on openxFactory#656, comment `5875729625`. **This was drafted ahead, and T063 has now landed.** Brett's phase-3 word, as the holder recorded it (2026-09-28, ~18:00Z), was *"Only the independent ones (Recommended)"*. Every phase-3 task comes after T063, which landed as openxFactory#1218 → `a883bbf6` and closes phase 2. T078 landed as #61 → `8a98e317` and T079 as #62 → `2fc714d2`, so this PR may go READY. Its READY line is the holder's to post. > [!IMPORTANT] > **Dependencies: `After: T079, T007 (batches H and K)`.** Both batches have landed, and so have T063 and T079 (#62). > - **Batch H**, openxFactory#1206 → `f99a2097`: 16.3's addendum for the built-in resolver and the auth kind `none` (R1Q17 (b), R1Q18 (a)). This PR carries it out. > - **Batch K**, openxFactory#1210 → `39f19145`: the dated note in requirement 17's body in #1144's spec delta, with a pointer after 16.3's batch H addendum (`5916000030`, item 1). See *Ruled* below. > - Read at openxFactory `main` `2140f5a7`, and again at `a883bbf6`, where it is unchanged. Plan 034's T080 entry records the loopback rule, batch K and openDox-code#64, and its `After:` line names both batches. F16.1's block is unchanged. **One PR per task, stacked.** T078 (#61) and T079 (#62) have landed, and this PR is based on `main`. #64, the broker hardening, is based on this branch, so retarget #64 to `main` before this branch is deleted, because a merge with `--delete-branch` closes the PR stacked on it. ## What changes **A key inside the endpoint URL is refused when the binding is declared.** Two checks ask. One is the product's own detector, `runtime/local_git_adapter.carries_a_credential`, imported where it is asked, as `authoring.py` does. The other is a raw key's shape anywhere in the URL, its path and fragment included (the adversarial review's M5, below). Both run before the scheme check, and every refusal of an endpoint is a fixed sentence, the scheme's too (M3), so no refusal repeats the URL. The key's refusal is `ENDPOINT_CARRIES_A_CREDENTIAL`, because the URL it refuses carries the key, and through the console the refusal can reach a browser. A key in an extra field is refused as an unknown key, as it always was. **An endpoint longer than the product's URL bound is refused before the detector is asked.** The bound is `runtime/config.MAX_REMOTE_URL_CHARS` (2048), which the repository act applies to a remote for the same reason: the detector is quadratic in a parameter name's length. It is read when the binding is declared, and its refusal, `ENDPOINT_TOO_LONG`, repeats nothing of the endpoint. **A reference is never a raw key** (the adversarial review's M2, below). A reference is held to the same length bound (`CREDENTIAL_REF_TOO_LONG`), and then refused if the detector flags it or it has a raw key's shape (`CREDENTIAL_REF_IS_A_RAW_KEY`). Both sentences are fixed. A reference a broker hands back at intake meets the same rule. **Each record now has ONE resolver**, and `credential_source()` names it: | the record | its resolver | `broker_argv` | `credential_ref` | |---|---|---|---| | any other reference | the broker it names, as before | required | required | | `env:NAME` or `keyring:SERVICE/USERNAME` | the **built-in resolver** (R1Q17 (b)) | not needed; **one given is refused** | the reference | | auth kind **`none`** (R1Q18 (a)) | nothing: no credential is presented | **forbidden** | **forbidden** | **The built-in resolver** is `doxbench_provider.resolve_credential_reference`, inside `doxbench_provider.py` only. - It reads the reference at call time, once per request, and keeps nothing. There is no mint, no ledger event, and no credential on the port between turns, so a rotated key is the one the next request presents. The resolved value travels as `Authorization: Bearer <value>` and nowhere else. - The resolved value must be non-empty printable ASCII with no whitespace, which is what a bearer credential is by its grammar. A value that is unset, or is anything else, refuses with the fixed `DIAG_REFERENCE_UNRESOLVED`, before any provider is contacted. A keyring that cannot be read refuses with `DIAG_KEYRING_UNAVAILABLE`. That refusal is raised after the backend's error has been handled, so it has no cause and no context: neither the backend's words nor its frames travel with it. `FIXED_DIAGNOSTICS` goes from eight to eleven: these two, and `DIAG_PROVIDER_REDIRECTED` below. - A 401 without a broker is `DIAG_PROVIDER_REFUSED` with no retry. The 2026-08-26 ruling's re-mint is for a minted token, and a second read of a reference names the same value. **A credential the built-in resolver reads travels only by a private route**: `https://`, or `http://` to `127.0.0.1`, `::1` or `localhost`. This is Brett's ruling of 2026-09-28, openxFactory#656 comment `5880893901`, which batch K's note to #1144's requirement 17 records (see *Ruled* below). - `doxbench_binding.is_a_private_route` is the one predicate. It matches the endpoint as written, case-blind: `https://`, or `http://` followed by exactly one of `LOOPBACK_HOSTS` (the IPv6 literal bracketed), an optional port, and then `/`, `?`, `#` or the end. - It does not use a URL parser's reading of the host, because the parser and the HTTP client disagree. For `http://evil.example\@localhost/`, `urllib.parse` reads the host as `localhost`, while the HTTP client reads the whole authority. - The record refuses a built-in reference on any other route when it is declared, whether through the constructor, a stored record or the operator door. The refusal is one fixed sentence, `ENDPOINT_NOT_PRIVATE`, and it comes before any resolution, because no binding exists to resolve. - The resolver asks the same predicate before its first read. No declared binding reaches that check, so a binding-shaped object that does gets an `AssertionError` with nothing read, as a broker's reference already does. - The broker path's route is unchanged, as the ruling says. So is `none`'s, since it presents no credential. The one change this PR makes to the broker path is L4's mapping in the shared transport (below). **A request that carries a built-in credential keeps it on that route.** This came out of Copilot's later rounds, below. - It follows no redirect. `urllib`'s default opener re-sends the credential header to any `Location`, whatever its host or scheme. The redirect is declined, and the turn refuses with the fixed `DIAG_PROVIDER_REDIRECTED`. - Over plain `http://` it uses no proxy, because a proxy would carry a loopback request off this host. An `https://` request may still use one, since a proxy reaches it only by CONNECT and the credential stays inside TLS. - No frame a refusal keeps holds the raw credential. The credential travels as `_PresentedCredential`, whose repr says nothing, on both paths. A refusal of such a request is raised with no cause and no context, so no traceback reaches urllib's own frames, whose locals hold the headers. **`none`** joins `AUTH_KINDS` after `api_key` and `oauth`, so F16.1's `AUTH_KINDS[0]` is unchanged. Its requests carry no authorization header. **Around them:** - The record parses a reference's form once, for both sides (`built_in_reference_parts`), and reads nothing a reference names. Both forms parse to one shape, `BuiltInReference(form, name, user)`. - The `env:` and `keyring:` forms are reserved for the built-in resolver. So a broker whose intake answer names a reference in one of them is refused as malformed (`DIAG_BROKER_MALFORMED`), which both entry points already catch. - Otherwise the record would be declared again with two resolvers. - The console's intake route builds that record outside the handler that catches a refused binding (`serve_workbench.py`, which is left alone), so there the refusal would have gone uncaught. - A stored record may leave out the fields its own resolver forbids or does not need. - Each read-back and each removal states the custody sentence that is true of its resolver. - The operator door takes `--auth-kind none`, an omitted `--credential-ref`, and an empty broker invocation. - `set-credential` refuses a binding no broker answers, without reading its standard input. - `broker_operation_argv` treats such a binding as a programming error, since its empty base invocation would run the subcommand as a program. - `http.client.HTTPException` joins the errors the transport maps. A response `http.client` cannot read, or a path it cannot send, lands on `DIAG_PROVIDER_UNREACHABLE` (the adversarial review's L4, below). The transport is shared, so this changes the broker path's failure too: a broker's turn now lands on that sentence where it escaped before. Raising it afresh there, with no token in a kept frame, is #64's (Copilot at `44582f8f`). - A `keyring` package that fails as it is imported, with anything other than an `ImportError`, refuses with `DIAG_KEYRING_UNAVAILABLE`, raised with no context, as a failed read already does (Copilot at `82ec9a20`). Files: `src/opendox/doxbench_binding.py`, `src/opendox/doxbench_provider.py`, `src/opendox/cli_model_binding.py` and `tests/test_model_provider_broker.py`, all inside P3-B's row. One docstring outside the row, `doxbench_intake.auth_kind_disclosure`'s, now says which kinds the console flow offers (see *Review remarks*). `pyproject.toml` is untouched. **Realizes**: 16.3. **Ruled**: R1Q22 (a), `5817152735`; R1Q17 (b) and R1Q18 (a), `5850003126`; and the loopback rule, `5880893901`, with batch K's note to requirement 17, `5916000030`, item 1 (see *Ruled* below). ## Readings Brett let stand Brett's word, relayed by the holder on 2026-09-28: the readings below stand. 1. **A `broker_argv` given beside a built-in reference is refused.** This is the plan's fail-closed reading (analyze round 2, V2-21), which openxFactory#656 comment `5851950767` records as standing, not overruled. That such a record needs no broker at all follows from R1Q17 (b). One test holds both halves: `test_a_built_in_reference_needs_no_broker_and_refuses_one_beside_it`. 2. **Where the plan is silent, this PR chose:** - the reference syntax: `env:` plus a portable variable name, and `keyring:SERVICE/USERNAME`, split at the last `/` so a service may contain one; - the OS keyring through the `keyring` package, imported at call time and not declared as a dependency. Without it, a keyring reference refuses with the fixed sentence. `pyproject.toml` is outside this slice, and T072 owns its `local` extra, so declaring it is a holder decision; - the resolved value presented as a bearer credential for either kind that takes one. Refreshing an OAuth grant stays a broker's job. ## The falsifier: F16.1's three refusals, and tests of the resolver and of `none` This is F16.1's record block, extracted verbatim from #1144's `tasks.md` at openxFactory `e369cb25`, and unchanged at `main` `2140f5a7` after batches H and K and at `main` `a883bbf6` after T063 (sha256 `8af3001b5f5fb885…`), with the control record and the three raw keys: ```python rec = {f: "stand-in" for f in b.BINDING_FIELDS} rec.update(auth_kind=b.AUTH_KINDS[0], dialect="openai-chat-v1", endpoint="http://127.0.0.1:9/v1/chat/completions") if "broker_argv" in rec: rec["broker_argv"] = ["stand-in-broker"] b.ModelProviderBinding.from_record(rec) # the control: a clean record is accepted for bad in (dict(rec, endpoint="https://user:sk-stand-in@api.example.invalid/v1"), dict(rec, endpoint="https://api.example.invalid/v1?api_key=sk-stand-in"), dict(rec, api_key="sk-stand-in")): ... print("dialect and model declared; a raw key is refused in a field and in the URL") ``` At T079's head `b04a3a95` it stops with `FAIL: a raw key was accepted in ['endpoint']`. At this head `05cb1c70`, as at `4948e6dd`, the whole block passes: ``` dialect and model declared; a raw key is refused in a field and in the URL ``` The named tests are in `tests/test_model_provider_broker.py`: - `test_f16_1_a_raw_key_is_refused_in_a_field_and_in_the_url` (the block above, as a test) and `test_f16_1_the_first_auth_kind_still_takes_a_credential`; - the URL detector over eight keyed shapes, with none repeated in its refusal; four clean endpoints accepted; extra fields refused; - **the URL bound**: an endpoint past it refused without the detector being asked, and nothing of it repeated; one at it accepted; the number read from `runtime/config` at declaration; - **the private route** (32 cases; each declaration case runs with an `env:` and a `keyring:` reference): - accepted: `https://`, `127.0.0.1`, **`[::1]` (IPv6 loopback)**, `localhost`, `LOCALHOST` and a bare `http://localhost`; - refused, by the constructor and from a stored record: another host, **`localhost.evil.com`**, `127.0.0.1.evil.com`, `localhost` only in the path, `127.0.0.2`, `[0:0:0:0:0:0:0:1]`, `localhost.`, `localhost%2eevil.com` (the HTTP client decodes it) and `0.0.0.0`; - **mixed-case schemes**, read by the predicate: `HTTP://` to another host and `Http://localhost.evil.com` are not private, while `hTTp://127.0.0.1`, `HTTP://[::1]`, `HTTPS://` and `hTtPs://` are. Also not private: the backslash case, a leading space and another scheme; - the resolver reads nothing, from the environment or the keyring, for four routes that are not private, one of them in mixed case. It does read for IPv6 loopback; - the operator door refuses an `env:` binding over `http://` to another host and stores nothing, while a `none` binding to that host is declared. A broker binding and a `none` binding keep their route; - **the transport** (9 cases, over real sockets unless noted): - a 301, 302, 303, 307 or 308 declined, with the second server hearing nothing; - a stand-in proxy hearing nothing; - three walks of every frame a refusal keeps, through its causes and contexts, finding no local that holds the secret. One is a real refused socket, one is an unpresentable value, and one, with a stand-in opener, is the broker path's provider-call frame; - **`none`**: forbids both fields, round-trips, may leave both out, sends no authorization header, spawns no broker (over a real loopback socket too), and refuses a 401 without a retry; - **the resolver**: `env:` read at call time and rotated between turns; production reading `os.environ`; eleven unusable values refused before any request, and every printable ASCII character but the space presented unchanged; a value outside latin-1 refused over a real socket with nothing chained; availability restored after a success; the keyring read per request at the last-`/` split; an absent entry and three unusable keyring answers; a failing backend (fixed sentence, no cause and no context); the `keyring` package absent; the production import path; the bearer over a real socket; no trace of the resolved value in answers, notices, the ledger, the port's state or on disk; and the refusal mapping onto the seam's fixed `model_failed`; - **the reference forms**: one parse and one shape for both; fourteen malformed references refused without being repeated, two of them variable names of another script (`\w` is held to ASCII); a broker's answer in either built-in form refused as malformed, through two real broker scripts; - **the resolver is in `doxbench_provider.py` alone**: the record reads no environment and no keyring, and `get_password` and `import keyring` appear in no other module of the package; - **the operator door**: each resolver declared, two-resolver and no-reference records refused, a keyed URL refused and nothing stored, `set-credential` refusing a binding no broker answers. F16.1's other blocks are later tasks'. `tests/test_provider_boundary.py` is T083's, and it passes here: 24 passed. The no-model block is T081's, and it still fails as #1144 records (`['omp-local']`), untouched here. `tests/test_chat_model_configuration.py` is T081's and T082's, and does not exist yet. ## The repository's own suite Local runs of the whole suite, as CI runs it (`python -m pytest -q`), use a PostgreSQL 16 service for `tests_runtime` and `LANG=C.UTF-8`, as on the runner: | tree | passed | skipped | failed | |---|---|---|---| | `main` `9a490405` (T081 landed) | 3262 | 11 | 0 | | this head `05cb1c70` | 3475 | 11 | 0 | Along the way this round, `abbb05d4` read 3361, `93660ec9` (the merge of `main` `2fc714d2`) 3418, `82ec9a20` 3419 and `44582f8f` 3420. Before this round, `main` `047bb4fa`, T079's `b04a3a95` and this branch's `4948e6dd` read 3044, 3083 and 3206. Before phase 2 landed, the same three read 2468 (`2d116415`), 2507 (`053e207a`) and 2630 (`3f14bb96`). CI's `validate` at this head (run `37086697677`) reads `selected=3486 passed=3475 skipped=11 failures=0 errors=0`, against the pins 2476, 2465 and exactly 11. The +213 cases are in `tests/test_model_provider_broker.py`: 64 at the first push (`e9ef9514`, 2571 passed), 16 in the fix commit `f92fca47`, 1 in `146b5a22`, 32 in the ruling's commit `4abc6d4d`, 9 in the three transport commits (`5167084c`, `1b0fb3f4` and `286655f3`), 1 in `3f14bb96`, 82 in the adversarial review's commit `645280ac`, 1 in `82ec9a20`, 1 in `44582f8f` and 6 in `47c9da9a`. None skips, because the keyring is always a stand-in. Skipped stays at the pinned 11. ## This round's commits, and its merges (`05cb1c70`) In order: - `645280ac` fixes the adversarial review's four findings (next section). - `03fb7791` merges T079's `cb059d5d`. That commit merges openDox-code `main` `8a98e317`: T078's squash (#61), with T085 (#71), T088 (#65) and T071 (#60) landed before it. None of those landings changes a file this PR changes, and the merge had no conflict. - `abbb05d4` merges T079's `e7f3a7b3`, a docstring fix in `doxbench_intake.py` that Copilot asked for on #62. This PR's own edit to that file is a different docstring, and the merge had no conflict. - `93660ec9` merges `main` `2fc714d2`, which is T079's squash (#62), after T070 (#67). This branch carried T079's own commits, so the five files T079 changed met its squash on `main`: - `main`'s copy of each equals T079's head `e7f3a7b3`. - Four of them conflicted only where this branch changes T079's lines. Each is resolved to this branch's side. - So the merge adds exactly what `main` gained beyond T079: `git diff abbb05d 93660ec` is `git diff e7f3a7b 2fc714d`, T070's thirteen files. - `82ec9a20` makes the scheme refusal route-neutral (Copilot at `abbb05d4`). - `67d00617` fixes SonarCloud's five findings at `82ec9a20`. - `44582f8f` makes a failing `keyring` import refuse like a failed read (Copilot at `82ec9a20`). - `47c9da9a` runs the L4 case for a broker's turn too (Copilot at `44582f8f`). - `05cb1c70` merges `main` `9a490405`, T081 (#74). T081 changes `tests/test_model_provider_broker.py` at cases this PR does not change, and the merge had no conflict. Its changed lines in that file are T081's exactly, and every other file it touches equals `main`'s. The suite above is this head's. Before this round, `4948e6dd` merged T079's `b04a3a95`, which carried T078's merge of `main` `047bb4fa` (phase 2's nine landings). SonarCloud at this head: 0 issues, 0 hotspots to review, quality gate OK. ## The adversarial review at `4948e6dd`: M2, M3, M5 and L4 An adversarial reviewer read this PR at `4948e6dd`, and the holder forwarded four confirmed findings. `645280ac` fixes all four. Every new case fails at `4948e6dd`, and every mutant of the new checks is killed. - **M2 (medium): a raw key given as `--credential-ref` was taken as a broker's reference.** It was stored in the file the module calls safe to commit, and `list` printed it. Each mint then put it in the broker's argv, where `/proc/<pid>/cmdline` shows it to every local user. - The record now asks two questions of every reference, inside the built-in forms too: the product's detector, and a raw key's **shape** (`has_a_raw_key_shape`, below). A reference that fails either is refused with one fixed sentence, `CREDENTIAL_REF_IS_A_RAW_KEY`. - The reference a broker hands back at intake is held to the same rule. One that breaks it is a malformed answer (`DIAG_BROKER_MALFORMED`), which both entry points already catch. - No grammar is declared for a broker's references. #1144 box 16.3 says only that a reference is never a raw key. So this is the floor the holder named: the detector, plus a shape check. - **M3 (medium): the scheme refusal repeated the endpoint.** So `--endpoint <key>`, `ftp://<key>` and `" https://…?q=<key>"` printed the key to the terminal, to startup's standard error (`doxbench_install.py:322`) and, through the console's intake route, to a browser (`serve_workbench.py:1110`). It is now one fixed sentence, `ENDPOINT_SCHEME_REFUSED`, built from `ENDPOINT_SCHEMES` alone. A short key that no shape rule knows is not repeated either. Since `82ec9a20` it also says nothing about hosts, because every resolver meets it and only a built-in credential is held to a private route (Copilot at `abbb05d4`). - **M5 (medium): a key in the endpoint's path or fragment was accepted.** The detector reads only a URL's userinfo and its parameters' names. The endpoint is now checked for the same shape, before the scheme check, and refused with `ENDPOINT_CARRIES_A_CREDENTIAL`. That covers a key used as a path segment, glued to a path word (`bot<key>`), placed in the fragment, put in a parameter with an innocent name, or given in place of the URL. - **L4 (low): `http.client.HTTPException` escaped `dispatch`.** `http.client` raises it when it cannot read a status line, a protocol, a header line or a body, or cannot send a path. It is no `OSError`, so it escaped, and its traceback kept `do_open`'s frame, whose `headers` hold the key. - The transport now maps it to the fixed `DIAG_PROVIDER_UNREACHABLE`. The built-in path raises that afresh, with no cause and no context, as it does every refusal. - There is one case per subclass, over a real loopback server: `BadStatusLine`, `UnknownProtocol`, `LineTooLong`, `HTTPException` (more than 100 headers), `IncompleteRead`, and `InvalidURL` (a path with a space, which the record accepts). - Each case first checks that `urllib` alone raises exactly that class. Each runs for a built-in credential, for `none` and, since `47c9da9a`, for a broker's turn with a real fake broker's mint. For a broker's turn it pins the sentence only. #64 raises it afresh there and pins that. **The shape, and where it stops.** A key has no grammar, so the rule is a shape, and it errs toward refusing. A text has a raw key's shape if it holds any of these: - a run of 32 or more letters and digits that mixes upper case, lower case and digits; - a run of 40 or more that mixes two of the three; - a widely used key prefix (`sk-`, `ghp_`, `xoxb-`, `hf_` and the rest of `_KEY_PREFIXED`) with 16 or more key characters after it. Those characters must mix all three classes, or two where the prefix begins a word, so `bot` glued to a key is still refused, while a word that only ends in a prefix, as `benchmark-` ends in `rk-`, is not; - a Google API key, an AWS access key id, or a JSON Web Token. The rule passes this product's `opref-` references, UUIDs, model and deployment names under 32 characters, and the 32-character lowercase hex ids that gateways put in their paths and key vaults put in their secrets' versions. The tests hold both sides at each rule's edge. It cannot see a key that uses only one class, a two-class key under 40 characters with no known prefix at the start of a word (that is the shape of a gateway's account id), or a format it does not list. Every refusal around it repeats nothing, so a key it misses still reaches no message. **A length bound on the reference, which is new and accepted.** Asking the detector about a reference means running a quadratic check on a value that had no bound. Measured locally, an `opref-` reference of 65,536 characters took the detector 4.3 s. So a reference is held to the endpoint's bound first (`MAX_REMOTE_URL_CHARS`), with its own fixed sentence, `CREDENTIAL_REF_TOO_LONG`, and `carries_a_raw_key` never asks the detector about a longer text. The holder accepted the cap. Brett's *"Leave unbounded (Recommended)"* (openxFactory#656 comment `5916000030`, item 5) is about a TIME limit on reading the operator's key. It does not reach the size of a reference field. **Not in this PR:** M4, and the execution of `broker_argv` from a served repository's bindings file. Both belong to T100, another writer's PR stacked on #64, and neither is touched here. **Failing first.** The 82 new cases were run against `4948e6dd`'s sources. 67 fail, and the 15 that pass are controls. Each failure is the case's own: `DID NOT RAISE` where a key was accepted, the scheme refusal's echo, and each `http.client` subclass escaping. Each later case fails without its fix: `82ec9a20`'s with `93660ec9`'s record, `44582f8f`'s with `82ec9a20`'s import, and `47c9da9a`'s six with `4948e6dd`'s transport. **30 mutants**, each run against the whole broker module, and each killed: | mutant | killed by | |---|---| | B1 no 3-class run rule | 9: the base62 run, as a reference and in seven endpoint places | | B2 no 2-class run-of-40 rule | 2: the 40-character hex run | | B3 the run of 40 widened to 41 | 2: the 40-character hex run | | B4 the run of 40 narrowed to 32 | 5: the 32- and 39-character hex controls, the gateway's account id | | B5 the run of 32 widened to 33 | 8: the 32-character base62 run | | B6 the run of 32 narrowed to 31 | 2: the 31-character control | | B7 no prefix rule | 8: the four prefixed shapes | | B8 a prefix always needs 3 classes | 6: a prefix with a two-class tail, at a word start | | B9 a prefix always needs 2 classes | 2: `benchmark-runner-…`, a word that ends in a prefix | | B10 the prefix's tail of 16 widened to 17 | 2: a tail of exactly 16 | | B11 the prefix's tail of 16 narrowed to 15 | 2: the 15-character tail control | | B12, B13, B14 no Google, AWS or JSON Web Token format | 2 each: that format | | B15 digits not a class | 18 | | B16 no bound floor in `carries_a_raw_key` | 2: the predicate's floor, and a broker's reference past the bound | | B17 `carries_a_raw_key` without the shape | 35 | | B18 `carries_a_raw_key` without the detector | 2: F16.1's keyed URLs, and the reference at the bound | | B19 the endpoint asks the detector alone (`4948e6dd`) | 14: every endpoint place, both keys | | B20 no length check on the reference | 1: the bound case | | B21 no raw-key check on the reference | 12 | | B22 the scheme checked before the key | 7 | | B23 the scheme refusal repeats the endpoint (`4948e6dd`) | 5: every scheme case | | B24 a prefix at the start of the text begins no word | 6 | | B25 the scheme refusal's host advice restored (`93660ec9`) | 1: `test_the_scheme_refusal_is_route_neutral` | | P1 no `http.client.HTTPException` in the transport's tuple | 18: every subclass, for a built-in credential, `none` and a broker's turn | | P2 a broker's reference not held to the record's rule | 2: both intake cases | | P3 a built-in refusal re-raised with its cause | 7: the six subclasses and the refused connection, built-in | | K1 a `keyring` import that catches only `ImportError` (`82ec9a20`) | 1: `test_a_keyring_package_that_fails_as_it_is_imported_refuses_the_same_way` | | K2 that refusal raised inside the handler | 1: the same case, on its context | ## Review remarks, answered at `4948e6dd` **Copilot's overview** (review `5343397205`, at `e9ef9514`) asked to *"address the endpoint-size handling and credential encoding validation findings"*. It posted no inline threads. Both remarks were valid, and both were measured before they were fixed: 1. **Endpoint size.** The endpoint reached the detector unbounded, from the command line or from the console's intake route. Measured locally over two runs, the detector takes about 0.002 s on a 2,048-character parameter name, about 0.1 s on 16,384 characters, and 1.4 to 2.2 s on 65,536. The bound now comes first (see *What changes*). 2. **Credential encoding.** The resolver refused only CR, LF and NUL. Measured against `urllib`: - a character outside latin-1 failed while the header was encoded. That refusal read `DIAG_PROVIDER_UNREACHABLE`, which names the wrong party, and the `UnicodeEncodeError` chained to it held the whole header, credential included; - any other non-ASCII character, and an embedded space, was sent. Both now refuse with `DIAG_REFERENCE_UNRESOLVED`. With the old check restored, `test_a_value_outside_latin_1_is_refused_before_any_header_is_built` fails over a real socket with the provider-unreachable sentence. **SonarCloud** (13 findings at `e9ef9514`): - in source: S8495 (the parser returned tuples of two lengths, now one named tuple), S6353 (`[A-Za-z_]\w*` under `re.ASCII`) and S7632 (a bare `# noqa: BLE001`, with the reason on the line above); - in tests: S5778 ×8 (one throwing call per `pytest.raises` block) and S9073 ×2 (composite assertions split). All 13 are fixed in `f92fca47`. Its own analysis found one more, S8997 on the new real-socket test, which reset the stand-in handler's record by assignment. `68b6e412` resets it through `monkeypatch`. At `4abc6d4d` it found S3776: the binding's `__post_init__` reached a cognitive complexity of 17, where 15 is allowed. `d240fd50` moves the endpoint's own checks into `_require_a_declarable_endpoint` and the loopback rule into `_require_a_private_route`. Every check keeps its order. **Five mutants of the fix**, each killed by a named test: | mutant | test that fails | |---|---| | `re.ASCII` dropped | `…malformed_built_in_reference…[env:NAMÉ]` | | the old CR/LF/NUL check restored | eight cases, including `…outside_latin_1…` over the socket | | no length bound | `test_an_endpoint_past_the_url_bound_is_refused_before_the_detector` | | the bound checked after the detector | the same test | | the bound copied as 2048 | `test_the_url_bound_is_the_products_own_read_when_it_is_asked` | **Copilot's later rounds.** Each inline thread is answered with evidence and resolved. | review at | verdict | threads | answered in | |---|---|---|---| | `146b5a22` | Copilot reported an error | none | | | `4abc6d4d` | Changes recommended | redirects forward the credential (high); the console's kinds are not derived from `AUTH_KINDS` (low) | `5167084c`; the pin test, with evidence and no code change | | `d240fd50` | Changes recommended | the raw credential in frame locals (high) | `1b0fb3f4` | | `5167084c` | Needs a closer look | none new | | | `1b0fb3f4` | Needs a closer look | environment proxies carry a loopback request off the host | `286655f3` | | `286655f3` | Changes recommended | the header sends six asterisks (high): not reproduced, since the review's secret filter masks the `Bearer` expression | evidence, three real-socket tests; no code change | | `286655f3`, re-run | Needs a closer look | none; its summary named a broker's reserved-form reference and the keyring refusal's context | `3f14bb96` | | `3f14bb96` | Needs a closer look | none; its summary names the draft's upstream dependencies, which is the flag at the top | | | `4948e6dd`, the merge of `main` | Needs a closer look | none; its summary names the upstream dependencies and asks for a human review of the security-sensitive change | | | `4948e6dd`, asked again through the reviewer API | Needs a closer look | none; its summary asks for a final human review of the credential and transport changes | | | `abbb05d4`, the adversarial review's fixes and T079's merges | Changes recommended | the scheme refusal's advice, *"an http:// one on this host"*, was false for a broker's binding and a `none` binding, which may name an `http://` endpoint on another host (moderate) | `82ec9a20`: the sentence names the schemes alone; reply `4171022804` | | `82ec9a20` | Needs a closer look | none; its summary named a `keyring` package whose import fails with something other than an `ImportError` | `44582f8f` | | `44582f8f` | Needs a closer look | none; its summary named the shared transport's L4 mapping as a change to the broker path that the description did not state | `47c9da9a`, and this description | | `05cb1c70`, the merge of `main` `9a490405` | Needs a closer look | none; see the two unposted notes below | | Copilot's run at `abbb05d4` (Actions run `37082414121`) also stored notes it did not post. Its summary reads *"Three unresolved moderate findings remain in diagnostics, malformed endpoint handling, and bearer-header construction"*, and its log gives each note by location only: - the diagnostics are the scheme refusal, `doxbench_binding.py:290-293`, posted as the one thread above; - the bearer header's construction, `doxbench_provider.py:905`, was dropped as a duplicate of the resolved *six asterisks* thread; - `doxbench_provider.py:995-1000`, where the request is built from the declared endpoint, passed the duplicate check but was not posted, and its text is not in the log. This PR's reading, an inference, is an endpoint that `http.client` cannot send, such as one with a space in its path. The record accepts it, and the turn refuses it with the unreachable sentence (L4's `InvalidURL` case). Refusing such an endpoint when it is declared would be a new rule, so it is left for the holder. Copilot's run at `05cb1c70` (Actions run `37086706082`) stored two notes and posted neither. Its summary reads *"Update catalog privacy metadata for `none` and locally resolved credentials, and clarify the endpoint diagnostic for `none`."* Its log gives the notes by location: - `doxbench_provider.py:1217-1221`, where a binding no broker answers takes `_dispatch_without_a_broker`. This is the catalog's data-handling sentence, which *For the holder* below records as outside this PR. - `doxbench_binding.py:276-280`, a nit: `ENDPOINT_CARRIES_A_CREDENTIAL` advises naming the credential by its reference in `credential_ref`, which a `none` binding may not declare. The sentence still repeats nothing, and it is left as it is. **SonarCloud at `82ec9a20`** found five issues, all in this round's code. S5713: `urllib.error.URLError` is an `OSError`, so the transport's tuple named one class twice. S5778 ×4: four new cases built an argument inside their `pytest.raises` block. `67d00617` fixes all five, and no behaviour moves. Before that, Copilot's run at `f92fca47` posted nothing, but its log recorded three notes by location only. `146b5a22` answered two of them: a spelling note, and the console flow's kinds. Its security note later became the frame-locals thread. **Six mutants of the transport fixes**, each killed: | mutant | tests that fail | |---|---| | the redirect-declining opener not used | 5: every redirect code | | the redirect declined silently | 5: the diagnostic | | the wrapper's repr disclosing | 2: the real refused socket and the broker path's frame | | the chained cause kept | 1: the real refused socket | | the unpresentable value kept | 1 | | the proxy bypass removed | 1: the stand-in proxy answered the turn | **Two mutants of `3f14bb96`**, each killed: | mutant | test that fails | |---|---| | the keyring refusal raised inside the handler, keeping the backend's error as its context | `test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own` | | a broker's reference in a built-in form accepted | `test_a_broker_reference_in_a_built_in_form_is_malformed` | ## Ruled: a built-in credential travels only by a private route **Brett Heap ruled on this PR's question on 2026-09-28**, choosing *"Refuse unless loopback (Recommended)"*. It is recorded at openxFactory#656 comment `5880893901`: > A credential resolved by openDox's built-in resolver (env:/keyring: reference) is sent only over https://, or over http:// to 127.0.0.1, [::1] or localhost; any other http:// endpoint is refused (ENDPOINT_NOT_PRIVATE). The broker path is unchanged in this task. **Batch K records it in #1144** (openxFactory#1210 → `39f19145`). Brett Heap's word for the batch is `5916000030`, item 1: *"Yes, amendment batch K (Recommended)"*. - The amendment is a dated note in requirement 17's body in #1144's spec delta, after its SHALL paragraph and above its scenarios. #1144's `tasks.md` points to it after 16.3's batch H addendum. - The note narrows requirement 17's and scenario 17.1's *"any endpoint"* in one respect only: the route a credential travels by. It covers a credential the built-in resolver resolves (`5880893901`) and a token a broker mints (`5890601202`). It rewrites no ratified text. - This PR carries out the built-in resolver's half. openDox-code#64, stacked on this branch, carries out the broker's. - F16.1 stands, as the pointer says. Its control record names a broker reference on a loopback endpoint, which the rule accepts, and its three raw-key refusals are unchanged. Until `5880893901` was posted, this body quoted the holder's in-session relay of the same ruling. `4abc6d4d` realizes it, and `d240fd50` answers SonarCloud's complexity note on it. `5167084c` and `286655f3` complete it on the wire, because a followed redirect or an environment proxy would have sent the credential by another route. See *What changes* for the rule and *The falsifier* for the cases. **Seven mutants of the rule**, each killed by the named tests: | mutant | tests that fail | |---|---| | the declaration check removed | 10: every refused route, and the operator door | | a case-sensitive test for "is this http?" | 5, including `HTTP://` to another host and `Http://localhost.evil.com` | | the host matched as a prefix | 6, including `localhost.evil.com` and `127.0.0.1.evil.com` | | `::1` dropped from `LOOPBACK_HOSTS` | 3, including the IPv6 declaration and the resolver's IPv6 control | | the resolver's own check removed | 4: every route the resolver must read nothing for | | a case-sensitive match | 6, including `LOCALHOST` and `hTTp://127.0.0.1` | | a URL parser's reading of the host | 3: the backslash case, in the predicate and in the resolver, and a leading space | **Readings, for Brett to overrule if he wishes:** - The three hosts count only as the ruling spells them. `127.0.0.2`, `[0:0:0:0:0:0:0:1]` and `localhost.` are refused, which fails closed. Widening to all of 127.0.0.0/8 would change one constant. - The scheme check is case-sensitive and unchanged from `main`, so it refuses a mixed-case scheme at declaration before this rule is asked. The predicate's case-blind reading is what the resolver's own check relies on. - The reference forms are case-sensitive, so `ENV:NAME` or `Keyring:…` is a broker's reference, which needs its broker. Copilot's run at `5167084c` left an unposted note at that check. - `3f14bb96` refuses a broker's intake answer in a built-in form. That is the broker's enrolment answer, not its token's route, so this PR reads the ruling's *"leave the broker path as it is today"* as still met. The token is presented exactly as at `main`. ## For the holder: downstream, none of it edited here - **openXdox-code** `tests/test_doxchat_model_intake.py` asserts, in two places, that the intake flow's served kinds equal `list(doxbench_binding.AUTH_KINDS)`. With `none` appended, both fail at T086's pin. - The flow is right to offer only the two kinds a broker enrols, so those assertions need to name them: openDox now pins that relation in `test_the_console_flow_offers_every_kind_a_broker_enrols`. - The file sits in openXdox-code's declared exclusion (`doc_health`). **For T086.** - **openxFactory** `cli-help-tree.golden.txt` changes at T094's pin: `--auth-kind {api_key,oauth,none}`, an optional `--credential-ref`, and an optional broker invocation. - **`doxbench_install.brokered_catalog`**'s `data_handling` says *"leaves this host: … a short-lived token the credential broker minted"*. That is false for a `none` binding to a local server and for a built-in one. `doxbench_install.py` is P3-D's file. T081 has since landed (#74) and moved the sentence into `BROKERED_DATA_HANDLING`, unchanged, so the item stands for that file's owner. Copilot's summary at `05cb1c70` names it too. - The console's intake route enrols broker bindings only. For a `none` kind sent through it, the binding it would build is refused, and the route answers with that reason. `test_the_consoles_intake_shaped_binding_still_builds` holds the refusal. Enrolling `env:`, keyring or `none` bindings from the console would be a feature of its own. - **A gap that #64 closes, found by Copilot there at `e1a6cb0f`.** At this head, a provider answer nested past the recursion limit escapes the shared parse in `_post_to_provider` as a `RecursionError`, on every path, as it does on `main`'s broker path. - Measured at `4948e6dd` on the built-in path: the `RecursionError` escapes `dispatch`, and its traceback keeps `_post_to_provider`'s frame. That frame's `request` holds the key in its `Authorization` header (`_post_to_provider.request.Authorization`). - No kept local discloses the key by its repr, which is what this PR's frame tests measure. - openDox-code#64 refuses such an answer as `DIAG_PROVIDER_MALFORMED` at that parse (`da9e639a`, then `a271d307`), with a case for each credential source. - **The holder decided it stays in #64** (2026-10-02). #63 and #64 land back to back after T063, so the window is short, and the fix changes the broker path's failure too, which T080's ruling leaves to #64. ## The broker path's pre-existing gaps, left here as the ruling says, and closed by #64 Each of these is at `main`, and only item 3 moves here, as noted. This PR asked whether the broker path should follow the built-in path's pattern. Brett Heap answered *"Yes, separate phase-3 draft (Recommended)"*, openxFactory#656 comment `5890601202`. openDox-code#64, stacked on this branch, closes all four, and T080's scope is unchanged. 1. The token is presented over plain `http://` to any host. `test_the_loopback_rule_is_the_built_in_resolvers_alone` pins that scope. 2. The default opener follows redirects, and the environment's proxies, with the token on the request. 3. A provider-unreachable refusal chains urllib's error, and that error's frames hold the token in their locals. Measured: `do_open.headers`, `_send_request.headers` and `send.data`. Since `645280ac`, an `http.client.HTTPException` on a broker's turn lands on that refusal too, where it escaped before, so this item covers it as well. 4. The token is not checked for presentability, so one outside latin-1 fails in `urllib` as `DIAG_PROVIDER_UNREACHABLE`. 🤖 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>
Brings in #60, #61, #62, #63, #65, #67, #71 and #74 (T071, T078, T079, T080, T088, T070, T085, T081). No file this branch touches 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>
…ild (plan 034) (#69) 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` `91e4685f`), phase-3 slice **P3-I, install mode and the bundle**: - **T072** (#1144's 13.1): the bundled PostgreSQL server, as T007 batch H's 13.1 addendum reads (openxFactory#1206 → `f99a2097`). - **Falsifier:** F13.1's TCP-listener block, which reads the kernel's socket table at run time, and its `runtime status` block. - **After:** T070 (openDox-code#67, landed as `66ff7257`). It was stacked on #67's branch; the holder has retargeted it to `main`, and `57b7ed8f` merges main `66ff7257`, so this diff is T072 alone. **Ruled:** - R1Q22 (a), `5817152735`; - R1Q16 (i)–(iv), `5850003126`; - the phase-3 draft-ahead widening, `5901112350`. Claimed on openxFactory#656 in [`5901575394`](opensoft/openxFactory#656 (comment)). **Authored ahead as a DRAFT**, which did not go READY before T063 landed and the holder said so. **T063 has landed** (openxFactory#1218 → `a883bbf6`, 2026-10-02 23:37:43Z), which closes phase 2, so phase 3 is open. #60 (`7ff434d9`) and #67 (`66ff7257`) ahead of it have landed. ## What it does, by R1Q16's four parts - **(i) The document server starts it as its own child, and reports it.** - `opendox generate-and-open --local` (or `OPENDOX_INSTALL_MODE=local`) starts `postgres` as a **direct child** of the process serving the document surface. It uses `subprocess.Popen` and never `pg_ctl`, which would re-parent it. - It prints the socket, the pid and what it migrated. - `runtime status` reports `database_bundle` (`data_dir`, `socket_dir`, `pid`). It reads the pid from the server's own `postmaster.pid` and believes it only while a postgres runs there. - **(ii) Started and migrated, and nothing more.** - `initdb` runs once per data directory. - An idempotent bootstrap makes the database and the SERVED role. Its grants are the compose stack's (`init-runtime-role.sh`): CONNECT, USAGE on `public`, and DML on what the owner creates, by default privileges. - Then `migrations.MigrationRunner` runs as the owner, with the served role and database declared. The run narrows the ledger to SELECT for the served role and verifies its access, exactly as a hosted `runtime migrate` does. - `runtime migrate` under `local` migrates the bundle too. - **(iii) It ships as the `opendox[local]` extra**, which is `opendox[runtime]` plus `pixeltable-pgserver>=0.6.0` (RULED, openxFactory#656 `5916000030` item 2). **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 PostgreSQL fast shutdown (then an immediate one, then SIGKILL, each bounded). - The backstop is `PR_SET_PDEATHSIG` on Linux, so a SIGKILLed entry point still takes its server with it. - The server runs in its own session, so a terminal's Ctrl-C reaches the entry point, and the stop happens in order. ### 13.1's fixed identity - **The data and socket directories live under `OPENDOX_STATE_DIR`**, at `<state>/postgres/data` and `<state>/postgres/run`. - The setting is new. It defaults to `$XDG_STATE_HOME/opendox`, else `~/.local/state/opendox`, and must be absolute, because the server's process and a `runtime status` run from elsewhere must derive the same socket. - A state directory too long for the kernel's `sun_path` is refused, naming the setting. - **No TCP listener:** `listen_addresses` is empty, and the socket directory is narrowed to 0700. - **Peer authentication** (RULED, openxFactory#656 `5916000030` item 3, *"Peer auth + accept (Recommended)"*): - `initdb` runs with `--auth-local=peer --auth-host=reject`. - Before every launch the bundle rewrites `pg_hba.conf` and `pg_ident.conf` (atomically, mode 0600). `pg_hba.conf` holds one rule, `local all all peer map=opendox`, plus `host … reject` for IPv4 and IPv6. `pg_ident.conf` maps the running OS user, and nobody else, to `opendox` and `opendox_runtime`. - The kernel reports the connecting uid, so the DSNs carry no password because there is none. A cluster an older build left as `trust` is put back to peer on its next start. - **Both DSNs are supplied:** two users (owner `opendox` for migrations, `opendox_runtime` for serving) over the one socket, with `port` spelled so a stray `PGPORT` cannot redirect libpq. They pass T071's three checks for the reason those exist: one dialect, one database, and never one credential in both settings. - **An operator DSN given beside `local` is refused by name.** It is added to T070's `HOSTED_ONLY_SETTINGS`, a holder reading on openxFactory#656 that Brett may overrule. - **A second entry point on the same state directory is refused**, naming the running pid. One install's database belongs to one entry point at a time. ### The migrations gap (assigned to T072 by the holder) - The migrations were not package data. `migrations/` sits at the repository root, and only the image copies it (`WORKDIR /app`), so `pip install "opendox[local]"` run outside a checkout had nothing to apply. - Now `pyproject.toml`'s `[tool.setuptools.data-files]` maps `migrations/*.sql` into the wheel's data directory (`share/opendox/migrations`). **The root `migrations/` does not move.** - `config.migrations_dir` resolves an unset `OPENDOX_MIGRATIONS_DIR` in this order: 1. `migrations` wherever the working directory has one (a checkout, or the image's `/app`), which is **today's default, unchanged**; 2. otherwise the copy the installed distribution's `RECORD` lists (`packaged_migrations_dir`); 3. for an editable install, which installs no data files, the source tree's own `migrations/`. - The canonical digest gate is what proves any copy found is the pinned one. - `test_a_wheel_install_migrates_its_bundled_server_outside_a_checkout`: - builds this package's wheel offline (`--no-build-isolation`, `--no-index`); - installs it under a `--prefix` outside the checkout; - runs from a directory with **no** `migrations/`, asserting that `opendox` is the wheel's copy and that the migrations dir is under the prefix's `share/opendox`; - migrates the bundled server there (`applied == ["0001", "0002"]`). ### The server package (RULED, openxFactory#656 `5916000030` item 2: *"pixeltable-pgserver (Recommended)"*) - **`pixeltable-pgserver` 0.6.0**, the maintained fork of `pgserver`, uploaded 2026-07-14. Apache-2.0, as its dist-info `LICENSE` and OSI classifier say. It carries **PostgreSQL 16.14** under the PostgreSQL License (`initdb --version` and `postgres --version` from `pixeltable_pgserver/pginstall/bin`). It also carries an 18.4 under `pginstall18/`, which this package does not use. - **Only its binaries are used**, found with `importlib.util.find_spec("pixeltable_pgserver")` without importing it. Its own manager is not used, because: - it daemonizes through `pg_ctl`, against (i); - it stops from `atexit`, which SIGTERM never runs, against (iv); - it may put the socket under the user's runtime directory, opened to 0777, against 13.1. - **Linkage, re-verified on the installed wheel** (`readelf -d`, `ldd`): - `postgres` needs `libz`, `libpthread`, `librt`, `libdl`, `libm`, `libc`; - `initdb` needs those less `libz` and `libdl`, plus the wheel's own vendored `libpq`. That libpq resolves through `RPATH $ORIGIN/../../../pixeltable_pgserver.libs` and itself needs only `libc`, `libm` and `libpthread`; - the server's loadable modules need `libc`, and one needs the vendored libpq. So the system libraries are the C library and libz only: no system PostgreSQL, no ICU. - **Its floor and its size:** - Wheels exist for **cp310 to cp314**, on Linux x86_64 and aarch64, macOS and Windows. - The Linux wheels are tagged `manylinux_2_27` and `manylinux_2_28`, so they need **glibc 2.27 or later**. That is the tag's floor. The highest GLIBC symbol any binary or module needs is 2.25, by `objdump -T`. - Each wheel is about **24.7 MB** (the cp312 x86_64 wheel is 24,704,230 bytes), because it carries two server majors. - It pulls in `fasteners`, `platformdirs`, `psutil` and `typing-extensions`, which nothing here imports. All four were already pinned. - **Superseded:** `pgserver` 0.1.4 carried PostgreSQL 16.2 and had no wheel after cp312 (Copilot r4139811507 and r4139811528, both now resolved with this ruling cited). Also rejected: `postgresql-binaries`, which links the system's ICU and untars at first use, and `pgembed`, which is PostgreSQL 17. ### Outside `src/` and `tests/` - **`pyproject.toml`:** the `local` extra, the `test` extra joining it, `setuptools>=70.1` in `test` (the wheel test's offline build; 70.1 is the first release that builds a wheel with no `wheel` package), and the data-files map. It is a single-writer file (T057 → T072). #58 (T057) adds package data there, and the merge-from-main round takes it. - **`constraints-cpython312-linux.txt`,** in its own commit as its header asks. It was extended under its own pins in a clean 3.12.3 venv (`psutil==7.2.2`, `platformdirs==4.12.2`, `fasteners==0.20` and `setuptools==84.0.0` new). At `84a6c041` it was re-resolved in a clean environment under the pins less `pgserver`. The one line that moved is `pgserver==0.1.4` → `pixeltable-pgserver==0.6.0`. - **`deploy/` — one line, and the task requires it.** `deploy/compose/.env.example` gains `OPENDOX_STATE_DIR=`, because `test_every_runtime_setting_is_documented_in_env_example` requires every `SETTINGS` entry there. The compose stack is hosted and never reads it. **`docs/`: untouched.** - **Not touched:** `serve.py` (T073 adds the `install` block) and `validate.yml`. It already installs `.[runtime,test]`, and `test` now carries `local`. - **Existing tests changed:** - `tests/test_doxbench_entrypoint.py` stands the bundle in, with a tripwire. Its cases test the model port, which reads nothing from the store (R1Q16 (ii)). - T070's `test_install_mode.py` and `test_install_mode_entrypoint.py` stop passing DSNs beside `local`. - `test_runtime_surface.py` declares `opendox.runtime.bundle` stdlib-only at import, because `opendox.cli` imports it and `opendox --help` runs with no extra installed. ## The falsifier F13.1's `runtime status` block and its TCP-listener block, verbatim in their assertions (`f13-1-local.sh`), against a server `generate-and-open --local` started **in the background**, with no broker and no operator database, under `set -euo pipefail`. **Since the merge round (`19e32f0c`), the start is the REAL entry point.** It is `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. The stand-in driver is deleted. One deviation remains, and it does not weaken a check: - ~~**Generation is stood in.**~~ Retired at `19e32f0c`. Until then the start went through `tests_runtime/local_entrypoint_driver.py`, because this stack's base predated T055/T056's standalone generate. - **The pid comes from `runtime status`.** The TCP-listener block reads the server's pid from `runtime status`'s `database_bundle`, not from `caps.json`. `/capabilities`' `install` block is T073's, so F13.1's `caps.json` block is T073's to run. - ~~**The corpus is a one-file stand-in.**~~ Retired at `19e32f0c`: it is T050's fixture now. BEFORE is #67's head `b50e3b1`; AFTER is this branch: ``` === BEFORE (b50e3b1) ready=1 runtime status rc=1 AssertionError: no bundled database answered: None OPENDOX_DATABASE_URL is required and is not set: … FAIL status-block AssertionError: the bundle reports no server pid: None FAIL tcp-listener-block === AFTER (this branch, set -euo pipefail, exit 0) ready=1 runtime status rc=0 PASS status-block server pid 638810, sockets ['3692911'], TCP LISTEN rows: none PASS tcp-listener-block PASS stops-with-entry-point (pid 638810 gone) --- server stdout: database /tmp/tmp.v0wydKm5Zm/postgres/run (bundled, pid 638810, migrations applied now: ['0001', '0002']) ``` T070's F13.1 refusal probes and F13.1's `load_settings` block still pass on this branch (`F13.1 REFUSALS + T070 PAIR: ALL PASSED`). In the suite, `tests_runtime/test_bundled_postgres.py` runs the same two blocks on the same background launch, and adds three checks: the server's `PPid` is the entry point's pid (i), the socket directory is 0700, and SIGTERM ends the entry point with exit 0 and the server gone (iv). Beside that it has a SIGKILL case (the parent-death backstop), the second-server refusal, `runtime migrate` under `local`, the wheel case above, and the layout and refusal cases. **Under `CI` it fails rather than skips** if the server is missing, because `validate.yml` pins `EXPECT_SKIPPED=11` exactly. ## A mutant of each new refusal and guarantee, killed Each mutant was applied alone, and `test_bundled_postgres.py` plus `test_install_mode.py` were run with `-x`, with a 240 s bound so a hang could not pass for a kill: | mutant | killed by | |---|---| | M1 an operator DSN accepted beside local | `test_migrate_under_the_local_mode_uses_the_bundle_and_refuses_a_dsn` | | M2 a socket path too long accepted | `test_a_state_dir_too_long_for_a_unix_socket_is_refused_naming_it` | | M3 a relative state dir accepted | `test_a_relative_state_dir_is_refused_naming_it` | | M4 the server opens a TCP listener (`listen_addresses=127.0.0.1`) | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` | | M5 the server is not the entry point's child (`setsid -f`) | same | | M6 `stop()` a no-op | `test_a_second_entry_point_on_the_same_state_dir_is_refused` | | M7 no parent-death signal | `test_the_server_stops_even_when_the_entry_point_is_killed_outright` | | M8 SIGTERM not read as an interrupt | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` | | M9 started but not migrated | same | | M10 the served DSN is the owner's (a collapse) | `test_the_two_dsns_are_two_users_over_the_one_socket` | | M11 no packaged migrations found | `test_a_wheel_install_migrates_its_bundled_server_outside_a_checkout` | | M12 the socket directory left 0755 | `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` | | M13 a second server on one state dir not refused | `test_a_second_entry_point_on_the_same_state_dir_is_refused` | **A defect this PR's own test found in itself.** The first cut of the wheel case ran `pip install --prefix` without `--ignore-installed`. pip then read the suite's own editable `opendox` as the installed copy of the same project and **uninstalled it**, emptying the environment the suite runs in (measured: `pip list` lost `opendox` and both console scripts). The flag is now there, with a comment, and the case asserts afterwards that the suite's own `opendox` still resolves. ## The repo's own suite Full `python -m pytest -q`, `LANG=C.UTF-8`, `CI=true`, against a `postgres:16` like `validate.yml`'s: | | selected | passed | skipped | failed | errors | |---|---|---|---|---|---| | #60's head `f097fd8` | 2485 | 2474 | 11 | 0 | 0 | | #67 (T070) `b50e3b1` | 2531 | 2520 | 11 | 0 | 0 | | #67 (T070) `525f61c`, its fix round 2 | 2538 | 2527 | 11 | 0 | 0 | | this branch before the merge, `c3a70a2` | 2545 | 2534 | 11 | 0 | 0 | | `32db5d8` (merges `525f61c`) | 2552 | 2541 | 11 | 0 | 0 | | `ac61596` (merges `02dadc5`) | 2554 | 2543 | 11 | 0 | 0 | | `28bdccd` (fix round 4, and merges `859b37b6`) | 2580 | 2569 | 11 | 0 | 0 | | `96b2699f` (fix round 5) | 2584 | 2573 | 11 | 0 | 0 | | `a0fb7c8d` (merges `026f00ea`; fix round 6) | 2584 | 2573 | 11 | 0 | 0 | | `0f77d5c1` (fix round 7) | 2595 | 2584 | 11 | 0 | 0 | | `379fbb14` (fix round 8) | 2601 | 2590 | 11 | 0 | 0 | | `84a6c041` (the carrier and peer authentication, as ruled) | 2613 | 2602 | 11 | 0 | 0 | | `f8e6e9e9` (fix round 10) | 2619 | 2608 | 11 | 0 | 0 | | `fe232fe` (merges #67's `c8fac05e`, carrying main `047bb4fa`), before its edits | — | — | — | 3 (the bundled background cases) | — | | `19e32f0c` (the merge round's edits) | 3195 | 3184 | 11 | 0 | 0 | | `6eb0bbdb` (merges #67's `cdf7382b`; the env probe expects the child's own state dir) | 3204 | 3193 | 11 | 0 | 0 | | `fedfa75d` (fix round 11, `0488f5bd`, then merges #67's `d1de1fd9` with no file change) | 3210 | 3199 | 11 | 0 | 0 | | `f66e5f82` (fix round 12, `3426c753`, then merges #67's `105f2f12`) | 3215 | 3204 | 11 | 0 | 0 | | `a9854078` (in-process local cases get their own state dir), with local and broker settings, a runner `OPENDOX_STATE_DIR` and a 90-character `XDG_STATE_HOME` exported | 3215 | 3204 | 11 | 0 | 0 | | `21bde1af` (the adversarial review's M1, L1, L2, L3 and replication note) | 3236 | 3225 | 11 | 0 | 0 | | `28e195b9` (fix round 13: the carrier is found as the installed distribution's own files) | 3240 | 3229 | 11 | 0 | 0 | | `57b7ed8f` (fix round 14, `bf9bcb08`, then merges main `66ff7257`) | 3327 | 3316 | 11 | 0 | 0 | | `058d96ef` (fix round 15) | 3328 | 3317 | 11 | 0 | 0 | | `085ba0b0` (merges main `2fc714d2`, #62 T079) | 3346 | 3335 | 11 | 0 | 0 | | `3ebccb3c` (fix round 16) | 3350 | 3339 | 11 | 0 | 0 | | `52a2b8dc` (merges main `9a490405`, #74 T081) | 3399 | 3388 | 11 | 0 | 0 | | `4a9dbe95` (fix round 17) | 3402 | 3391 | 11 | 0 | 0 | | this branch, `c04690f8` (fix round 18, `8986158e` and `c04690f8`) | 3404 | 3393 | 11 | 0 | 0 | `EXPECT_SKIPPED=11` holds exactly, and the floors allow the rise unchanged. The new module adds about 32 s to the run (ten cases, each server start about 1.5 s). **Merging #67's fix rounds.** `95fe16f` merges `32683e8`, and `32db5d8` merges `525f61c`: `runtime migrate` and `reset` refuse what a local install cannot be. - `525f61c` and this PR both rewrite `load_migration_settings`. The conflict resolves to this PR's structure: under `local`, the refusal is asked first, and only then is the bundle's migration DSN read. T070's reason is carried into the comment. - The two merged cases set the local shape as this PR defines it, with the mode and the state dir and no operator DSN. Beside `local` a DSN is itself refused here, so a case that set one would have tested the DSN refusal instead of the broker or bind refusal it names. - A mutant that drops the refusal from the migration loader fails all 7 merged cases. - `ac61596` merges `02dadc5`, cleanly. With the runtime extra absent, `status`'s early return now reports a local install's broker as not configured. The merged case uses this PR's local shape and also asserts that `database_bundle` is reported on that return: present for local, `null` for hosted. ## Fix rounds 4 and 5: Copilot's twelve threads (`5e52872`, `96b2699f`) Copilot reviewed `95fe16f`, `32db5d8` and `ac61596a` and opened twelve threads. **Ten are fixed, answered and resolved.** The new cases are in `tests_runtime/test_local_lifecycle.py` (new, hermetic), plus two in `test_bundled_postgres.py`. - **Migrations** (r4139811473, r4139880241). - An explicit `OPENDOX_MIGRATIONS_DIR` is used as given. - Unset, a **local** install uses only its own installation's copy, never the working directory's. That copy is the source tree `__file__` came from first, then the `RECORD` of the distribution that holds the running module. Where there is none, it is refused. - A **hosted** install's default is unchanged (13.6). - **The pid** (r4139811555, r4139938402). - A pid is believed only when `/proc` proves it is an executable named `postgres` running in this data directory. A proven-stale lock is removed before the launch. - Where there is no `/proc`, nothing is believed, and PostgreSQL's own interlocks stand. The price is a `status` with no pid on macOS and the BSDs, recorded in the thread. - **initdb** (r4139880213): it runs into an attempt directory that is renamed into place only on success. Abandoned attempts are removed. A non-cluster `data/` is refused and left alone. - **start()** (r4139880279): every phase is one guarded operation, and every failure is the one named refusal, with its phase and class. - **Interrupts** (r4139880267): SIGTERM or Ctrl-C anywhere in the local lifecycle is a clean stop, exiting 128 + the signal number. A served run still exits 0. - **Refusal wording** (r4139880298): broker settings and DSNs are two classes, each with its own reason. - **State dir** (r4139938444): an unknown `~user`, or no home, refuses naming `OPENDOX_STATE_DIR`. - **Test helper** (r4139811584): bounded by a selector. With a silent 8 s child and a 1 s deadline, it returned after 1.0 s where the old loop took 8.0 s. Evidence: - Against `ac61596`'s source, 17 of round 4's first 18 cases fail; the one that passes is the unchanged hosted default. Round 5's cases fail against `28bdccd`. - Mutants: 23 of round 4 and 5 of round 5, all killed (`runs/mutants-t072-r4.txt`, `-r5.txt` in the writer's workdir). **Round 6** (`a0fb7c8d`): Copilot's review at `28bdccd9` opened two more threads. - The unknown-`_serves` point was already fixed at `96b2699f`; it is answered and resolved. - The pyproject note named a function that no longer exists. It now names `installation_migrations_dir`, and the packaging case checks every `opendox.runtime.config.<name>` pyproject names. - `4aed6278` merges T070's `026f00ea`, a docstring change. **Round 7** (`0f77d5c1`): Copilot's review at `a0fb7c8d` opened three threads, all fixed and resolved. SonarCloud raised one reliability finding. - **libpq's environment.** Every `PG*` variable is lifted out of `os.environ` for the duration and put back afterwards, around `generate-and-open --local`'s lifecycle and the runtime verbs under `local`. `PGHOSTADDR`, `PGSERVICE` and `PGOPTIONS` can no longer redirect the bundle's connections. The real entry point and `status` are proven with all three set. - **The socket's path.** The resolved state tree must be this user's own and writable by no one else. Every ancestor must be owned by the user or root, and sticky where others can write it; a group-writable ancestor of the user's own group is allowed. Symlinks inside the tree are refused before any chmod. - **A relative HOME** is refused for the default state directory. - **SonarCloud S6466** (reliability): `server_binaries` no longer indexes a list. - Evidence: 8 new cases fail against `a0fb7c8d`, and 11 mutants are killed. **Round 8** (`379fbb14`): Copilot's review at `0f77d5c1` opened two threads, both fixed and resolved. - A group-writable ancestor is refused whatever its group, because a primary group can have other members. - The configured path is checked as configured, as well as resolved: both chains' ancestors, every link's owner, and no `..` anywhere. - Evidence: 5 new cases fail against `0f77d5c1`, and 5 mutants are killed. **Round 9** (`84a6c041`) applies Brett's two rulings, openxFactory#656 [`5916000030`](opensoft/openxFactory#656 (comment)) items 2 and 3: the carrier and peer authentication, as described above. - **The server confirms it**, in its own views: - `pg_hba_file_rules` has exactly the one peer rule (with `map=opendox`) and the two rejects; - `pg_ident_file_mappings` has exactly the two mappings, for this OS user; - `system_user` is `peer:<os user>` for both roles. - **The map decides.** The same OS user asking for a role outside the map is refused (`peer authentication failed`). A non-root suite cannot connect as a second OS user, so for that case the map, read back from the server, stands: it names no other user. - **Evidence:** 13 new cases fail against `379fbb14`. 9 mutants are killed: - initdb trust; - no map; - a trust rule; - a wildcard system user; - a third role; - no re-assertion; - any name admitted; - files 0644; - the old carrier. The auth mutants are also killed by the real-server cases alone. **Round 10** (`f8e6e9e9`): Copilot's review at `84a6c041` raised three points, all fixed. - An existing `postgres/data` joins the tree check, a broken link included (r4147680113, resolved). - Missing parent directories are created exactly 0700 whatever the umask. `mkdir(parents=True)` under umask 0002 made them group-writable. - Readiness requires the data directory's lock file to name the launched child, so a racing loser cannot adopt the winner's socket. - Evidence: 6 new cases fail against `84a6c041`, and 4 mutants are killed. **SonarCloud S2115, ACCEPTED, as ruled.** - **Issue:** `AaDvyyOCiqwq-gAw53M3`, python:S2115, "Add password protection to this database", on `src/opendox/runtime/config.py` `DatabaseBundle.dsn`. - **New status:** `accept` (SonarCloud now reports it `RESOLVED`). Set with the SonarQube tool on openxFactory#656 `5916000030` item 3's authority. - **Rationale:** the DSN has no password because the server authenticates Unix-socket connections by PEER. The kernel verifies the connecting uid (`SO_PEERCRED`), and `pg_ident.conf` maps only this install's OS user to the two roles. The socket directory is 0700, the server has no TCP listener (`listen_addresses` is empty), and every host connection is rejected. The same rationale is in the DSN's docstring. - **Gate:** after the change, SonarCloud reports the PR's quality gate `OK` on every condition. ## Merge-from-main round (`fe232fe`, `19e32f0c`; 2026-10-02) Phase 2 has landed. This branch now carries #67's `c8fac05e`, which carries #60's `adeb6fed` and main `047bb4fa` (T054 to T058, T055's follow-up #70 and T056's standalone test). 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. Four edits followed, all in `19e32f0c`: 1. **The stand-ins in `tests_runtime/local_entrypoint_driver.py` go.** The driver is deleted. Its stand-ins patched names 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. `test_bundled_postgres.py` now runs the real entry point over T050's fixture, with the validator on. 2. **Every cheap refusal comes before the database start.** Main's T055 added `_refuse_empty_source_options`, so the local path asks it before it builds the bundled server. `tests/test_projection_seams.py`'s empty-option case carries a tripwire bundle, so a regression neither starts a server nor passes. 3. **No child touches the user's state directory.** T070 gave four phase-2 callers `--local`, and here `--local` starts the bundled server, whose `OPENDOX_STATE_DIR` defaults to the user's `~/.local/state/opendox`. `tests/standalone_child.py` now gives every child a fresh, short, private state directory under `/tmp` and removes it when the child stops. Measured before: three children of T056 and T058 initialized a cluster in the (sandboxed) default state home. 4. **T056's case 3 asserts it:** while serving, its bundled server's data directory is under the child's own state directory, and the directory is gone after the stop. - **Mutants**, all four killed: the refusal dropped; no private state dir; the dir not removed; the fixture not a repository. - **Full suite:** `3195 selected, 3184 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-child-*`. ## Merge of #67's fix rounds (`94254b18`, `6eb0bbdb`; 2026-10-02) `94254b18` merges #67's `cdf7382b`, which carries #60's `c39d960e` (a PostgreSQL scheme libpq would not read as a URI is refused). `cdf7382b` itself means a `--local` caller inherits none of the runner's runtime settings. There were two docstring and setup conflicts, and both were resolved by keeping both sides: - **`tests/standalone_child.py`:** the code merged cleanly in the needed order. The child's environment first drops every `SETTING_NAMES` entry, and only then is `OPENDOX_STATE_DIR` set to the child's own directory. - **`tests/test_projection_seams.py`:** the empty-option case scrubs the settings and keeps this PR's bundled-server tripwire. `6eb0bbdb` changes #67's harness probe. On #67 it asserted that a child sees no runtime setting. Here every child is given exactly one, its private state directory. The probe now also exports a runner state directory, and asserts three things: - the child sees exactly `OPENDOX_STATE_DIR` among the runtime settings; - its value is the child's own `Child.state_dir`, not the runner's; - the directory is gone once the child has exited. Evidence: - **Mutants**, all four killed, under an exported hosted install's settings: - the child keeps the runner's settings; - the scrub runs after the state dir is set; - the runner's own state dir is passed through; - the in-process case does not scrub. - **Exported settings:** the child-driven modules (`tests/test_standalone_generate_path.py`, `tests/test_post_render_validator.py`) pass whole with a hosted install's settings and a runner `OPENDOX_STATE_DIR` exported. - **Full suite:** `3204 selected, 3193 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-child-*`. ## Fix round 11: nothing is made through a path the tree check would refuse (`0488f5bd`, then `fedfa75d`) Copilot's review at `19e32f0c` opened one thread, which is real (reproduced) and is now answered and resolved. `_prepare_directories` made the missing `postgres/run` before the tree check judged the path, so a component it refuses had already been written through: another user's link, or a 0777 directory. In a sticky parent such as `/tmp`, another user could also plant the state directory's name between the check and the `mkdir`. The fix: - **What exists is judged before any write.** `_refuse_an_unsafe_tree(existing_only=True)` runs the link-ownership loop first, so even a broken foreign link is named. The whole tree is judged again afterwards, before the socket directory's `chmod`. - **Missing components are made by descriptor.** `_make_private_directories` makes each one relative to its parent's descriptor and opens it with `O_NOFOLLOW`. `fstat` must show it is this user's alone before anything is made beneath it. A planted link, non-directory or foreign directory is the named refusal: never followed, never re-moded. - **No `mkdir`/`chmod` window.** Each component is born 0700 under a umask of 077, and the umask is put back afterwards. Evidence: - **New cases:** six, in `tests_runtime/test_local_lifecycle.py`. All fail against `19e32f0c`'s `bundle.py` and pass here. - **Mutants:** seven, all killed. - **Full suite:** `3210 selected, 3199 passed, 11 skipped, 0 failed`. `fedfa75d` merges #67's `d1de1fd9` (its healthy-local status case reads either DSN form). On this branch that case uses the bundled server, so the conflict resolves to this side and changes no file. ## Fix round 12: the auth files are exactly 0600, and the cluster runs on its own files (`3426c753`, then `f66e5f82`) Copilot's reviews at `6eb0bbdb` and `fedfa75d` opened two threads, both real and now answered and resolved. - **`write_authentication`'s 0600 was only a creation request.** The umask filtered it, and a stale temporary from an interrupted start kept its own mode or was written through as a link. Now the stale temporary is unlinked, the new one is opened `O_CREAT | O_EXCL | O_NOFOLLOW`, and its descriptor is `fchmod`-ed to exactly 0600 before anything is written. A link raced in after the unlink is a refusal, never followed. - **A reused cluster's `postgresql.conf` could redirect `data_directory`, `hba_file` and `ident_file`**, for example to an outside `trust` file. The launch now pins all three on the command line, which outranks every configuration file. Evidence: - **New cases:** four in `tests_runtime/test_local_lifecycle.py` (umask, stale 0644, stale link, raced link) and one real-cluster case in `tests_runtime/test_bundled_postgres.py`. All but the raced-link case fail against `fedfa75d`. - **Mutants:** six, all killed. - **Full suite:** `3215 selected, 3204 passed, 11 skipped, 0 failed`. `f66e5f82` merges #67's `105f2f12` cleanly. It adds an autouse fixture in `tests_runtime/conftest.py` that clears every runtime setting before each case, so a case wanting `OPENDOX_STATE_DIR` sets its own, as every one here already does. ## The adversarial review of `f66e5f82` (`a9854078` to `21bde1af`; 2026-10-02) An adversarial review of `f66e5f82` found nothing high. It found one medium, three lows and a note. Each is fixed in its own commit, each with a new case that fails without it and mutants that are killed. One more hermeticity fix came first. - **`a9854078`: the in-process local cases give themselves a short state directory.** The doxBench entrypoint fixture and the empty-option case in `test_projection_seams.py` scrubbed the settings, and so fell back to the runner's default state directory. When that is too long for a Unix socket, configuration refuses it before the case is reached. Measured with a 90-character `XDG_STATE_HOME`: 4 errors and 1 failure. Each now sets its own short `OPENDOX_STATE_DIR` and removes it afterwards. Nothing is made in it, because the database is stood in. Both mutants are killed. - **M1, medium (`e214477d`): a comma in the state directory is refused.** PostgreSQL splits `-k` on commas, and libpq splits a decoded `host` on them. The review reproduced sockets in two unchecked directories, one of them 0777, while the checked 0700 directory stayed empty. `config.database_bundle` (every bundle's one derivation) now refuses a `,` from `OPENDOX_STATE_DIR`, `XDG_STATE_HOME` or `HOME`, without repeating the value. - **L1 (`c4f5df3c`): the carrier is pinned to `pixeltable-pgserver>=0.6.0,<0.7`, and another major is refused by name.** Before an existing cluster is used, the server's own `postgres --version` is asked against its `PG_VERSION`. Another major, or a server that does not say, is a named refusal, before anything is written. 4 mutants are killed. - **L3 (`b2d80e94`): the directory creation starts from is judged by its descriptor.** `_make_private_directories` now `fstat`-judges the base it opens with `_unsafe_because` before the first `mkdir`. That is the install's own rule for the state directory and below, and the ancestors' rule above it. This makes round 11's rule hold inside the function itself. 2 mutants are killed. - **L2 (`9e2f3030`): the local verbs judge their socket before connecting to it.** `runtime status`, `migrate` and `reset` connected to whatever answered at the bundle's socket path. Reproduced here: `status` on a 0777 tree whose `run` linked to another bundle's socket reported that bundle's applied migrations and exited 0. The fix: - `bundle.refusal_before_connecting` asks the start's tree check (now `bundle.refuse_an_unsafe_tree`) of what exists. It then asks for a live server of THIS data directory: its own `postmaster.pid` must name a live `postgres` whose working directory is this data directory, listening at this socket directory. - **Status's `database` reads `"not probed: <reason>"`** for a local install that fails this check, including one with no server running. It used to read `"unreachable: …"`, found by connecting. - **`migrate` and `reset` refuse as `local-bundle-unverified`.** Hosted is unchanged. - The status database block is re-indented under the new branch; `git diff -w` shows only the branch. - 7 mutants are killed. - **The note (`21bde1af`): no replication connection, logical or physical. Fixed, not only reworded.** `authentication_files`' docstring said a replication connection is refused. A physical one was refused, but a logical one (`replication=database`) was accepted as `peer:<user>`, and `IDENTIFY_SYSTEM` answered (measured). No `pg_hba.conf` rule can tell it from an ordinary connection. So the launch sets `max_wal_senders=0`, and the docstring now says both kinds are refused by the server. The mutant is killed. **Full suite:** `3236 selected, 3225 passed, 11 skipped, 0 failed`. Nothing is left under `~/.local/state/opendox` or `/tmp/odx-*`. ## Fix round 13: the server is found as the installed distribution's own files (`28e195b9`) Copilot's review at `f66e5f82` opened one thread, which is real and is now answered and resolved. `server_binaries` used `importlib.util.find_spec`, which follows `sys.path`. Under `python -m opendox.cli` that starts with the working directory, and a corpus checkout is where it runs. So a checkout holding an executable `pixeltable_pgserver/pginstall/bin/postgres` was run as the database server. The carrier is now looked up by distribution name (`importlib.metadata`), on `sys.path` without the working directory. Both binaries must be files its RECORD lists, inside it and executable. Evidence: - **New case:** the working directory holds an importable package and a forged `.dist-info`, and the server is not taken from it. - **Reworked lookup case:** four refusal shapes. - **Before:** 6 of the 8 fail against `find_spec`. - **Mutants:** 4 killed, 1 equivalent. - **Full suite:** `3240 selected, 3229 passed, 11 skipped, 0 failed`. ## Fix round 14: a named platform gate, resolution failures as reasons, no pid behind a refused tree (`bf9bcb08`) Copilot's review at `21bde1af` opened three threads, all real and now answered and resolved. - **A named platform gate.** The bundle is a POSIX design, but the carrier ships Windows wheels, where a start failed as an `AttributeError`. `bundle.unsupported_platform()` names the missing primitives (`os.getuid`, `O_DIRECTORY`, `O_NOFOLLOW`, `os.fchmod`, `mkdir` with `dir_fd`, `socket.AF_UNIX`). A start and the local verbs' socket check ask it first. - **Resolution failures are reasons.** `refusal_before_connecting()` names a symlink loop (`RuntimeError`) and an embedded NUL (`ValueError`) as reasons, beside `OSError`. - **No pid behind a refused tree.** `bundle.report()` reports a pid only behind a verified tree. A `postgres/data` linked to another live bundle had reported that server's pid. Evidence: - **New cases:** three hermetic ones, and a `linked-data` shape with null-pid assertions in the real verbs case. - **Mutants:** eight, all killed. ## Merge of main after #67 landed (`57b7ed8f`; 2026-10-03) #67 (T070) landed as a squash, `66ff7257`, after #61 (T078, `8a98e317`). Main thus holds T070's content as one commit this branch's history never saw, and a plain merge against the old base `047bb4fa` conflicted in ten files where both sides carry the same T070 text. So the merge is computed against the T070 state this branch already held, #67's `105f2f12`: `git merge-tree --write-tree --merge-base=105f2f12 HEAD origin/main`. It is recorded with both parents. The base is sound because `git diff 2526245 66ff725` (#67's last branch head against its squash) names only #61's three files. Against it the merge is clean: - **18 files** take main's changes since `105f2f12`: #65 T088, #71 T085, #61 T078, and #67's `--local` in #71's test. - **17 of them are byte-identical to main's.** `src/opendox/cli.py` is the one merged file: main's two `doxbench_defaults` registrations sit beside this branch's local path. - **No standalone `generate-and-open` child main brought in lacks `--local`.** #71's case gained it in #67's merge. #65's lens test runs `generate` and `serve.build_server`. #61 runs no entry point. Under T072, #71's `--local` child starts a bundled server in `tests/standalone_child.py`'s private state directory. - **Full suite:** `3327 selected, 3316 passed, 11 skipped, 0 failed`. ## Fix round 15: on Linux the parent-death signal is armed, or the server is not started (`058d96ef`) Copilot's review at `57b7ed8f` opened one thread, which is real and is now answered and resolved. `ctypes` reports a failed `prctl()` by returning `-1`, never by raising (a seccomp denial, say), and the child ignored it. A killed entry point could then orphan the server, against R1Q16 (iv). The fix: - **A failed `prctl` is a refusal.** The child now raises on a nonzero return. `subprocess` re-raises that as `SubprocessError`, which `_launch` names as the refusal "could not be given its parent-death signal", with no server process left. - **A Linux C library with no `prctl` is the same refusal**, where it used to fall back silently. Other platforms are unchanged. Evidence: - **New case (Linux):** a stand-in `prctl` that returns `-1`. The start refuses by name, and the stand-in server never runs. - **Mutants:** both killed. - **Full suite:** `3328 selected, 3317 passed, 11 skipped, 0 failed`. ## Merge of main after #62 landed (`085ba0b0`; 2026-10-03) `085ba0b0` merges main `2fc714d2` (#62, T079). #62 changes only `doxbench_binding.py`, `doxbench_intake.py`, `doxbench_provider.py` and `test_model_provider_broker.py`, none of which this branch touches, so the merge is clean. It runs no `generate-and-open` child, so it needs no `--local`. Full suite: `3346 selected, 3335 passed, 11 skipped, 0 failed`. ## Fix round 16: a local install's served role and database are the bundle's own (`3ebccb3c`) Copilot's review at `085ba0b0` opened one thread, which is real and is now answered and resolved. Under `local`, `OPENDOX_RUNTIME_PG_ROLE` could replace the bundle's served role in the settings. The migration run narrows the ledger privileges of exactly that configured role, while the bundle bootstraps `opendox_runtime` with the default DML and its served DSN connects as `opendox_runtime`. So `OPENDOX_RUNTIME_PG_ROLE=pg_read_all_data` left `opendox_runtime` able to rewrite `opendox_schema_migrations`. `refuse_what_a_local_install_cannot_be` (asked by both loaders and by `generate-and-open --local`) now accepts two settings only unset or naming the bundle's own: - `OPENDOX_RUNTIME_PG_ROLE` must be `opendox_runtime`; - `OPENDOX_SERVED_DATABASE` must be `opendox`, since it declares the same identity. Hosted is unchanged. Evidence: - **New case:** both settings, through both loaders. - **Mutants:** both killed. - **Full suite:** `3350 selected, 3339 passed, 11 skipped, 0 failed`. ## Merge of main after #74 landed (`52a2b8dc`; 2026-10-03) `52a2b8dc` merges main `9a490405` (#74, T081), with no overlap. #74's standalone `generate-and-open` fixture already passes `--local`, since it landed after #67. Under T072 that child now starts a bundled server in the harness's private state directory. - **`tests/test_chat_model_configuration.py`:** 49 passed. - **Full suite:** `3399 selected, 3388 passed, 11 skipped, 0 failed`. ## Fix round 17: the `/proc` falsifier is gated, and a backslash in the map is pinned literal (`4a9dbe95`) Copilot's review at `52a2b8dc` opened two threads, both now answered and resolved. - **The `/proc` falsifier is gated.** `test_the_entry_point_owns_a_migrated_server_with_no_tcp_listener` reads Linux's `/proc`, so it now skips where `/proc` is absent, as the other `/proc` and PDEATHSIG cases do. On Linux, and in CI, nothing changes. - **Backslashes are pinned literal, not escaped.** The thread suggested escaping backslashes in `pg_ident.conf`. Measured against the bundled PostgreSQL 16.14, `pg_ident_file_mappings` reads `"DOMAIN\alice"`, `"alice\"` and `"a\\b"` back as exactly those names, without error. The tokenizer treats a backslash specially only at the end of a line, never inside quotes, so escaping would map a different name. - The code is unchanged. - A real-server read-back case and a hermetic verbatim assertion pin the behaviour. - The escaping mutant is killed. **Full suite:** `3402 selected, 3391 passed, 11 skipped, 0 failed`. main has since taken #63 (T080, `1130e996`), whose five files (the doxbench model-provider family) do not overlap this branch. The PR stays MERGEABLE, and CI's merge ref includes it. ## Fix round 18: one schema for both DSNs, and an uninspectable live pid fails closed (`8986158e`, `c04690f8`) Copilot's review at `4a9dbe95` opened two threads. The holder accepted both, and both are now answered and resolved. - **`8986158e`: both bundle DSNs name `search_path=public`.** Left implicit, PostgreSQL's `"$user", public` let a reused cluster with a schema named `opendox` or `opendox_runtime` split the owner's ledger from the served role's reads. - **New real-server case:** both role-named schemas are created, then the bundle restarts. Both roles' `current_schema()` is `public`, they read one ledger, nothing is re-applied, and `status` is reachable with nothing pending. - **Before:** without the pin, the restart fails with `RuntimeAccessMissingError`. - **Mutants:** 2 killed. - **`c04690f8`: a live pid `/proc` will not describe is unknown, not "not ours".** `_identity` raises `_Withheld` for anything but a vanished entry, and `_remove_a_proven_stale_lock` keeps the lock and refuses the start by name rather than unlinking a possibly live server's lock. With no `/proc` at all, PostgreSQL still judges. - **New case:** a live pid whose `/proc` links are withheld. It fails against `4a9dbe95`. - **Mutants:** 2 killed. **Full suite:** `3404 selected, 3393 passed, 11 skipped, 0 failed`, run with `LANG=C.UTF-8` and no `GIT_*`/`XF_*`. ## Downstream, for the holder - **Out of scope, and not T072's: `serve.py` cannot bind `::1`.** This is pre-existing, measured at #60's head `f097fd8`: `gaierror [Errno -9] Address family for hostname not supported`. `serve.LOOPBACK_HOSTS` lists `::1`, but `ThreadingHTTPServer` is IPv4. Recorded on #67 (T070) for a `serve.py` follow-up in that file's single-writer order. This PR does not touch `serve.py`. - **Done at the merge round:** the driver's stand-ins are gone, and the probe runs the real entry point on `tests/fixtures/plain-documents`. - **For T073 and T075, which stack on this PR:** a `--local` child built with `tests/standalone_child.py` gets its own state directory automatically. Any other `--local` caller must set `OPENDOX_STATE_DIR` itself. - **T073** reads `database_bundle` from the server object the entry point keeps (`args.database_bundle`) for `/capabilities`' `install` block. - **T074** runs F13.1 whole, `caps.json` block included. - **Overlap** (`gh pr diff -R opensoft/openDox-code`): `pyproject.toml` (#58) and `cli.py` (#59, and the T058 writer after it). They have landed, and this stack took its merge-from-main round after them. `serve.py` is not touched here. 🤖 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>
… a built-in credential's rules (plan 034) (#64) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034, phase 3. This PR is not one of the plan's tasks. It follows T080 (#63), and gives the broker path the rules T080 gave a built-in credential. It is claimed on openxFactory#656, comment `5889279351`. Plan 034's T080 entry, read at openxFactory `main` `2140f5a7` and again, unchanged, at `main` `a883bbf6`, names this PR as the broker path's own draft, outside T080's scope. **Ruled.** Brett Heap answered #63's closing question, *"Should the broker path follow it?"*, on 2026-09-29: *"Yes, separate phase-3 draft (Recommended)"*. It is recorded at openxFactory#656 comment `5890601202`: *"The broker token path gets T080's protections in openDox-code#64 (stacked after #63). T080's scope is unchanged."* It extends the T080 ruling *"Refuse unless loopback (Recommended)"*, which is recorded at openxFactory#656 comment `5880893901` and ends "The broker path is unchanged in this task". This is the separate draft. **Ruled, for the runner.** Brett Heap answered this PR's second flag on 2026-09-29: *"Yes, add to #64 (Recommended)"*. It is recorded at openxFactory#656 comment `5901112350`, the lane's latest RULED comment, as item 2. When a broker misbehaves (non-zero exit, oversize answer, or timeout after writing), the shared runner's refusal carries no broker output, no cause and no context. This PR now does that for all four broker operations. To keep the diagnostics useful, each refusal names the operation and the failure class, and never the bytes. See *The broker runner* below. **Batch K records the route rule in #1144** (openxFactory#1210 → `39f19145`). Brett Heap's word for the batch is `5916000030`, item 1: *"Yes, amendment batch K (Recommended)"*. - The amendment is a dated note in requirement 17's body in #1144's spec delta, after its SHALL paragraph and above its scenarios. #1144's `tasks.md` points to it after 16.3's batch H addendum. - The note narrows requirement 17's and scenario 17.1's *"any endpoint"* in one respect only: the route a credential travels by. A token a broker mints (`5890601202`) takes the rule that a credential the built-in resolver resolves takes (`5880893901`). Each is sent only over `https://`, or over `http://` to `127.0.0.1`, `[::1]` or `localhost`. - A binding that would present either over `http://` to another host is refused when it is declared (`ENDPOINT_NOT_PRIVATE`), before anything is resolved or minted. The request that presents it follows no redirect, and over plain `http://` it takes no proxy. - The note names this PR as the broker path's half, and #63 as the built-in resolver's. It rewrites no ratified text, and it says the runner ruling (`5901112350`, item 2) does not bear on the route. **This was drafted ahead, and what it waited for has landed.** T063 has landed (openxFactory#1218 → `a883bbf6`), and T080 has landed (#63 → `1130e996`), after T078 (#61) and T079 (#62). So this PR may go READY. Its READY line is the holder's to post. **Based on `main`.** The holder retargeted this PR to `main` when #63 landed. T100 (#82, another writer's PR) is stacked on this branch, so retarget #82 to `main` before this branch is deleted, because a merge with `--delete-branch` closes the PR stacked on it. ## What changes At `main`, and unchanged at #63's head `3f14bb96`, the broker path had the four gaps #63's body listed for Brett. Each was measured over real sockets and a real broker child before it was closed, at `main` `2d116415` and at `3f14bb96` alike. Each is now closed by T080's own rule, reusing #63's helpers. #63's merge of `main`, `4948e6dd`, changes none of the stack's files, so the base reads the same there. | the gap at `3f14bb96` | now | |---|---| | 1. A minted token could be declared over plain `http://` to any host. | Refused when the binding is declared, by the same predicate (`is_a_private_route`) and with the same fixed `ENDPOINT_NOT_PRIVATE` sentence. `mint` asks the predicate again before it asks the broker for anything. | | 2. A POST answered 301, 302 or 303 reached the redirect's target as a GET carrying the token. With `http_proxy` set, a request to `127.0.0.1` went to the proxy with it. | The request follows no redirect (`DIAG_PROVIDER_REDIRECTED`), and over plain `http://` it uses no proxy. | | 3. A refused connection chained urllib's `URLError`, and `do_open.headers`, `_send_request.headers`, `_send_output.msg`, `send.data` and `request.headers` held the token. | Every refusal of a turn that presented a token chains nothing: no cause and no context. | | 4. The token was not checked. One outside latin-1 failed inside urllib as `DIAG_PROVIDER_UNREACHABLE`, which names the wrong party. One with a space or another non-ASCII character was sent as it was. | `mint` asks `_presentable` of it. Any other token is a malformed answer (`DIAG_BROKER_MALFORMED`), refused before the port holds it or any provider is contacted. | **How, in the code:** - `doxbench_binding._require_a_private_route` asks the rule of every record that presents a credential. Only the auth kind `none`, which presents nothing, keeps whatever route it declares. `ENDPOINT_NOT_PRIVATE` now names both credentials. - `BrokeredProviderPort._call_provider` makes every provider request, on both paths. With a credential, it uses `_open_with_a_credential` (#63's opener, renamed) and raises any refusal afresh. #63's `_dispatch_without_a_broker` is rebuilt on it and behaves as before. - The broker branch of `dispatch` answers an expiry outside every handler. The one re-mint and the one paid retry of the 2026-08-26 ruling do what they did, and a refusal raised by either keeps no context. - `mint` reads the answer through `_minted_token`. Any refusal of it is raised again, afresh, after the answer has left the frame that read it. So the refusal of an unpresentable token keeps no frame that holds it, as the built-in resolver's refusal of an unpresentable value keeps none. The first flag below says what else this covers. Since the runner ruling, the wrapper that does this is `_broker_operation`, shared by all four operations. - After Copilot's second review, an answer that could not be read at all is refused too, with the same fixed sentence: - an expiry that is no finite number (an integer past a float's range, `NaN`, `Infinity` or `-Infinity`), in `_parse_expires_at`; - an answer nested past the recursion limit, in `_answer_document`, for all four broker operations. Before, the first escaped `mint` as an `OverflowError` and the last as a `RecursionError`, each with the answer still in its frames. The three non-finite expiries minted a token that would never expire, or would always have expired. - After Copilot's review at `e1a6cb0f`, a provider answer nested past the recursion limit is refused the same way, as `DIAG_PROVIDER_MALFORMED` (`da9e639a`; `a271d307` then names `ValueError` alone for the decode error, SonarCloud S5713). - The parse in `_post_to_provider` serves every path, so a broker's token, the built-in resolver's key and `none` each refuse. - Before, the parse escaped as a `RecursionError`, whose traceback kept the request with its authorization header. It did so at #63's head and at `main` too (measured; flag 12). - `DIAG_PROVIDER_REDIRECTED`, `_DeclineRedirects`, `_presentable` and `_PresentedCredential` now name both credentials. The four gaps left `FIXED_DIAGNOSTICS` at eleven. The runner ruling adds a twelfth (below). ## The broker runner (the second ruling) Measured at `788d764b`, this PR's head before the ruling, with a stand-in broker that wrote a fake token first: | the broker | at `788d764b` | now | |---|---|---| | exits non-zero | refused, but the token stayed in the runner's frame: `answer`, and the child's `_fileobj2output` | `DIAG_BROKER_REFUSED`, keeping nothing | | answers past the 64 KiB bound | the same, and refused as `DIAG_BROKER_MALFORMED`, like an answer of the wrong shape | `DIAG_BROKER_OVERSIZE`, a new sentence, keeping nothing | | times out, with its output open or closed | the refusal chained the `TimeoutExpired`, whose `output`, and whose frames inside `subprocess`, held the token | `DIAG_BROKER_TIMEOUT`, with no cause and no context | | answers in bytes that are not UTF-8 | no refusal at all: a `UnicodeDecodeError` escaped every operation, holding the token in `object` | `DIAG_BROKER_MALFORMED` (or `DIAG_BROKER_TIMEOUT` if it then hangs), keeping nothing | | cannot be started | refused, chaining the `FileNotFoundError` | `DIAG_BROKER_UNREACHABLE`, with no cause and no context | | writes without end (Copilot at `25788f91`) | read in full until the timeout, then refused as a timeout: the bound limited what was accepted, not what was read | refused with `DIAG_BROKER_OVERSIZE` as soon as it passes the bound, and killed | | has a descendant holding its output open (Copilot at `b847ef3d` and `a603a032`) | at `e3eec6b1`, never refused: the reader waited on the pipe after the broker was killed (still waiting after 10 s, with a 0.5 s timeout). At `a603a032`, a descendant that left the group held the refusal for a 2 s grace and left a blocked reader thread, with its descriptor, behind. | refused at the timeout. The broker's process group is killed whole, and this process's ends of both pipes are closed, so no thread or descriptor is left behind. | | never reads a credential larger than its pipe | at `a603a032`, the refusal waited past 10 s: the timeout began only once the credential was written | refused at the timeout, which now covers the credential's streaming too | | its credential source fails mid-copy (the operator's input) | at `b847ef3d`, the broker was left running with its reader blocked, and the interpreter aborted at exit (`Fatal Python error: _enter_buffered_busy`) | the broker is reaped before the error goes on, and what it wrote is dropped. The error itself is unchanged (flag 9). | | leaves a descendant in its group, holding none of its pipes, then exits non-zero or answers in no UTF-8 (Copilot at `a271d307`) | at `a271d307`, refused at once, but the descendant went on running, one more for each such call (measured: both still running a second later) | refused, and what is left of its group is killed while the broker is a zombie, so the group's id cannot have been reused | At `788d764b` no refusal named its operation either. Two of the sentences said "so no token could be minted", whichever operation had failed. **How, in the code:** - The runner's work moves to `_run_broker`, which returns an answer or a sentence and raises no refusal. `subprocess_broker_runner` raises the refusal after it returns. So the refusal is outside every handler, in a frame that never held the child or its answer. - One selector loop in the calling thread (`_answer_of`) streams the credential to the broker in `PIPE_BUF` writes and reads the answer, as `communicate` does on POSIX. There is no reader thread. One deadline covers both. At most one byte past `MAX_BROKER_ANSWER_BYTES` is read, straight into one list, so no other name holds it. The child's exit is awaited within what is left of the timeout. - The broker starts in its own process group (`process_group=0`; the session and the terminal stay this process's). `_reap` kills that group, waits for the broker, and closes this process's ends of both pipes. Anything else that escapes the runner, such as a failing credential source, is reaped the same way before it goes on, and what the broker wrote is dropped. - The answer is decoded as UTF-8, JSON's own encoding, where `communicate` used the locale's. An answer that is not UTF-8 is malformed. - The four operations ask through one wrapper, `_broker_operation`, which replaces `mint`'s own. It raises every refusal again, afresh, with the operation named, after the answer has left its frame. So a runner that was injected is covered too, as are refused `intake`, `revoke` and `list` answers. - `BrokerRefused` takes `operation=`, from `OPERATIONS` only and only beside a broker's sentence (`BROKER_DIAGNOSTICS`). Its message reads `broker <operation>: <sentence>`, for example `broker intake: the credential broker exited non-zero, and its answer is withheld by design`. `.diagnostic` is still the sentence alone, so every caller that compares it is unchanged. - The broker's five sentences now name a failure class and no operation. `FIXED_DIAGNOSTICS` has twelve. - Every refusal of the runner kills what is left of the broker's group: at the timeout, at the bound, after a non-zero exit, and after an answer that is not UTF-8 (`bbcb565e`, after Copilot at `a271d307`). - The broker's exit is read with `os.waitid(…, WNOWAIT)` (`_exit_status_unreaped`, in `f8bc8aca`, after Copilot at `bbcb565e`). - This leaves the broker a zombie, so its pid, which is its group's id, cannot be reused before `_reap` signals the group. - `_reap` signals a group only while the broker is unreaped. - Its wait is bounded by `_REAP_GRACE_SECONDS`, 5 s, as `runtime/local_git_adapter` bounds its own. CPython reaps a broker that outlives that once its `Popen` is collected. - Where `os.waitid` does not exist, the exit is read with a reap, and no group is signalled. - The runner's tail moves to `_settled`, which decodes the answer in place in the one list that holds it. Files: `src/opendox/doxbench_binding.py`, `src/opendox/doxbench_provider.py` and `tests/test_model_provider_broker.py`, all in P3-B's row. The runner's five commits touch the last two only. `cli_model_binding.py`, `serve_workbench.py`, `validate.yml` and `pyproject.toml` are untouched. ## Merging #63, and then `main` (`63e534cb`) `63e534cb` merges `main` `1130e996`, which is T080's squash (#63). This branch was stacked on #63's branch, so the merge takes `main` whole and replays none of #63's own commits. Its tree is the three-way merge of this branch and `main` over their old base, #63's `4948e6dd` (`git merge-tree --merge-base 4948e6d f8bc8ac 1130e99`), with three resolutions and nothing else: - The test module's imports conflicted, each side adding one (`functools` here, `http.client` there). Both are kept. - T080's route-neutral case declares an `http://` endpoint on another host for a broker's binding. Here a broker's minted token is held to a private route too, so the case declares that endpoint for the `none` binding alone. - `ENDPOINT_SCHEME_REFUSED`'s comment names both credentials that `ENDPOINT_NOT_PRIVATE` covers on this branch. The provider merged with no conflict: - `_intake_reference`, where this branch reads an intake answer, holds a broker's reference to the record's rule (#63's M2). - The shared transport maps `http.client.HTTPException` (#63's L4). `main` also brought T078 (#61), T079 (#62), T070 (#67), T081 (#74), T085 (#71), T088 (#65) and T071 (#60). None of them changes this PR's code, and T081's changes to the test module are at cases this PR does not change. Before that, `e1a6cb0f` merged #63's head `4948e6dd`, which carried `main` `047bb4fa` (phase 2's nine landings). Copilot's reviews of that merge and of the heads after it found four things, each fixed here (`da9e639a`, `bbcb565e` and `f8bc8aca`), and SonarCloud two more kinds (`f0d28a79` and `a271d307`). **After the merge, two commits answer the holder and the adversarial review:** - `b8323d01`, the adversarial review's L4 for the broker path. #63 maps an `http.client.HTTPException` in the shared transport to the unreachable sentence, and its case runs a broker's turn too, pinning that sentence only. Here every refusal of a request that carried a credential is raised afresh by `_call_provider`, so for a broker's turn the case also pins no cause, no context, and neither the key nor the token in any frame the refusal keeps. - `e313437b`, the holder's Q2 (flag 13). ## Flags, for Brett and the holder 1. **One step past the fourth gap's letter.** The wrapper that keeps gap 4's own refusal clean covers every refusal of the mint answer. So a malformed answer beside a good token no longer keeps the token either. At `3f14bb96` it did: `mint.answer`, `mint.document`, `_answer_document.text` and `_answer_document.document`. Copilot's second review took the same rule to answers that raised something other than a refusal (see *How* above). This is T080's rule, that no frame a refusal keeps holds the raw credential, applied in the same function. The runner ruling then took it to all four operations. Narrowing it would need a separate cleanup for gap 4's refusal alone; say so if you would rather have that. 2. **Closed: the broker runner's own refusals.** This was the open question at `788d764b`. It is ruled (see *Ruled, for the runner*) and done (see *The broker runner*). 3. **A stored record can stop reading.** A broker binding already stored with plain `http://` to another host now refuses when the document is read. The entry point falls back to the harness declaration and says so. `model-binding edit` and `remove` read the whole document first, so they refuse too, and the operator edits the file by hand to an `https://` or loopback endpoint. T080's refusal of a key in a stored URL set the precedent (`test_a_stored_document_whose_endpoint_carries_a_key_does_not_read`). 4. **T080's scope pin is replaced.** `test_the_loopback_rule_is_the_built_in_resolvers_alone` pinned T080's scope by declaring a broker binding on two plain-`http://` routes. That half is gone. The case is now `test_a_none_binding_keeps_a_route_that_is_not_private`, over all nine routes. #63's body cites the old case among the broker path's pre-existing gaps, which this PR closes. 5. **A reading.** An opener installed process-wide with `urllib.request.install_opener` no longer serves a request that carries a credential. It already did not serve a built-in one. Nothing in openDox installs one. 6. **The operator reads new text.** `model-binding set-credential` and the console's intake route both show a broker refusal's `str()`. That now reads `broker <operation>: <sentence>`, and the broker's sentences are reworded. No test here or in openXdox-code compares the old text; every test compares the constants. `serve_workbench.py`'s intake comment still says a refusal carries "one of FIXED_DIAGNOSTICS and nothing else". The operation it now names comes from a closed vocabulary. The file is left alone: T084 owns it, and #59, which edited it, has landed. 7. **A twelfth sentence.** An answer past the bound had `DIAG_BROKER_MALFORMED`, and now has `DIAG_BROKER_OVERSIZE`, so the refusal names that class. The provider's own bound stays on `DIAG_PROVIDER_MALFORMED`. Say so if you would rather keep eleven, and the old class. 8. **Two cases past the ruling's three.** - An answer that is not UTF-8 was not a refusal at all at `788d764b`. It escaped as a `UnicodeDecodeError` holding the token. It is the same runner and the same rule, so it is refused here too. - Copilot's review at `25788f91` found the bound was checked only after the whole answer was read. The bound now limits the read itself. 9. **Not changed: the input side of `intake`.** This was measured at `25788f91` and again at `b847ef3d`. Since `e3eec6b1` the broker is reaped first, and the error is otherwise unchanged. When the credential's own source fails while it is copied to the broker, the error escapes the runner and is not refused: - A lone surrogate in the credential escapes as a `UnicodeEncodeError` whose `object` holds the credential. `sys.stdin` can yield one under `surrogateescape`. The stand-in broker still received, and stored, the credential without that character. - A source that fails to decode mid-copy escapes as a `UnicodeDecodeError`. This is the operator's input, not the broker's output, so it is outside the ruling. Should a follow-up refuse it? 10. **The runner is POSIX-only now.** The selector loop reads and writes pipes, which Windows' selectors cannot watch, and the process group needs `os.killpg`. On a platform without `killpg` the broker alone is killed. Nothing in this repository runs on Windows, and CI is Linux. Say so if the runner must also run there. 11. **Ruled: the operator's input stays without a time limit.** Copilot at `e75900ff` said a credential source whose `read()` blocks (`sys.stdin` in the CLI, the request body at the console) holds the loop, and the broker, past the deadline. That is an operator or a client still sending the credential, not a broker that misbehaves. Brett Heap ruled on 2026-09-30, openxFactory#656 comment `5916000030`, item 5: *"Leave unbounded (Recommended)"*. Operator input is outside the broker-runner ruling. No code changed for it. 12. **The nested provider answer is fixed here, for every path, and the holder decided it stays here** (2026-10-02). #63 and #64 land back to back after T063, so the window is short, and the fix changes the broker path's failure too, which T080's ruling leaves to this PR. `da9e639a` changes the parse that #63's built-in path and `none` share. Measured at #63's head `4948e6dd`, all three paths escape with a `RecursionError`, and so does `main`'s broker path at `047bb4fa`. #63's body says so. 13. **Every refusal kills what is left of the group, a refused answer included** (the holder's answer, 2026-10-02). Measured at `f8bc8aca`, a broker that left a helper in its group and exited 0 with an undeclared answer was refused as `DIAG_BROKER_MALFORMED`, and the helper was still running a second later. Since `e313437b`, the subprocess runner reads each operation's answer while the broker is a zombie, its exit read with `WNOWAIT` (`_settled`, given `read`). - A refusal of the answer kills the group, and then reaps the broker. - A successful answer reaps the broker alone and leaves the group, since a broker may leave a helper running on purpose. - `_broker_operation` hands its reader to the subprocess runner, and raises a refusal afresh, naming the operation, as before. A runner injected in its place, such as a test's, is given the argv alone, as before. 14. **SonarCloud's S3776 on `_answer_of` is known, and stays** (the holder's answer, 2026-10-02: no refactor in this PR). The runner's selector loop has a cognitive complexity of 28, where 15 is allowed. It was 31 before `f8bc8aca` moved its tail into `_settled`. The quality gate passes, and the runner's mutants pin the code. `e313437b` only passes `read` through it, and its complexity is unchanged. For the holder, downstream: - No downstream file changes. openXdox-code's `tests/test_doxchat_model_intake.py` builds its bindings on `https://` only, and asserts only that an intake refusal has a reason, not its text. - The console's intake route builds its binding inside its existing `BindingRefused` handler (`serve_workbench.py`, left alone). So a posted broker binding on plain `http://` to another host is refused there with the fixed sentence. - At `e313437b`, the only other open openDox-code PR that touches these three files is T100 (#82), stacked on this branch at `63e534cb`. A trial merge of this head with #82's `1ef4c71d` conflicts in one place, `_broker_operation`'s docstring, where each side added a paragraph, and #82's trust check, which goes before the argv is built. Both are kept by the obvious resolution. - T081 (#74) has landed, and this branch carries it through `main` `1130e996`. ## The tests ### The four gaps' cases fail at `3f14bb96` Each gap's cases ran with `3f14bb96`'s `src` (the base, whose broker path is `main`'s) and this branch's test module. They fail there on the behaviour itself, never on a missing name: `DID NOT RAISE`, the wrong sentence, a cause or a context set, a frame holding the token, or an `OverflowError` or `RecursionError` escaping. The controls beside them pass there too. | gap | its cases | at `3f14bb96` | |---|---|---| | 1 | a broker binding refused on nine routes, by the constructor and from a stored record; `mint` asking no broker for a binding forced past the record; the operator door refusing one and storing nothing | 11 fail. 15 controls pass: six private routes, and nine `none` routes. | | 2 and 3 | five redirect codes declined over real sockets, with nothing heard elsewhere; a stand-in proxy hearing nothing; a real refused connection keeping no frame that holds the token; four refusals (unreachable, refused, malformed, expired twice) and a refused re-mint, each with no cause and no context | 12 fail. T080's refactored redirect and proxy cases (6) pass. | | 4 | eight unpresentable tokens refused before any request, with the catalog unavailable and nothing recorded; one outside latin-1, over a real socket; an unpresentable token kept in no frame; eight malformed answers (a bad instant, an undeclared key, JSON cut short, an expiry past a float, `NaN`, `Infinity`, `-Infinity`, nesting past the recursion limit) keeping no frame, cause or context | 18 fail. 2 controls pass: every printable ASCII character but the space is presented unchanged, and the declared answer mints. | The proxy cases, T080's included, now drop `urlopen`'s cached global opener first (`_an_environment_proxy`). `urlopen` reads the proxy environment only when it builds that opener. Without the reset, the broker's proxy case passed at `3f14bb96` whenever an earlier test had built it. T080's redirect and proxy scaffolds are shared with the broker's cases (`_a_provider_that_redirects`, `_an_environment_proxy`). T080's two route lists are shared parametrize marks (`ON_A_PRIVATE_ROUTE`, `NOT_ON_A_PRIVATE_ROUTE`). T080's node ids do not change. ### The runner's cases fail at `788d764b` Each stand-in broker writes a fake token and marks that it did, then misbehaves. Each case checks the mark, so a slow start cannot pass it vacuously. Then it checks three things, in this order: no cause and no context; nothing kept (no frame local, no attribute of one, and nothing in the refusal itself holds the token); and the sentence, the operation and the message. With `788d764b`'s `src` and this branch's test module, 43 of the 44 cases fail, on the behaviour: - 10 hold the token in a frame; - 10 let a `UnicodeDecodeError` escape; - 10 chain a `TimeoutExpired`; - 4 chain a `FileNotFoundError`; - 1 reads until the timeout; - 5 name no operation; - 1 prints the old text; - 2 are the two updated counts. | the runner's cases | cases | at `788d764b` | |---|---|---| | the shared runner, called directly, for six misbehaviours: exits non-zero, answers past the bound, times out, closes its output and times out, answers in no UTF-8, and answers in no UTF-8 and times out | 6 | 6 fail | | each of the four operations, through the real runner, for the same six | 24 | 24 fail | | a broker that writes without end, refused at the bound well inside a 5 s timeout | 1 | fails. It is also the one case that fails at `25788f91` (5.1 s measured). | | a broker that cannot be started, by the runner and by each operation | 4 | 4 fail | | an injected runner's refusal, named by each operation | 4 | 4 fail | | the operation vocabulary, and a provider's sentence refused an operation | 1 | fails | | the operator's `set-credential` door, with a broker that wrote and exited non-zero | 1 | fails | | updated: twelve sentences; the bound's own sentence, with an answer at the bound as its control | 2 | 2 fail | | a guard that the cases ask every declared operation | 1 | passes | Five later cases answer Copilot and a finding of this PR's own, each measured at the head it answers: - a descendant holding the broker's output, in its group and out of it (2). Each is refused at the timeout, leaves no thread and no descriptor, and an in-group descendant is killed. Both hang at `e3eec6b1`. The one that left the group takes 2.5 s at `a603a032`. - a broker that reads 5000 bytes of a 1 MB credential and stops is refused at its 0.5 s timeout. At `a603a032` it held the refusal past 10 s. - a credential source that fails mid-copy, in a child interpreter, exits 1 with its own error. At `b847ef3d` it aborts, 3 of 3 runs. - the same in this process, after the answer has been read: what escapes keeps nothing the broker wrote, and the broker is not left running. It found a local, `chunk`, holding the answer, which is removed. Its two mutants are killed. ### This round's cases - **The adversarial review's L4, for a broker's turn** (`b8323d01`): #63's six `http.client.HTTPException` cases, run for a broker's turn, now pin a fresh raise with no cause, no context, and no key and no token in a kept frame. With #63's transport (`05cb1c70`), where the broker path chains its cause, all six fail on that cause. - **A refused answer kills what is left of the group** (`e313437b`), by each operation. A broker starts a helper in its group, holding none of its pipes, and exits 0 with an answer of no declared kind that carries the token. The refusal names the operation and keeps nothing, the group is signalled once while the broker is a zombie, and the helper is killed. All four fail at `63e534cb`, with `[] == ['Z']`. - **A successful answer leaves the group alone** (`e313437b`), by each operation, as a control. A broker starts a helper and then becomes the fake broker in the same process. No group is signalled, and the helper still runs. All four pass at `63e534cb` too. ### Copilot's rounds after the merge of `main` - **A provider answer nested past the recursion limit** (Copilot at `e1a6cb0f`), once for each credential source: a broker's token, the built-in resolver's key, and `none`. All three fail at `e1a6cb0f`, with the `RecursionError`. - **A refusal kills what is left of the group** (Copilot at `a271d307`). The broker leaves a descendant holding none of its pipes, then exits non-zero or answers in no UTF-8. Both cases fail at `a271d307`, with the descendant still running. Since `f8bc8aca`, each also checks that the group is signalled once, while the broker is a zombie. With `bbcb565e`'s provider that reads `[None]`. - **Where the exit cannot be read unreaped, no group is signalled** (Copilot at `bbcb565e`). With `os.waitid` removed, no `killpg` comes after the reap. With `bbcb565e`'s provider, one did. - **A refusal waits on a killed broker only so long** (Copilot at `bbcb565e`). The broker's `wait` behaves like that of a broker stuck in uninterruptible I/O. With `bbcb565e`'s provider the case fails: `an unbounded wait`. **Forty-seven mutants**, each run against the whole module, and each killed. The first thirty-five were run at `e75900ff`, and the last twelve at the heads named: | mutant | tests that fail | |---|---| | the declaration's rule back to the built-in resolver alone | 10: the nine broker routes, and the operator door | | `mint`'s own route check removed | 1: `test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private` | | the rule asked of `none` too | 10: the nine `none` routes, and T080's operator-door case | | the credential opener kept for the built-in resolver alone | 6: the five broker redirects, and the broker's proxy case | | the redirect-declining handler dropped | 10: every redirect, on both paths | | the proxy bypass removed | 2: the proxy case, on both paths | | a refusal re-raised with its chain, whatever was presented | 5: three chain cases, and both refused-connection cases | | the expiry answered inside its handler, as at the base | 2: expired twice, and the refused re-mint | | the token's check back to non-blank text | 10: the eight tokens, the real socket, and the frame case | | each operation's refusals raised where they happen (no wrapper) | 42 | | the answer kept in the operation's frame | 9: the eight malformed answers, and the unpresentable token's frames | | the operation's refusal raised inside its handler | 43 | | the finite check on an expiry removed | 4: an expiry past a float, `NaN` and both infinities | | an integer past a float's range left to `float()` | 1: an expiry past a float | | a `RecursionError` from the JSON reader not caught | 1: the answer nested past the limit | | runner: the non-zero exit's answer handed back beside the refusal | 1: the runner's exit case | | runner: the non-zero exit refused where it is seen, as at `788d764b` | 1: the same | | runner: past the bound refused as `DIAG_BROKER_MALFORMED`, as at `788d764b` | 7 | | runner: past the bound refused where it is seen | 2 | | runner: the bound's check removed, so the whole output is read | 7 | | runner: a timeout on the exit refused inside its handler | 1: the runner's closed-output case | | runner: a timeout in the loop refused where it is seen | 2: the runner's two open-output timeouts | | runner: no deadline on the child's exit | 5: the closed-output case, by the runner and by each operation | | runner: the credential written whole, blocking past the deadline | 1: the broker that never reads the credential | | runner: the decode error not caught | 5: no UTF-8, by the runner and by each operation | | runner: a broker that cannot be started refused inside the handler | 4 | | runner: anything else escaping, the broker not reaped | 1: the failing source, in this process | | runner: anything else escaping, what the broker wrote kept | 1: the same | | runner: a descendant, the broker killed alone | 1: the descendant in its group | | runner: a descendant, the broker given no group of its own | 1: the same | | the refusal names no operation | 34 | | an undeclared operation accepted | 1: the vocabulary case | | an operation named beside a provider's sentence | 1: the same | | the message does not name the operation | 30 | | (control) the presentability test too strict | 2: the printable-ASCII cases, on both paths | | the provider's parse without `RecursionError` (`da9e639a`, and again at `a271d307`) | 3: the nested provider answer, on every path | | the provider's parse without `ValueError` (`a271d307`) | 1: `test_a_refusal_of_a_turn_that_presented_a_token_chains_nothing[malformed]`, with a `JSONDecodeError` | | runner: no reap after a non-zero exit (`bbcb565e`, and again at `f8bc8aca`) | 1: its own group case | | runner: no reap after an answer in no UTF-8 (`bbcb565e`) | 1: its own group case | | runner: the broker reaped before its group is signalled (`f8bc8aca`) | 2: both group cases | | runner: `waitid` without `WNOWAIT`, so reading the exit reaps (`f8bc8aca`) | 2: both group cases | | runner: the group signalled after a reap (`f8bc8aca`) | 1: the case without `os.waitid` | | runner: the reap's wait unbounded (`f8bc8aca`) | 1: the bounded-wait case | | a refusal of a credentialed request re-raised with its cause (`b8323d01`) | 19, among them the six broker L4 cases | | runner: a refused answer reaped without the group kill (`e313437b`) | 4: the refused answer, by each operation | | runner: a successful answer kills the group (`e313437b`) | 4: the control, by each operation | | runner: the in-place read unused (`e313437b`) | 4: the refused answer, by each operation | ## Review Each inline thread is answered with evidence and resolved. | Copilot at | verdict | threads | answered in | |---|---|---|---| | `af457e66` | Needs a closer look | the redirect case's docstring read as repeating 303 | `a2c838a0`, the docstring only | | `a2c838a0` | Changes recommended | an expiry past a float's range escaped `mint`, and `NaN` and the infinities were taken | `788d764b`, which also closed the `RecursionError` of the same class | | `788d764b` | Needs a closer look | none. Its summary names the stacked prerequisites, which are the draft flag at the top. | | | `25788f91` | Changes recommended | the answer's bound limited what was accepted, not what was read (high); this description was stale (low) | `b847ef3d`, and this description | | `b847ef3d` | Changes recommended | a descendant holding the broker's output made `_reap` wait forever; this description was stale (read before it was updated) | `a603a032`, and this description. `e3eec6b1` between them reaps a broker whose credential source fails. | | `a603a032` | Changes recommended | a descendant that left the group left a blocked reader thread and its descriptor behind each refusal; that reader could add to the answer after it was dropped | `e75900ff` | | `e75900ff` | Changes recommended | a credential source whose `read()` blocks holds the loop past the deadline | ruled: left unbounded (flag 11). The thread is answered with the ruling and resolved, and Copilot was asked again at `e75900ff`. | | `e75900ff`, asked again | Needs a closer look | none. Its summary asks for a final human review of the subprocess rewrite, and names the stacked prerequisite, which is the draft flag at the top. | | | `e1a6cb0f`, the merge of `main` | Changes recommended | a provider answer nested past the recursion limit escaped `_post_to_provider` as a `RecursionError`, its traceback keeping the request | `da9e639a` | | `f0d28a79` | Needs a closer look | none | | | `a271d307` | Needs a closer look | none, and a note it had missed before: a non-zero or non-UTF-8 refusal left the broker's descendants running | `bbcb565e` | | `bbcb565e` | Changes recommended | the group was signalled after the broker was reaped, so a reused pid could name another group; the reap's wait was unbounded | `f8bc8aca` | | `f8bc8aca` | Needs a closer look | none. Its summary asks for a final human review once the stacked prerequisites land. | | | `f8bc8aca`, asked again through the reviewer API | Needs a closer look | this description was stale: it still named `e75900ff` as this head | this description | | `63e534cb`, the merge of `main` `1130e996` | Needs a closer look | none. Its summary asks for a final human review of the subprocess rewrite, and names the stacked draft dependencies, which have now landed. | | | `e313437b`, L4's broker cases and Q2 | Changes recommended | this description was stale: it still said only the runner's refusals kill the group, and its head, totals and mutant count stopped before this round (moderate) | this description, pushed after the review; reply `4173524883` | SonarCloud's check passed at each head, and its quality gate is OK. Its API read 0 issues at `af457e66` and at `788d764b`. At `e1a6cb0f` it read five, all from the runner's commits of 2026-09-30: four S5778 in the tests, fixed in `f0d28a79`, and one S3776. The catch `da9e639a` touched drew an S5713, fixed in `a271d307`. At `f8bc8aca` one remains, the S3776 on `_answer_of`, and at this head it is the same one, known and kept (flag 14). Its analysis of this head is pending. ## The repository's own suite Local runs of the whole suite, as CI runs it (`python -m pytest -q`), use a PostgreSQL 16 service for `tests_runtime` and `LANG=C.UTF-8`: | tree | passed | skipped | failed | |---|---|---|---| | `main` `1130e996` (T080 landed) | 3475 | 11 | 0 | | `63e534cb`, the merge of `main` | 3585 | 11 | 0 | | this head `e313437b` | 3593 | 11 | 0 | `main` `1130e996`'s row is #63's last head `05cb1c70`'s run, and its tree is `1130e996`'s, byte for byte. Before this round, #63's `4948e6dd` read 3206, `e1a6cb0f` 3309 and `f8bc8aca` 3316. Before phase 2 landed, #63's `3f14bb96` read 2630, `788d764b` (before the runner ruling) 2686, and `e75900ff` 2733. The +118 cases are all in `tests/test_model_provider_broker.py`: - 24 for gap 1 (26 added, and T080's two-route scope case removed); - 12 for gaps 2 and 3; - 20 for gap 4, with the malformed answers; - 47 for the runner; - 3 for the nested provider answer, and 4 for the runner's later rounds: the two group cases, the case without `os.waitid`, and the bounded wait; - 8 for the holder's Q2: the refused answer and its control, by each operation. None skips, and skipped stays at the pinned 11. F16.1's record block still passes (`dialect and model declared; a raw key is refused in a field and in the URL`), and `tests/test_provider_boundary.py` passes (24). CI's `validate` at this head (run `37129092647`) reads `selected=3735 passed=3724 skipped=11 failures=0 errors=0`, against the pins 2476, 2465 and exactly 11. That run checked out GitHub's merge of this head into `main` `5e7ab003`, where T072 (#69) landed after this round's merge. T072 changes none of this PR's files, and the merge is clean, so CI's count includes T072's cases. The floors are not raised here: `validate.yml` was outside the draft-ahead scope, as it was for #61 to #63. 🤖 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>
…_reach retired (plan 034) (#77) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034 (`specs/034-opendox-standalone-operation/tasks.md`, read at openxFactory `main` `2140f5a7`), phase 3: - **T084, the last deferred reaches** (#1144's 4.3, with T007's batch G and batch L addenda). - **Realizes:** 4.3. - **Falsifiers:** - F4.1 whole; - `tests/test_capability_honesty.py` (new); - `tests/test_rejection_report.py` (new). - **After:** T073 (openDox-code#72, landed as `90ac7033`), for `serve.py`'s single-writer order T055 → T073 → T084, and T070 for `cli.py`'s, T070 → T084. - **Based on `main`**, retargeted by the holder once T073 landed, so this diff is T084 alone: its own 28 files, the same list as `93f77dc1..7536bfe`. - While it was stacked on #72's branch, #72 was merged in at each of its moves, never rebased: `620bee27` at `b333bf16`, then #72's final pre-squash head `93f77dc1` at `7536bfe2`. That head carries #69's final pre-squash head `c04690f8` and openDox-code `main` `5e7ab003` (#69's squash). Through stack A it also carries T085 (#71), T088 (#65), T071 (#60), T078 (#61), T070 (#67), T079 (#62) and T081 (#74). - After #72 landed as the squash `90ac7033`, `main` was merged at `d556c3fb` with `git merge-tree --merge-base 93f77dc`, recording both parents, so the squash's copy of T073 is not merged twice. `90ac7033`'s tree equals `93f77dc1` merged onto `8e377823` over `5e7ab003`. Through `main` this branch also takes T075 (#73) and the broker-path hardening (#64). **Ruled:** - R1Q1 (a), R1Q22 (a), `5817152735`; - R1Q10 (a), `5850003126`; - items 1 and 3 of `5920216845`; - the model approval: Brett Heap, 2026-10-02, "Refuse by name, hide intake (Recommended)", confirmed at `5961364221`, item 1; - the chat scope: `5961651355`, "Tile's own documents editable (Recommended)"; - drafted now: `5960162524`. Claimed on openxFactory#656 in [`5960235138`](opensoft/openxFactory#656 (comment)). T063 has landed (openxFactory#1218 → `a883bbf6`), and T073 has landed (openDox-code#72 → `90ac7033`). The holder posts READY and the landers merge. ## F4.1 whole At this branch's base (T073's `bb05e3d7`, before T084), the scan lists eleven reaches: ``` AssertionError: 11 deferred reach(es) still name the consumer or the publisher: src/opendox/branch_session.py:1592: openxdox.register src/opendox/branch_session.py:2010: openxdox src/opendox/serve_project.py:271: openxdox.gate_console src/opendox/serve_project.py:272: openxdox.kickoff src/opendox/serve_workbench.py:1219: openxdox src/opendox/serve_workbench.py:1669: openxdox src/opendox/serve_workbench.py:2611: openxdox src/opendox/serve_workbench.py:351: openxdox src/opendox/serve_workbench.py:411: openxdox src/opendox/serve_workbench.py:412: openxdox src/opendox/serve_workbench.py:548: openxdox ``` `src/opendox/consumer_reach.py` was present. At `b333bf16`, and again at `d556c3fb` after the merge of `main`: ``` no deferred reach names the consumer or the publisher ``` `src/opendox/consumer_reach.py` is absent. `tests/test_projection_seams.py::test_the_stand_ins_module_is_retired` holds the absence. ## What it does, commit by commit 1. **Every broken rule, once** (`5920216845` item 3). `cli._report_non_conformance` prints each broken rule id once, with its count and up to five of the places it is broken, then the validator's other lines. - `tests/test_rejection_report.py` (new) covers a snapshot that breaks one rule several times and a second rule once. - Before: 3 failed, 1 passed. After: 6 passed. - Mutants killed: always prints `1` (5 failed), never groups (5 failed), drops the places (3 failed). 2. **Capability honesty** (`5920216845` item 1). `compute_capabilities(route_bindings=)` sets `gate` true only where a contributed binding answers `POST /actions/gate/<verb>`, and `refresh` true only where one answers `POST /actions/refresh`. A standalone server reads both false. - Before, at `047bb4fa`: a standalone server with an identity answered `gate` and `refresh` true, and both routes answered `404 unknown_action`. The module's 20 cases then: 20 failed. After: 20 passed. - Mutants killed: - gate ignores the routes: 4 failed; - refresh ignores the routes: 4 failed; - a predicate that is always true: 5 failed; - every flag off: 6 failed; - gate drops the actor: 1 failed. 3. **The consumer columns' seams.** `opendox.column_seams` declares four seams in `projection_seams`' discipline: `gate`, `scope`, `kickoff` and `register`. `opendox.default_columns` holds openDox's own default for each (R1Q10 (a)). The four entry points register them where no host has, beside `projection_seams`' defaults. - The gate default carries the vocabulary-free primitives as real code. - The governed record functions refuse BY NAME (`GateRecordsNotRegistered`, naming `opendox.column_seams.gate` and its registration call). openDox writes no governed shape it does not own. This is the holder's reading (B). 4. **The routing.** Every reach above now reads its seam: - the chat turn's and the document abstract's scope authority (the abstract's reach moves below its step-1 check); - the thread read's live-session question; - the first-edit Save gate; - model approval; - the project register's prefix and kickoff readers; - `branch_session`'s gate, register and kickoff; - `cli`'s `gate_mod`. The scope value types are openDox's own (`doxbench_scope_types`). 5. **A tile's own documents are editable** (`5961651355`). This supersedes the holder's read-only reading. The neutral scope marks each section a tile projects as owned, as openXdox's authority marks its owned sections: a group's members, a selection's files, and a candidate's claiming groups' members. The set is ONE named function, `default_columns.editable_paths`. Nothing outside the tile is editable, an unresolved row is not, and neither is a created path. 6. **`opendox --help` names the installed command and openDox only.** This is the holder's addition, found by T099's PyPI writer. T084 is `cli.py`'s last phase-3 writer. - The installed help printed `usage: ideation-dashboard` and `cli.py`'s module docstring, which is openxFactory's pre-carve history ("validates it against the pinned openxFactory validator", `python3 -m ideation_dashboard.cli`). - `cli.PROG = "opendox"`, and `PARSER_DESCRIPTION` and `PARSER_EPILOG` are neutral. - Two subcommand help lines in the same listing lose their internal words: `create` ("ideation doc" becomes "document") and `runtime` (drops "(split-opendox § 3.5)"). - `tests/test_installed_help.py` (new) runs the INSTALLED console script, after checking that its entry point is `opendox.cli:main`. It asserts `usage: opendox` and no openxFactory, xFactory, ideation-dashboard, split-opendox or `scripts/` wording. - Before: 3 failed. Mutants killed: the prog reverted, 3 failed; the description back to `__doc__`, 3 failed. 7. **The static bundle's content types are pinned.** This is **the holder's addition**: T084 is `serve.py`'s last phase-3 writer. It comes from T075's finding on openDox-code#73 and realizes 10.2, "reachable in a browser from an openDox-only install". - The static route's `guess_type` reads `extensions_map` first, and the platform's `mimetypes` table only after it. A host whose table maps `.js` to `text/plain` serves every ES module as text, and a browser refuses to run it. Windows reads its table from the registry. - `serve.STATIC_CONTENT_TYPES` pins every extension the bundle ships, measured from a built wheel: 41 files under `opendox/web/`, 39 `.js`, one `.html` and one `.css`. It also pins `.mjs`, `.json`, `.svg`, `.png`, `.ico` and `.woff2`. - Each value is the standard library's built-in one. `.woff2`, which that table lacks, takes its registered type (RFC 8081). - Any other extension falls back to the platform table. 8. **`consumer_reach` is retired.** `LateGateRoutes` and `LateProjectionRoutes` leave `DashboardHandler`'s bases. The gate and projection columns are a host's, composed in through the handler-contribution facet beside the bindings that name their methods (R1Q1 (a)). A host that contributes a binding without its column is refused at wiring, before a socket. openXdox contributes both columns that way at T086. ## The three crash sites, and a fourth `tests/test_capability_honesty.py` runs a standalone `python -m opendox.serve` child with neither sibling importable. Each site gets a structured answer, never `RemoteDisconnected`: - the document abstract: `model_capability_unavailable` at step 1, now that `gate` reads false; - model approval: `approval_refused`, with the sentence naming the gate seam (see below); - `GET /project-register.json`: today's 404 "no project register", from openDox's own kickoff default; - the chat rail's **thread read**, with the exact query the rail sends on opening a document (`repository=fixture&ref=main&tile_kind=cluster&tile_id=barrel-rain&document=<member>`, `views/staging-workbench.js` `loadThread`): the stated no-session absence. T095's AT-R1 harness found this site on openDox-code#75. A query-less GET stops at the 400 check first, which is why batch L's measurement missed it. Composed in-process hosts take the chat turn and the document abstract past their early checks, to the scope step that dropped the connection. The answer is a refusal in the released envelope. **Before** (`a39e0201`, the routing files at their pre-routing state), each of these fails with `http.client.RemoteDisconnected: Remote end closed connection without response`: - the crash sites; - the composed chat turn; - the composed abstract; - the host approval; - the thread read. Before, too, the standalone intake surface read `offered: true`. It now reads `offered: false`, with the reason (next section). ## Model intake and approval (Brett Heap, 2026-10-02; `5961364221` item 1) The enrolment ends in a recorded approval, a governed gate-action record that only a HOST's gate writes. With no host's gate registered (`column_seams.gate_records_writable()`): - `GET /workbench/model-intake` answers `offered: false` with `column_seams.GATE_RECORDS_REFUSAL` as its reason, even beside a hand-written broker block; - the intake act refuses with that sentence, before a broker is spawned or a declaration is written; - so does the approval, before a record is built; - each drains the body it was sent, so the refusal survives its transport. The order is: no gate-record writer first, then no broker. A composed host that registers its gate is offered the flow, and its approval is recorded before the declarations document moves. Mutants of the predicate killed: always true, 3 failed; always false, 2 failed. **Known, and out of this PR's scope:** - `declared_model_port_factory` serves only the FIRST approved binding (`approved[0]`). - An approval's `expires_at` is recorded and not enforced. ## The chat scope (`5961651355`) `tests/test_neutral_turn_scope.py` (new) runs over a composed host: the plain fixture, a loopback bind, an authenticated actor, the binding `opendox model-binding add` declares, and the port the entry points declare over it. The host also has the released validators, as a plane with a readable contract has them. - A turn over cluster `barrel-rain`'s own document passes the guard and reaches the model step. It answers `model_unavailable`, because it names a model the catalog lacks, so nothing is spawned or contacted. - A turn over a corpus document outside the tile is refused `turn_scope_refused`. - Save is still the gate's: `POST /actions/gate/first-edit` answers `unknown_action`, and the record a Save writes is refused naming `opendox.column_seams.gate`. - **Standalone** (case 4, a `python -m opendox.serve` child with a binding configured): the same turn passes the guard and reaches the model step, `model_unavailable`, with openDox's own validators (T085, merged in at `3387293e`) and no stand-in. Until that merge the case asserted only a structured answer, as the holder accepted, and the merge commit narrowed it. `tests/test_capability_honesty.py`'s standalone turn with a binding configured likewise now asserts the scope step's `turn_scope_refused`. Before (the read-only default): 9 failed. The own-document turn answered 403 `turn_scope_refused`. Mutants killed: - widen the editable set to every corpus document: 7 failed; - empty it: 9 failed; - widen the tile itself to the corpus: 2 failed, including the outside-tile turn. ## The content types `tests/test_static_content_types.py` (new): - under a hostile table (every guess `text/plain`), every bundle file keeps its pinned type; - an extension outside the pin still reads the platform table; - the pinned types are the standard library's built-in ones; - every extension the bundle ships is pinned. Mutant killed: drop the pin, 2 failed. Under the hostile table, `index.html` and every `.js` were served `text/plain`. ## The suite **The whole suite, locally** (`LANG=C.UTF-8`), at `213344ad`: `tests/` `3026 passed, 11 skipped`, and `tests_runtime/` `632 passed, 166 skipped`. The skip count is unchanged from T073's. The database-backed cases skip locally without `OPENDOX_TEST_DATABASE_URL`. No new case skips, so `EXPECT_SKIPPED` does not move. As #67, #69 and #72 did, this PR does not edit `validate.yml`. `tests/test_consumer_reach.py` keeps its direction census. Its `CONVERTED_SITES` guard is retired into `tests/test_projection_seams.py`'s import-time rule, which now holds the column seams' proxies. `opendox.consumer_reach` leaves `NEUTRAL_MODULES`, the census "a removal from which has to be argued for". The argument: the module no longer exists. ## Fix round 1 (`b1db1965`, Copilot's review at `897029d8`) - **A seam registration with the names but not their shape is refused at registration** (r4170607959, r4170608011). `projection_seams._Seam` gains an optional `shape` check, run after the name probe in `register()` and `register_default()`. - `column_seams.gate` requires `GateRefused` to be an exception class, because openDox's verbs catch it. - `column_seams.register` requires `CrossReferenceIndexAdapter` to carry a callable `discover`, because `branch_session` calls it. - Mutant killed: skip the shape check, 2 failed. - **`tests/test_column_seams.py`'s docstring** now describes the scope default as the ruling made it (r4170608087). - **This body's crash-sites section** now states the intake surface's before and after (r4170608052). ## Fix round 2 (`ff70ac1e`, Copilot's review at `3387293e`) - **A gate verb on the default gate is refused, not a traceback** (r4170914922). openDox's own gate default refuses the governed `GateConsole` at construction, and `cli._commission_cli` built the console before its `try`. A contributed gate verb that reached the default with no host's gate registered therefore ended in an uncaught traceback. The console is now built inside the refusal boundary. - `tests/test_column_seams.py::test_a_gate_verb_on_the_default_gate_is_refused_not_a_traceback` asserts exit status 1 and `propose refused: ...`, naming `opendox.column_seams.gate.register(`. Before (`3387293e`): an uncaught `GateRecordsNotRegistered`. ## Fix round 3 (`ebe0a35f`, Copilot's review at `1d6a4f19`) - **A group's edges name documents by id, and the scope projects them by path** (r4171136778). - A group's `document_edges[].document` names a document by ID, and a selection's `files` by PATH (the snapshot schema's `$defs/id` and `$defs/path`). openDox's default scope looked every reference up as a path. - So a valid snapshot with id `notes/soil-test` and path `notes/soil-test.md` left every group and candidate member unresolved, with empty editable and context sets, and valid turns were refused. - The fix: `default_columns._document_index` maps each listed document's id, then its path, to the document's path, as openXdox's authority does. `_section` looks each reference up there, carries the document's path (confined to the root as before) and keeps the reference as the row's id. - A reference that no listed document answers stays unresolved under its own spelling, which must still be a safe path, so the confinement cases are unchanged. - `tests/test_column_seams.py::test_a_group_edge_names_its_document_by_id` covers a snapshot whose ids differ from its paths. Before (`b333bf16`): failed, with `editable_paths` `()` where `('a.md', 'b.md')` was expected. Mutant killed: references looked up as paths only, 1 failed. ## Fix round 4 (`1fb81cbd`, Copilot's review at `d556c3fb`, the holder's ruling: ACCEPT both) - **A selection's files are paths and a group's edges are ids, looked up apart** (r4173844321). - Fix round 3's index answered every reference id first, and it also served a selection's `files`, which are PATHS. The contract does not forbid one document's path from spelling another document's id. - `_document_index` now returns `_DocumentIndex(ids, paths)`. `ids` (id first, then path) serves group edges and a candidate's claiming groups. `paths` (path only) serves a selection's files. - `tests/test_column_seams.py::test_a_selection_file_is_a_path_where_it_spells_another_documents_id`. Before (`d556c3fb`): failed, resolving to `a.md` where `sel.md` was expected. Mutants killed: the files looked up as ids (1 failed); the edges looked up as paths (2 failed). - openXdox's authority (`openxdox/doxbench_scope.py`) still answers a selection's files from its one id-first map. Whether it follows is outside this PR. - **The server's help names loopback as the default, not a promise** (r4173844338): "on a loopback address unless --host names another", with no "locally". `tests/test_installed_help.py::test_the_servers_help_states_loopback_as_the_default_bind`. Before: failed. Mutant killed: the `--host` clause dropped, 1 failed. ## Fix round 5 (`213344ad`, Copilot's review at `1fb81cbd`, the holder's ruling: ACCEPT both) - **An in-root alias of a settings document is the settings document** (r4173903232). - `resolve_within` follows a symlink to the canonical file, but a scope row keeps the spelling it was named by. So `alias.md -> ideation/dashboard/model-provider-bindings.yaml`, or a directory link on the way to one, stayed owned and editable, bypassing M1. - `default_columns._settings_test(root)` now compares the file a row REACHES with the files the settings documents reach. `_without_settings` moves such an alias, under its own name, into the `settings` section that nothing owns: readable, never editable. - `tests/test_column_seams.py::test_an_in_root_alias_of_a_settings_document_is_never_editable` tests a file alias and a directory alias. Before (`1fb81cbd`): 2 failed, e.g. `('a.md', 'b.md', 'alias.md')` where `('a.md', 'b.md')` was expected. Mutants killed: the target comparison dropped (2 failed); the alias compared by spelling (2 failed). - **The typeless package marker is set aside by its reason** (the review's "previously missed" note on `tests/test_static_content_types.py`). - `UNSHIPPED` claimed the wheel leaves `.gitkeep` out, but since T075 it ships (`web/**/.*`). It is renamed `TYPELESS_MARKERS`: the shipped, empty, extensionless marker has no content type to pin. - `test_the_set_aside_markers_are_empty_and_extensionless` holds that reason. Mutant killed: the set widened to `index.html` (2 failed). ## Adversarial review 2 (at `b1db1965`), folded in Each item has a case that failed before its fix, and a mutant that fails it. - **M1, openDox's own settings documents are never a tile's editable material** (`5a3b51ee`). - The model-provider bindings (`doxbench_binding.DEFAULT_BINDINGS_RELPATH`) and the model declarations (`doxbench_intake.DEFAULT_DECLARATIONS_RELPATH`) live in the checkout, so a tile can name them. Under `5961651355` a group listing one made it editable, and a turn's proposal could then rewrite which provider a chat talks to. - `default_columns.SETTINGS_DOCUMENTS` holds both. `resolve_scope` moves them out of every owned section into a trailing `settings` section that nothing owns, so they stay readable. `editable_paths` refuses them whatever section carries them. - The corpus scan's own exclusion (openDox-code#76) is a second layer, not the only one. - Before (`1d6a4f19`): 3 failed. Mutants killed: the function admits settings (1 failed); settings left in owned sections (2 failed). - **L1, an unknown `tile_kind` is a malformed request, never a dropped connection** (`278855d4`). `ScopeKey` refuses one with a `ValueError`, which escaped three routes. Each now answers its fixed code: - the thread read: `invalid_turn_request`; - the document abstract: `invalid_abstract_request`; - the chat turn: `invalid_turn_request`, in the released envelope. - Before (`5a3b51ee`): 3 failed, each `RemoteDisconnected`. - **G7, `generate-and-open` removes the run directory it minted** (`5a9cb0d8`). - With no `--run-dir` it minted `ideation-dashboard-*` and never removed it. Now it mints `opendox-*` (`cli.RUN_DIR_PREFIX`) and removes it when the run ends: a served run stopped by an interrupt (and, in a local install, by SIGTERM), a `--no-serve` run, a refusal, a failure. - A `--run-dir` the caller names is kept. - `tests/test_run_dir_lifetime.py` (new). Before (`278855d4`): 2 failed. Mutant killed: the minted directory kept, 2 failed. - **Known Release 1 limit, accepted by the holder:** a HOSTED run killed by SIGTERM leaves its `opendox-*` directory, because hosted mode installs no SIGTERM handler. A local run reads SIGTERM as an interrupt and cleans up. - **`python -m opendox.serve --help` names openDox only** (`6519660e`). This is the same fix as `opendox --help`, for the server entry point's parser. - `serve.SERVE_PROG = "python -m opendox.serve"`, and `SERVE_DESCRIPTION` is neutral. - Before (`278855d4`): 1 failed. Mutant killed: the prog reverted, 1 failed. - **Not here:** M4 and L2, the DNS-rebinding Host check on every route, belong to **T103**, a new writer stacked on this PR. M2 is #74's writer's, and M3 is covered by the turn patch below. ## Merges, done and ahead - **T085 (#71), merged at `3387293e`.** It conflicted only where measured: `cli.py` and `serve.py`, at the four `register_defaults()` call sites and one import. Both sides are kept, T085's `doxbench_defaults` first and T084's `column_seams` after. - T084's own turn tests needed T082's patch, checked and not taken blind: the turns carry the schema-required `last_assistant_turn_id`, and `_structured()` reads the released failure envelope. Without it, both standalone turns failed with 400 `invalid_turn_request`. - **T088 (#65), merged at `3387293e`,** with the ruled one-line edit in `tests/test_lens_seed_actions.py`: `capabilities["actions"]["gate"] is False` in both runs, with the identity asserted on the resolved `actor` field. - **T070 (#67), T079 (#62) and T081 (#74), merged at `b333bf16`** through stack A's merges of main and #72's merges of #69. - T081's hoisted no-model refusal and this task's scope seam sit two unchanged lines apart in the chat turn, and both are kept. - T082's second patch, which moves `tests/test_chat_model_configuration.py`'s `openxdox` stand-in to a scope authority registered at `opendox.column_seams.scope` (restoring the seam's state exactly), no longer applied once the landed file had moved. It is applied by hand and checked: before, 4 failed and 45 passed; after, 49 passed. - **T073 (#72), its final pre-squash head `93f77dc1`, merged at `7536bfe2`,** a plain merge with no conflict. It brings #69's final rounds (17, 18a and 18b, through `bc85f214`), `main` `5e7ab003` (#69's squash, merged on #72 with `merge-tree --merge-base c04690f`), and #72's Copilot fix round (`BundledServer.report()` reads the process once). - **`main` `90ac7033` (#72's squash), merged at `d556c3fb`** with `git merge-tree --write-tree --merge-base 93f77dc`, recording both parents (`7536bfe2`, `90ac7033`), with no conflict. It brings T075 (#73) and the broker-path hardening (#64), whose `doxbench_binding.py` and `doxbench_provider.py` changes merge clean with T084's. The F4.1 scan stays clean. ## Holder readings in this PR (Brett may overrule) - **The gate default:** primitives as real code, governed record functions refused by name, (B). - **kickoff and register:** answer nothing, and `/project-register.json` keeps its 404. - `hosted_ref_refused` stays `serve.py`'s own. - **The content-type pin** is the holder's addition for 10.2. - **The neutral `opendox --help`** is the holder's addition, from T099's finding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>






Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Plan 034 (
specs/034-opendox-standalone-operation/tasks.md, read at openxFactorymaine369cb25), phase-3 slice P3-I, install mode and the bundle, its first task only:load_settingsrefuses a non-PostgreSQL DSN, naming the one dialect kept. It refuses the same credential in both settings, namingOPENDOX_MIGRATION_DATABASE_URL.OPENDOX_MIGRATION_DATABASE_URLis required only for themigratepath (RULED, see below) —load_settings(served/status/init/project-verb) keeps it optional;load_migration_settings(migrate/reset) already required it, independently of this PR. Falsifier: F13.1'sload_settingsblock. After: T063, T069 (both done: T063 landed as openxFactory#1218 →a883bbf6).Claimed on openxFactory#656 in
5876416600.Authored ahead under the phase-3 draft-ahead rule (Brett, 2026-09-28 ~18:00Z: "Only the independent ones (Recommended)"). Only T071 and T078–T080 (P3-B, a separate stacked PR) are drafted before T063. This PR stayed DRAFT until T063 had landed and the holder said so. T063 has landed (openxFactory#1218 →
a883bbf6, 2026-10-02 23:37:43Z), which closes phase 2, so phase 3 is open and this PR is the first of its stack to go READY.Not T070. 13.4–13.6 (
OPENDOX_INSTALL_MODE, and the--localflag) are untouched — T071's ownAfter:line names T063 and T069 only, and nothing here reads or namesOPENDOX_INSTALL_MODE. 13.2/13.3 build cleanly without it.What it does
_refuse_non_postgresql_dsn(new) refuses either DSN whose URI scheme is notpostgresql://orpostgres://, naming the setting and the dialect found. 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. A DSNurlsplititself cannot parse (an unbracketed IPv6 host, measured) is a namedConfigurationError, never a bare exception — the same shape_split_urlalready covers for the broker settings. This check is a no-op on an ABSENT migration DSN — see 13.3.migrate.OPENDOX_MIGRATION_DATABASE_URLstays optional inload_settings(Setting'srequiredflag isFalse,_optionalreads it,RuntimeSettings.migration_database_urlkeepsstr | None) — RULED "Required only for migrate" (Brett, openxFactory#656, on the claim thread for plan 034's T071, 2026-09-28; see "Resolved by ruling" below). It is never defaulted fromOPENDOX_DATABASE_URL.load_migration_settings(themigrate/resetloader) is unaffected either way: it already independently required one._refuse_the_same_dsn_in_both_settings(new) refuses the two DSNs being the exact same STRING, namingOPENDOX_MIGRATION_DATABASE_URL— asked only once the two are already known to agree on where they land (the existing_refuse_two_dsns_that_select_different_schemas, unchanged, now runs first): two DIFFERENT secrets for one role still pass, exactly as the existing "single-role install" case (test_a_dsn_that_names_no_database_still_reaches_one) documents._refuse_non_postgresql_dsnand_refuse_the_same_dsn_in_both_settingsare no-ops when the migration DSN is absent (the same shape_refuse_two_dsns_that_select_different_schemasalready used for "nothing to compare"), and run exactly as before — dialect → schema-mismatch → collapse — whenever both DSNs are actually given. Optional does not mean unchecked.The ripple (opened, then reverted, by the ruling)
An earlier version of this PR (
91f7973) made the migration DSN required, which broke 27 existing call sites across five test files. The ruling above keeps it optional, so that ripple has been reverted in full, back tomain:tests_runtime/conftest.py,test_api_endpoints.py,test_migrations_apply.pyandtest_runtime_surface.pyare byte-for-bytemainagain — nomigration_dsnfixture, no threading, nothing.test_runtime_cli.py's 20 touched call sites (individualmonkeypatch.setenvblocks,base/envdicts, the shared_stub_uvicornhelper) are reverted the same way.test_two_dsns_that_select_different_schemas_are_refused's "AND A MIGRATION DSN THAT IS SIMPLY ABSENT" case is back to accepted (assert load_settings(...)), which is what the setting being optional again means for that test — with a one-line note on why it was briefly the opposite.test_a_non_postgresql_dsn_is_refused_naming_the_dialect_kept,test_an_unparseable_dsn_is_refused_and_never_raises_a_bare_valueerror,test_the_same_dsn_in_both_settings_is_refused_naming_the_migration_one(all three testload_settingsdirectly, both DSNs always given), plustest_serve_and_status_load_with_no_migration_dsn_configuredandtest_the_collapse_is_refused_through_the_served_workload_too(both at the CLI dispatch level —status/serve— rather than only throughload_settingscalled directly), plustest_migrate_refuses_a_non_postgresql_migration_dsn_at_configuration(f097fd8, a follow-up Copilot review round — see below).test_migrate_refuses_rather_than_borrowing_the_served_identity(pre-existing, untouched) already covered "migrate refuses without it."Fix round:
load_migration_settingsgets the same dialect gate (f097fd8)Copilot's re-review of
b5296f9found that 13.2's dialect gate was wired intoload_settingsonly.load_migration_settings— the loaderruntime migrate/resetactually use — checked only that the migration DSN was non-empty and handed it straight toDatabase, so a non-PostgreSQL migration DSN reached the driver instead of being refused by name at configuration: the same un-named failure 13.2 exists to prevent for the served loader, reachable through the one path F13.1's own falsifier does not call. Fixed with one more call to the existing_refuse_non_postgresql_dsn;test_migrate_refuses_a_non_postgresql_migration_dsn_at_configurationis the dialect-refused twin of the existingtest_migrate_and_reset_need_no_served_identity_and_no_broker(which shows an unreachable-but-valid-dialect migration DSN getting PAST configuration). Finding and reply, thread resolved.A second finding from the same round (here) read a stale, pre-ruling snapshot of this body ("required" language) — by the time it posted, the body already said "optional… required only for migrate" throughout. Replied noting the race and inviting a quote of any sentence still reading that way; thread resolved.
The falsifier (F13.1's
load_settingsblock)Quoted from
openspec/changes/add-neutral-product-standalone-operability/tasks.md, run against this branch:Both assertions pass on this branch (verified interactively; both calls give both DSNs, so the ruling above does not change either outcome; T074 is the task that runs F13.1 whole, later in Group 13, after T072/T073 exist).
The repo's own suite
Measured locally against this branch: this sandbox's host-mapped loopback ports are unreachable (a local environment quirk, not this change — even a fresh container's freshly-published port refuses a host-side connection here), so Postgres is reached at the container's own bridge IP instead of
127.0.0.1, which is otherwise identical tovalidate.yml's own DSN shape.The one failure,
tests/test_model_provider_broker.py::test_the_broker_child_inherits_no_credential_shaped_environment, is pre-existing: it is red the same way against unmodifiedmain(2d116415) in this same sandbox (an ambientLC_CTYPEthis environment sets and the check's allowlist does not name) — unrelated toruntime/config.py, and not touched by this PR. It does not reproduce in this PR's own CI (below), confirming the sandbox-only attribution.Against
main's own last recorded CI triple (2479 selected / 11 skipped,validate.yml's own comment history) this change is +6 / +6 / +0 — six tests (three carried over, three new: two for the ruling, one for the follow-up fix round below), nothing lost, the original ripple fully reverted.validate.yml'sPin the triplefloors (MIN_SELECTED=2476,MIN_PASSED=2465,EXPECT_SKIPPED=11) permit the rise unchanged; I have not touched them.This PR's own CI (
f097fd8):validateandSonarCloud Code Analysisboth green.Merge-from-main round (
adeb6fed, 2026-10-02)Phase 2 has landed, so this branch merges main
047bb4fa: T054 to T058, T055's follow-up #70 and T056's standalone test.src/opendox/runtime/config.pyandtests_runtime/test_runtime_cli.py, and main touches neither.LANG=C.UTF-8andCI=trueagainst apostgres:16:3061 selected, 3050 passed, 11 skipped, 0 failed.EXPECT_SKIPPED=11holds exactly, and the floors are met.LC_CTYPEfailure above does not reproduce in this run. It runs withLANG=C.UTF-8.Fix round: a PostgreSQL scheme libpq would not read as a URI is refused (
c39d960e)Copilot's review at
adeb6fednoted thatpostgresql:foowas still accepted. Answered on the PR.urlsplitreadspostgresql:without//, and any capitalizedPostgreSQL://orPOSTGRES://, as the PostgreSQL scheme. libpq reads none of them as a URI, and its refusal repeats the whole value, password included (measured).postgresql://,postgres://), naming the setting and never the value.adeb6fedand pass here, and 5 mutants are killed.3069 selected, 3058 passed, 11 skipped, 0 failed.Resolved by ruling
Brett ruled on the conflict Copilot's review found (openxFactory#656, on the claim thread for plan 034's T071, 2026-09-28), choosing "Required only for migrate (Recommended)":
OPENDOX_MIGRATION_DATABASE_URLonly on themigratepath.serveandstatuskeep it OPTIONAL, and it is never defaulted fromOPENDOX_DATABASE_URL.This matches #1144 13.3's own text and
deploy/compose/docker-compose.yaml's separation (theopendoxservice never gets a migration DSN;docs/runtime.md§ 3 never lists it as required) — neither file needed a change; both already documented 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.Reworked in
b5296f9per the ruling: the required→optional flip is reverted, the 27-site ripple it forced is reverted with it, and two new CLI-level tests (test_serve_and_status_load_with_no_migration_dsn_configured,test_the_collapse_is_refused_through_the_served_workload_too) prove the ruled shape end to end. The original finding and my first reply (flagging it for a ruling) are in the review thread, now closed out citing the ruling and marked resolved.Scope
Touches:
src/opendox/runtime/config.py,tests_runtime/conftest.py,tests_runtime/test_api_endpoints.py,tests_runtime/test_migrations_apply.py,tests_runtime/test_runtime_cli.py,tests_runtime/test_runtime_surface.py. Nothing else — noconftest.pyat the repo root, nopyproject.toml, no workflow file, noREADME.md, no pin, nodeploy/, nodocs/. Noopenspec/changes/path, so no Rule 6 window applies. The holder's lander merges. T063 has landed (openxFactory#1218 →a883bbf6), so the holder un-drafts it.Arc: neutral-product-standalone-operability
🤖 Generated with Claude Code
Summary by Sourcery
Enforce PostgreSQL-only DSN configuration and preserve separate served and migration credentials across runtime paths.
New Features:
Bug Fixes:
Tests: